fix(#6008): keep stop failure toasts actionable across locales

This commit is contained in:
Rod Boev committed 2026-07-22 12:33:57 -04:00
1 parent dc4078426a
commit 6d9e4ac4c5
5 files changed
+83 -23

No files matched your search

+2 -2
View File
@@ -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');
}
}
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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');
}
));
}
+1 -1
View File
@@ -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();
+78 -18
View File
@@ -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},
]