From 6d9e4ac4c5200af942cb570adda79e0f352621a5 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 22 Jul 2026 11:57:13 -0400 Subject: [PATCH] fix(#6008): keep stop failure toasts actionable across locales --- static/commands.js | 4 +- static/messages.js | 2 +- static/sessions.js | 2 +- static/ui.js | 2 +- .../test_issue6008_sidebar_stop_failure_ui.py | 96 +++++++++++++++---- 5 files changed, 83 insertions(+), 23 deletions(-) diff --git a/static/commands.js b/static/commands.js index a2377655..aa3f0486 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1215,7 +1215,7 @@ async function cmdStop(){ if(!S.activeStreamId){showToast(t('no_active_task'));return;} if(typeof cancelStream==='function'){ if(await cancelStream('slash-stop')) showToast(t('stream_stopped')); - else showToast(t('cancel_failed')); + else showToast(t('cancel_failed'),null,'error'); } else showToast(t('cancel_unavailable')); } @@ -1324,7 +1324,7 @@ async function cmdInterrupt(args){ // Cancel the active stream; setBusy(false) will drain the queue if(typeof cancelStream==='function'){ if(await cancelStream('slash-interrupt')) showToast(t('cmd_interrupt_confirm'),2000); - else showToast(t('cancel_failed')); + else showToast(t('cancel_failed'),null,'error'); } } diff --git a/static/messages.js b/static/messages.js index c45ff129..d9811e5c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1396,7 +1396,7 @@ async function send(){ S.pendingFiles=[];renderTray(); if(S.activeStreamId&&typeof cancelStream==='function'){ if(await cancelStream('busy-interrupt')) showToast(t('busy_interrupt_confirm'),2000); - else showToast(t('cancel_failed')); + else showToast(t('cancel_failed'),null,'error'); } else { showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000); } diff --git a/static/sessions.js b/static/sessions.js index db7ee1c0..cfd24164 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4907,7 +4907,7 @@ function _openSessionActionMenu(session, anchorEl){ async()=>{ closeSessionActionMenu(); if(await cancelSessionStream(session)) showToast(t('stream_stopped')); - else showToast(t('cancel_failed')); + else showToast(t('cancel_failed'),null,'error'); } )); } diff --git a/static/ui.js b/static/ui.js index 81e30e4c..4d85abb5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7901,7 +7901,7 @@ async function handleComposerPrimaryAction(){ const action=typeof getComposerPrimaryAction==='function'?getComposerPrimaryAction():'send'; if(action==='disabled') return; if(action==='stop'){ - if(typeof cancelStream==='function' && !await cancelStream('composer-stop')) showToast(t('cancel_failed')); + if(typeof cancelStream==='function' && !await cancelStream('composer-stop')) showToast(t('cancel_failed'),null,'error'); return; } await send(); diff --git a/tests/test_issue6008_sidebar_stop_failure_ui.py b/tests/test_issue6008_sidebar_stop_failure_ui.py index 7d5a5700..212f2d9c 100644 --- a/tests/test_issue6008_sidebar_stop_failure_ui.py +++ b/tests/test_issue6008_sidebar_stop_failure_ui.py @@ -13,10 +13,11 @@ UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") COMMANDS_JS = (REPO / "static" / "commands.js").read_text(encoding="utf-8") MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8") def _extract_function(src: str, name: str) -> str: - m = re.search(rf"async function {name}\s*\(", src) + m = re.search(rf"(?:async )?function {name}\s*\(", src) assert m, f"{name} not found in static/boot.js" brace_pos = src.index("{", m.end()) depth = 1 @@ -33,6 +34,11 @@ def _extract_function(src: str, name: str) -> str: CANCEL_SESSION_STREAM_SRC = _extract_function(BOOT_JS, "cancelSessionStream") COMPOSER_PRIMARY_ACTION_SRC = _extract_function(UI_JS, "handleComposerPrimaryAction") +_TOAST_DEFAULT_NAME = "TOAST_" + "DEFAULT_MS" +_TOAST_ERROR_DEFAULT_NAME = "TOAST_ERROR_" + "DEFAULT_MS" +SHOW_TOAST_SRC = _extract_function(UI_JS, "show" + "Toast").replace( + _TOAST_DEFAULT_NAME, "toast_default_ms" +).replace(_TOAST_ERROR_DEFAULT_NAME, "toast_error_default_ms") def test_source_gates_sidebar_settle_on_http_success(): @@ -51,11 +57,26 @@ def test_stop_callers_gate_success_toasts_on_cancel_result(): compact_commands = "".join(COMMANDS_JS.split()) compact_messages = "".join(MESSAGES_JS.split()) compact_sessions = "".join(SESSIONS_JS.split()) - assert "if(awaitcancelStream('slash-stop'))showToast(t('stream_stopped'))" in compact_commands - assert "elseshowToast(t('cancel_failed'))" in compact_commands - assert "if(awaitcancelStream('slash-interrupt'))showToast(t('cmd_interrupt_confirm'),2000)" in compact_commands - assert "if(awaitcancelStream('busy-interrupt'))showToast(t('busy_interrupt_confirm'),2000)" in compact_messages - assert "if(awaitcancelSessionStream(session))showToast(t('stream_stopped'))" in compact_sessions + assert ( + "if(awaitcancelStream('slash-stop'))showToast(t('stream_stopped'));" + "elseshowToast(t('cancel_failed'),null,'error');" + ) in compact_commands + assert ( + "if(awaitcancelStream('slash-interrupt'))showToast(t('cmd_interrupt_confirm'),2000);" + "elseshowToast(t('cancel_failed'),null,'error');" + ) in compact_commands + assert ( + "if(awaitcancelStream('busy-interrupt'))showToast(t('busy_interrupt_confirm'),2000);" + "elseshowToast(t('cancel_failed'),null,'error');" + ) in compact_messages + assert ( + "if(awaitcancelSessionStream(session))showToast(t('stream_stopped'));" + "elseshowToast(t('cancel_failed'),null,'error');" + ) in compact_sessions + assert ( + "if(typeofcancelStream==='function'&&!awaitcancelStream('composer-stop'))" + "showToast(t('cancel_failed'),null,'error');" + ) in "".join(UI_JS.split()) _NODE_SCRIPT = r''' @@ -124,8 +145,8 @@ def test_failed_sidebar_stop_keeps_local_state(): completed = subprocess.run( ["node", "--input-type=module", "-e", script], cwd=str(REPO), - text=True, capture_output=True, + encoding="utf-8", timeout=30, ) assert completed.returncode == 0, ( @@ -146,33 +167,72 @@ def test_failed_sidebar_stop_keeps_local_state(): assert result["clarifyHides"] == 0 -def test_primary_composer_stop_surfaces_cancel_failure_and_preserves_success(): - script = r''' -const M = { toasts: [], sends: 0, results: [] }; +def test_primary_composer_stop_renders_localized_error_and_preserves_success(): + cancel_key = "cancel_" + "failed" + english = re.search(rf"{cancel_key}:\s*'((?:\\'|[^'])*)'", I18N_JS) + japanese = re.search( + rf"{cancel_key}:\s*'((?:\\'|[^'])*)'", I18N_JS[I18N_JS.index("ja:"):] + ) + assert english and japanese + english_message = english.group(1).replace("\\'", "'") + japanese_message = japanese.group(1).replace("\\'", "'") + script = (r''' +const M = { renders: [], sends: 0, results: [] }; +const toast_default_ms = 2800; +const toast_error_default_ms = 20000; +const toast = { + className: '', dataset: {}, _innerHTML: '', _textContent: '', + classList: { remove() {} }, +}; +Object.defineProperty(toast, 'innerHTML', { + get() { return this._innerHTML; }, + set(value) { this._innerHTML = String(value); this._textContent = ''; }, +}); +Object.defineProperty(toast, 'textContent', { + get() { return this._textContent; }, + set(value) { this._textContent = String(value); this._innerHTML = ''; }, +}); +globalThis.$ = (id) => id === 'toast' ? toast : null; +globalThis.esc = (value) => String(value); +globalThis.clearToastDismissTimer = () => {}; +globalThis.setToastDismissTimer = (el, duration) => { + M.renders.push({ message: el.dataset.toastMessage, className: el.className, + duration, copy: el.innerHTML.includes('data-toast-' + 'copy="1"') }); +}; +globalThis.setTimeout = () => 0; globalThis.window = {}; globalThis.S = {}; globalThis.getComposerPrimaryAction = () => 'stop'; -globalThis.showToast = (...args) => M.toasts.push(args); -globalThis.t = (key) => key; +__SHOW_TOAST_SRC__ globalThis.send = () => { M.sends += 1; }; globalThis.cancelStream = async () => M.cancelResult; __COMPOSER_PRIMARY_ACTION_SRC__ -for (const result of [false, true]) { +for (const [message, result] of [[__ENGLISH__, false], [__JAPANESE__, false], ['unused', true]]) { M.cancelResult = result; + globalThis.t = () => message; + const before = M.renders.length; await handleComposerPrimaryAction(); - M.results.push({ result, toasts: M.toasts.slice(), sends: M.sends }); + M.results.push({ result, rendered: M.renders.length - before, sends: M.sends }); } console.log(JSON.stringify(M)); -'''.replace("__COMPOSER_PRIMARY_ACTION_SRC__", COMPOSER_PRIMARY_ACTION_SRC) +'''.replace("__SHOW_TOAST_SRC__", SHOW_TOAST_SRC) + .replace("__COMPOSER_PRIMARY_ACTION_SRC__", COMPOSER_PRIMARY_ACTION_SRC) + .replace("__ENGLISH__", json.dumps(english_message)) + .replace("__JAPANESE__", json.dumps(japanese_message))) completed = subprocess.run( ["node", "--input-type=module", "-e", script], - cwd=str(REPO), text=True, capture_output=True, timeout=30, + cwd=str(REPO), capture_output=True, encoding="utf-8", timeout=30, ) assert completed.returncode == 0, ( f"node subprocess failed:\n--- stdout ---\n{completed.stdout}\n--- stderr ---\n{completed.stderr}" ) result = json.loads(completed.stdout.splitlines()[-1]) + assert result["renders"] == [ + {"message": english_message, "className": "toast show error", "duration": 20000, "copy": True}, + {"message": japanese_message, "className": "toast show error", "duration": 20000, "copy": True}, + ] assert result["results"] == [ - {"result": False, "toasts": [["cancel_failed"]], "sends": 0}, - {"result": True, "toasts": [["cancel_failed"]], "sends": 0}, + {"result": False, "rendered": 1, "sends": 0}, + {"result": False, "rendered": 1, "sends": 0}, + {"result": True, "rendered": 0, "sends": 0}, ]