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