Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 15 additions & 1 deletion api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()})

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 20 additions & 2 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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})});
Expand All @@ -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'),
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
89 changes: 89 additions & 0 deletions tests/test_issue2508_session_pin_cap.py
Original file line number Diff line number Diff line change
@@ -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