mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
fix: add sidebar cancel for running sessions
This commit is contained in:
+30
-1
@@ -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'
|
||||
|
||||
|
||||
@@ -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: '선택',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user