From cbb251b8233afbb1e71dc22e005e17818d3e0ffe Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Sun, 3 May 2026 08:38:02 +0800 Subject: [PATCH] fix: add sidebar cancel for running sessions --- static/boot.js | 31 ++++++++++++- static/i18n.js | 18 ++++++++ static/sessions.js | 13 ++++++ tests/test_1466_sidebar_cancel_clarify.py | 54 +++++++++++++++++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tests/test_1466_sidebar_cancel_clarify.py diff --git a/static/boot.js b/static/boot.js index ff420dd8..4c9f1ce7 100644 --- a/static/boot.js +++ b/static/boot.js @@ -3,7 +3,7 @@ async function cancelStream(){ if(!streamId) return; try{ await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'}); - }catch(e){/* cancel request failed — cleanup below still runs */} + }catch(e){/* cancel request failed - cleanup below still runs */} // Clear status unconditionally after the cancel request completes. // The SSE cancel event may also fire, but if the connection is already // closed it won't arrive — so we handle cleanup here as the guaranteed path. @@ -13,6 +13,35 @@ async function cancelStream(){ else setStatus(''); } +async function cancelSessionStream(session){ + const streamId = session&&session.active_stream_id; + const sid = session&&session.session_id; + if(!streamId||!sid) return; + try{ + await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'}); + }catch(e){/* cancel request failed - cleanup below still runs */} + session.active_stream_id=null; + delete INFLIGHT[sid]; + clearInflightState(sid); + if(S.session&&S.session.session_id===sid){ + S.activeStreamId=null; + if(S.session) S.session.active_stream_id=null; + clearInflight(); + setBusy(false); + if(typeof setComposerStatus==='function') setComposerStatus(''); + else setStatus(''); + } + if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){ + stopApprovalPolling(); + hideApprovalCard(true); + } + if(typeof _clarifySessionId!=='undefined' && _clarifySessionId===sid){ + stopClarifyPolling(); + hideClarifyCard(true, 'cancelled'); + } + if(typeof renderSessionList==='function') renderSessionList(); +} + // ── Mobile navigation ────────────────────────────────────────────────────── let _workspacePanelMode='closed'; // 'closed' | 'browse' | 'preview' diff --git a/static/i18n.js b/static/i18n.js index fdcbc42f..eeebb3b1 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -351,6 +351,8 @@ const LOCALES = { session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicated: 'Session duplicated', session_duplicate_failed: 'Duplicate failed: ', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_delete: 'Delete conversation', session_delete_desc: 'Permanently remove this conversation', session_select_mode: 'Select', @@ -1229,6 +1231,8 @@ const LOCALES = { session_duplicate_desc: '同じワークスペースとモデルでコピーを作成', session_duplicated: 'セッションを複製しました', session_duplicate_failed: '複製失敗: ', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_delete: '会話を削除', session_delete_desc: 'この会話を完全に削除', session_select_mode: '選択', @@ -2418,6 +2422,8 @@ const LOCALES = { session_duplicate: 'Duplicate conversation', session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicate_failed: 'Duplicate failed: ', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_duplicated: 'Session duplicated', session_move_project: 'Move to project', session_move_project_desc_has: 'Change the project for this conversation', @@ -3220,6 +3226,8 @@ const LOCALES = { session_duplicate: 'Duplicate conversation', session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicate_failed: 'Duplicate failed: ', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_duplicated: 'Session duplicated', session_move_project: 'Move to project', session_move_project_desc_has: 'Change the project for this conversation', @@ -3784,6 +3792,8 @@ const LOCALES = { session_duplicate: 'Duplicate conversation', session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicate_failed: 'Duplicate failed: ', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_duplicated: 'Session duplicated', session_move_project: 'Move to project', session_move_project_desc_has: 'Change the project for this conversation', @@ -4845,6 +4855,8 @@ const LOCALES = { session_duplicate: 'Duplicate conversation', session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicate_failed: 'Duplicate failed: ', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_duplicated: 'Session duplicated', session_move_project: 'Move to project', session_move_project_desc_has: 'Change the project for this conversation', @@ -5222,6 +5234,8 @@ const LOCALES = { session_duplicate_desc: '建立一個相同工作區與模型的副本', session_duplicated: '對話已複製', session_duplicate_failed: '複製失敗:', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_delete: '刪除對話', session_delete_desc: '永久移除這個對話', session_select_mode: '選取', @@ -6207,6 +6221,8 @@ const LOCALES = { session_duplicate_desc: 'Criar cópia com mesmo workspace e modelo', session_duplicated: 'Sessão duplicada', session_duplicate_failed: 'Falha ao duplicar: ', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_delete: 'Excluir conversa', session_delete_desc: 'Remover permanentemente esta conversa', // settings panel @@ -6980,6 +6996,8 @@ const LOCALES = { session_duplicate_desc: 'Create a copy with the same workspace and model', session_duplicated: 'Session duplicated', session_duplicate_failed: 'Duplicate failed: ', + session_stop_response: 'Stop response', + session_stop_response_desc: 'Cancel the running response for this conversation', session_delete: 'Delete conversation', session_delete_desc: 'Permanently remove this conversation', session_select_mode: '선택', diff --git a/static/sessions.js b/static/sessions.js index eacdb754..551e16a8 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1,5 +1,6 @@ // ── Session action icons (SVG, monochrome, inherit currentColor) ── const ICONS={ + stop:'', pin:'', unpin:'', folder:'', @@ -942,6 +943,18 @@ function _openSessionActionMenu(session, anchorEl){ }catch(err){showToast(t('session_duplicate_failed')+err.message);} } )); + if(session.active_stream_id){ + menu.appendChild(_buildSessionAction( + t('session_stop_response'), + t('session_stop_response_desc'), + ICONS.stop, + async()=>{ + closeSessionActionMenu(); + await cancelSessionStream(session); + showToast(t('stream_stopped')); + } + )); + } menu.appendChild(_buildSessionAction( t('session_delete'), t('session_delete_desc'), diff --git a/tests/test_1466_sidebar_cancel_clarify.py b/tests/test_1466_sidebar_cancel_clarify.py new file mode 100644 index 00000000..2029dc86 --- /dev/null +++ b/tests/test_1466_sidebar_cancel_clarify.py @@ -0,0 +1,54 @@ +"""Regression coverage for issue #1466 sidebar cancel ownership. + +The active pane is only a projection; running state belongs to the session that +owns the stream. Cancelling a running session from the sidebar context menu must +address that session's stream id and must only clear approval/clarify UI owned by +that session. +""" +from pathlib import Path + +ROOT = Path(__file__).parent.parent +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str, window: int = 1800) -> str: + idx = src.find(f"function {name}(") + assert idx >= 0, f"{name} not found" + return src[idx : idx + window] + + +class TestSidebarCancelAction: + def test_running_sidebar_sessions_get_stop_action(self): + """Running sessions need a context-menu cancel action even when not active pane.""" + body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 3200) + assert "session.active_stream_id" in body, ( + "sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId" + ) + assert "cancelSessionStream(session)" in body, ( + "running sidebar sessions must expose a stop action that cancels that session" + ) + assert body.find("cancelSessionStream(session)") < body.find("deleteSession(session.session_id)"), ( + "stop action should appear before destructive delete action" + ) + + def test_cancel_session_stream_uses_session_owned_stream_id(self): + """Cancel-from-sidebar must call /api/chat/cancel with the row's stream id.""" + body = _function_body(BOOT_JS, "cancelSessionStream") + assert "session&&session.active_stream_id" in body or "session && session.active_stream_id" in body + assert "stream_id=${encodeURIComponent(streamId)}" in body + assert "S.activeStreamId" not in body.split("const streamId", 1)[1].split("fetch", 1)[0], ( + "sidebar cancel must not derive the stream id from the active pane global" + ) + + def test_cancel_session_stream_clears_only_owned_clarify_and_approval_cards(self): + """Cancelling A from sidebar must not blanket-clear B's clarify/approval cards.""" + body = _function_body(BOOT_JS, "cancelSessionStream") + assert "_clarifySessionId===sid" in body, ( + "clarify card cleanup must be gated to the cancelled session id" + ) + assert "_approvalSessionId===sid" in body, ( + "approval card cleanup must be gated to the cancelled session id" + ) + assert "hideClarifyCard(true" in body + assert "hideApprovalCard(true" in body