fix: add sidebar cancel for running sessions

This commit is contained in:
Dennis Soong
2026-05-03 08:38:02 +08:00
parent 7fddc331ae
commit cbb251b823
4 changed files with 115 additions and 1 deletions
+30 -1
View File
@@ -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'
+18
View File
@@ -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: '선택',
+13
View File
@@ -1,5 +1,6 @@
// ── Session action icons (SVG, monochrome, inherit currentColor) ──
const ICONS={
stop:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><rect x="4" y="4" width="8" height="8" rx="1.5"/></svg>',
pin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><polygon points="8,1.5 9.8,5.8 14.5,6.2 11,9.4 12,14 8,11.5 4,14 5,9.4 1.5,6.2 6.2,5.8"/></svg>',
unpin:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><polygon points="8,2 9.8,6.2 14.2,6.2 10.7,9.2 12,13.8 8,11 4,13.8 5.3,9.2 1.8,6.2 6.2,6.2"/></svg>',
folder:'<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4.5h4l1.5 1.5H14v7H2z"/></svg>',
@@ -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'),
+54
View File
@@ -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