feat: cap pinned sessions from sidebar
This commit is contained in:
parent
9c983e693a
commit
9ca846eb8f
6 changed files with 129 additions and 3 deletions
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **PR #2614** by @Michaelyklam (refs #2508) — Tighten session pinning around the clarified sidebar workflow: right-clicking a conversation row now opens the existing action menu, pin attempts are capped at three active pinned conversations, and the backend rejects a fourth pin until one is unpinned.
|
||||||
|
|
||||||
## [v0.51.95] — 2026-05-20 — Release BS (stage-388 — 5-PR batch — live tool callback event dedup + browser-only dashboard links + messaging transcript merge alignment + Geist Contrast skin + SSE runtime diagnostics)
|
## [v0.51.95] — 2026-05-20 — Release BS (stage-388 — 5-PR batch — live tool callback event dedup + browser-only dashboard links + messaging transcript merge alignment + Geist Contrast skin + SSE runtime diagnostics)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5631,8 +5631,22 @@ def handle_post(handler, parsed) -> bool:
|
||||||
s = _ensure_full_session_before_mutation(body["session_id"], s)
|
s = _ensure_full_session_before_mutation(body["session_id"], s)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return bad(handler, "Session not found", 404)
|
return bad(handler, "Session not found", 404)
|
||||||
|
pin_requested = bool(body.get("pinned", True))
|
||||||
|
if pin_requested and not getattr(s, "pinned", False):
|
||||||
|
pinned_ids = {
|
||||||
|
getattr(existing, "session_id", None) for existing in all_sessions()
|
||||||
|
if getattr(existing, "pinned", False) and not getattr(existing, "archived", False)
|
||||||
|
}
|
||||||
|
with LOCK:
|
||||||
|
pinned_ids.update(
|
||||||
|
sid for sid, existing in SESSIONS.items()
|
||||||
|
if getattr(existing, "pinned", False) and not getattr(existing, "archived", False)
|
||||||
|
)
|
||||||
|
pinned_ids.discard(body["session_id"])
|
||||||
|
if len(pinned_ids) >= 3:
|
||||||
|
return bad(handler, "Up to 3 sessions can be pinned. Unpin one before pinning another.", 400)
|
||||||
with _get_session_agent_lock(body["session_id"]):
|
with _get_session_agent_lock(body["session_id"]):
|
||||||
s.pinned = bool(body.get("pinned", True))
|
s.pinned = pin_requested
|
||||||
s.save()
|
s.save()
|
||||||
return j(handler, {"ok": True, "session": s.compact()})
|
return j(handler, {"ok": True, "session": s.compact()})
|
||||||
|
|
||||||
|
|
|
||||||
BIN
docs/pr-media/issue-2508/session-pinned-sidebar.png
Normal file
BIN
docs/pr-media/issue-2508/session-pinned-sidebar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
|
|
@ -1475,6 +1475,9 @@ function _sessionSnapshotById(sid){
|
||||||
if(S.session&&S.session.session_id===sid) return S.session;
|
if(S.session&&S.session.session_id===sid) return S.session;
|
||||||
return (_allSessions||[]).find(s=>s&&s.session_id===sid)||null;
|
return (_allSessions||[]).find(s=>s&&s.session_id===sid)||null;
|
||||||
}
|
}
|
||||||
|
function _pinnedSessionCount(){
|
||||||
|
return (_allSessions||[]).filter(s=>s&&s.pinned&&!s.archived).length;
|
||||||
|
}
|
||||||
function _worktreeSessionCount(ids){
|
function _worktreeSessionCount(ids){
|
||||||
return (ids||[]).reduce((count,sid)=>{
|
return (ids||[]).reduce((count,sid)=>{
|
||||||
const session=_sessionSnapshotById(sid);
|
const session=_sessionSnapshotById(sid);
|
||||||
|
|
@ -1785,12 +1788,17 @@ function _openSessionActionMenu(session, anchorEl){
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=3;
|
||||||
menu.appendChild(_buildSessionAction(
|
menu.appendChild(_buildSessionAction(
|
||||||
session.pinned?t('session_unpin'):t('session_pin'),
|
session.pinned?t('session_unpin'):t('session_pin'),
|
||||||
session.pinned?t('session_unpin_desc'):t('session_pin_desc'),
|
pinLimitReached?'Only 3 conversations can be pinned':(session.pinned?t('session_unpin_desc'):t('session_pin_desc')),
|
||||||
session.pinned?ICONS.pin:ICONS.unpin,
|
session.pinned?ICONS.pin:ICONS.unpin,
|
||||||
async()=>{
|
async()=>{
|
||||||
closeSessionActionMenu();
|
closeSessionActionMenu();
|
||||||
|
if(pinLimitReached){
|
||||||
|
if(typeof showToast==='function') showToast('Only 3 conversations can be pinned. Unpin one before pinning another.',3000,'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newPinned=!session.pinned;
|
const newPinned=!session.pinned;
|
||||||
try{
|
try{
|
||||||
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
|
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
|
||||||
|
|
@ -1799,7 +1807,7 @@ function _openSessionActionMenu(session, anchorEl){
|
||||||
renderSessionList();
|
renderSessionList();
|
||||||
}catch(err){showToast(t('session_pin_failed')+err.message);}
|
}catch(err){showToast(t('session_pin_failed')+err.message);}
|
||||||
},
|
},
|
||||||
session.pinned?'is-active':''
|
(session.pinned?'is-active':'')+(pinLimitReached?' is-disabled':'')
|
||||||
));
|
));
|
||||||
menu.appendChild(_buildSessionAction(
|
menu.appendChild(_buildSessionAction(
|
||||||
t('session_move_project'),
|
t('session_move_project'),
|
||||||
|
|
@ -3388,6 +3396,16 @@ function renderSessionListFromCache(){
|
||||||
actions.appendChild(menuBtn);
|
actions.appendChild(menuBtn);
|
||||||
el.appendChild(actions);
|
el.appendChild(actions);
|
||||||
}
|
}
|
||||||
|
el.oncontextmenu=(e)=>{
|
||||||
|
if(readOnly) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
clearTimeout(_tapTimer);
|
||||||
|
_tapTimer=null;
|
||||||
|
_lastTapTime=0;
|
||||||
|
_clearPointerDragState();
|
||||||
|
_openSessionActionMenu(s, actions||el);
|
||||||
|
};
|
||||||
|
|
||||||
// Use pointerup + manual double-tap detection instead of onclick/ondblclick.
|
// Use pointerup + manual double-tap detection instead of onclick/ondblclick.
|
||||||
// onclick/ondblclick are unreliable on touch devices (iPad Safari especially):
|
// onclick/ondblclick are unreliable on touch devices (iPad Safari especially):
|
||||||
|
|
|
||||||
|
|
@ -733,6 +733,8 @@
|
||||||
.session-action-copy{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
.session-action-copy{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
||||||
.session-action-meta{font-size:11px;color:var(--muted);line-height:1.3;white-space:normal;opacity:.72;}
|
.session-action-meta{font-size:11px;color:var(--muted);line-height:1.3;white-space:normal;opacity:.72;}
|
||||||
.session-action-opt.is-active{background:var(--accent-bg);}
|
.session-action-opt.is-active{background:var(--accent-bg);}
|
||||||
|
.session-action-opt.is-disabled{opacity:.55;cursor:not-allowed;}
|
||||||
|
.session-action-opt.is-disabled:hover{background:transparent;}
|
||||||
.session-action-opt.danger:hover{background:rgba(239,83,80,.08);}
|
.session-action-opt.danger:hover{background:rgba(239,83,80,.08);}
|
||||||
.session-action-opt.danger .ws-opt-icon,.session-action-opt.danger .ws-opt-name{color:var(--error);}
|
.session-action-opt.danger .ws-opt-icon,.session-action-opt.danger .ws-opt-name{color:var(--error);}
|
||||||
/* Hide overlay during inline rename */
|
/* Hide overlay during inline rename */
|
||||||
|
|
|
||||||
89
tests/test_issue2508_session_pin_cap.py
Normal file
89
tests/test_issue2508_session_pin_cap.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Regression checks for issue #2508 session pinning bounds and context menu access."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from tests._pytest_port import BASE
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
ROUTES_PY = (ROOT / "api" / "routes.py").read_text()
|
||||||
|
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text()
|
||||||
|
STYLE_CSS = (ROOT / "static" / "style.css").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, body=None):
|
||||||
|
data = json.dumps(body or {}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return json.loads(r.read()), r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return json.loads(e.read()), e.code
|
||||||
|
|
||||||
|
|
||||||
|
def make_session(created):
|
||||||
|
payload = {
|
||||||
|
"title": f"Pin cap {len(created) + 1}",
|
||||||
|
"messages": [{"role": "user", "content": "keep this conversation handy"}],
|
||||||
|
"model": "test/pin-cap",
|
||||||
|
}
|
||||||
|
d, status = post("/api/session/import", payload)
|
||||||
|
assert status == 200
|
||||||
|
sid = d["session"]["session_id"]
|
||||||
|
created.append(sid)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_pin_endpoint_caps_pinned_sessions_at_three():
|
||||||
|
created = []
|
||||||
|
try:
|
||||||
|
pinned = [make_session(created) for _ in range(3)]
|
||||||
|
for sid in pinned:
|
||||||
|
d, status = post("/api/session/pin", {"session_id": sid, "pinned": True})
|
||||||
|
assert status == 200
|
||||||
|
assert d["session"]["pinned"] is True
|
||||||
|
|
||||||
|
fourth = make_session(created)
|
||||||
|
d, status = post("/api/session/pin", {"session_id": fourth, "pinned": True})
|
||||||
|
assert status == 400
|
||||||
|
assert "3 sessions" in d.get("error", "")
|
||||||
|
|
||||||
|
d, status = post("/api/session/pin", {"session_id": pinned[0], "pinned": False})
|
||||||
|
assert status == 200
|
||||||
|
assert d["session"]["pinned"] is False
|
||||||
|
|
||||||
|
d, status = post("/api/session/pin", {"session_id": fourth, "pinned": True})
|
||||||
|
assert status == 200
|
||||||
|
assert d["session"]["pinned"] is True
|
||||||
|
finally:
|
||||||
|
for sid in created:
|
||||||
|
post("/api/session/delete", {"session_id": sid})
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_pin_cap_has_backend_and_frontend_guards():
|
||||||
|
assert 'pinned_ids = {' in ROUTES_PY
|
||||||
|
assert 'pinned_ids.update(' in ROUTES_PY
|
||||||
|
assert 'if len(pinned_ids) >= 3:' in ROUTES_PY
|
||||||
|
assert 'Up to 3 sessions can be pinned' in ROUTES_PY
|
||||||
|
|
||||||
|
assert 'function _pinnedSessionCount()' in SESSIONS_JS
|
||||||
|
assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=3;' in SESSIONS_JS
|
||||||
|
assert "Only 3 conversations can be pinned" in SESSIONS_JS
|
||||||
|
assert ".session-action-opt.is-disabled{opacity:.55;cursor:not-allowed;}" in STYLE_CSS
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_rows_open_action_menu_from_right_click():
|
||||||
|
assert 'el.oncontextmenu=(e)=>{' in SESSIONS_JS
|
||||||
|
context_idx = SESSIONS_JS.find('el.oncontextmenu=(e)=>{')
|
||||||
|
assert context_idx != -1
|
||||||
|
block = SESSIONS_JS[context_idx:SESSIONS_JS.find('};', context_idx) + 2]
|
||||||
|
assert 'e.preventDefault();' in block
|
||||||
|
assert 'e.stopPropagation();' in block
|
||||||
|
assert '_openSessionActionMenu(s, actions||el);' in block
|
||||||
Loading…
Reference in a new issue