diff --git a/CHANGELOG.md b/CHANGELOG.md index 185f15f1..a3667bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [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) diff --git a/api/routes.py b/api/routes.py index 063226bf..a8397efa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5631,8 +5631,22 @@ def handle_post(handler, parsed) -> bool: s = _ensure_full_session_before_mutation(body["session_id"], s) except KeyError: 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"]): - s.pinned = bool(body.get("pinned", True)) + s.pinned = pin_requested s.save() return j(handler, {"ok": True, "session": s.compact()}) diff --git a/docs/pr-media/issue-2508/session-pinned-sidebar.png b/docs/pr-media/issue-2508/session-pinned-sidebar.png new file mode 100644 index 00000000..d424edd4 Binary files /dev/null and b/docs/pr-media/issue-2508/session-pinned-sidebar.png differ diff --git a/static/sessions.js b/static/sessions.js index fc9ae52b..ec8d12ee 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1475,6 +1475,9 @@ function _sessionSnapshotById(sid){ if(S.session&&S.session.session_id===sid) return S.session; 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){ return (ids||[]).reduce((count,sid)=>{ const session=_sessionSnapshotById(sid); @@ -1785,12 +1788,17 @@ function _openSessionActionMenu(session, anchorEl){ } )); } + const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=3; menu.appendChild(_buildSessionAction( 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, async()=>{ 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; try{ 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(); }catch(err){showToast(t('session_pin_failed')+err.message);} }, - session.pinned?'is-active':'' + (session.pinned?'is-active':'')+(pinLimitReached?' is-disabled':'') )); menu.appendChild(_buildSessionAction( t('session_move_project'), @@ -3388,6 +3396,16 @@ function renderSessionListFromCache(){ actions.appendChild(menuBtn); 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. // onclick/ondblclick are unreliable on touch devices (iPad Safari especially): diff --git a/static/style.css b/static/style.css index 8a84a24d..78030f7c 100644 --- a/static/style.css +++ b/static/style.css @@ -733,6 +733,8 @@ .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-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 .ws-opt-icon,.session-action-opt.danger .ws-opt-name{color:var(--error);} /* Hide overlay during inline rename */ diff --git a/tests/test_issue2508_session_pin_cap.py b/tests/test_issue2508_session_pin_cap.py new file mode 100644 index 00000000..b629c7f5 --- /dev/null +++ b/tests/test_issue2508_session_pin_cap.py @@ -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