diff --git a/src/web/public/app.js b/src/web/public/app.js
index d7246a4f..0381fa77 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -381,8 +381,17 @@ function applyPaneExitBadge(tab, paneExit) {
tab.classList.toggle('tab-agent-exited', !!label);
// The tab's aria-label overrides its contents for the accessible name, and the
// badge is aria-hidden like its siblings, so the exit has to ride the label.
+ // Compared with the last English label set (data-aria-source, seeded by the
+ // full render too), never the attribute: in zh-CN the translator rewrites it,
+ // and writing English back on every pass would have it translate again.
const name = tab.querySelector('.tab-name')?.dataset?.fullName;
- if (name) tab.setAttribute('aria-label', paneExitAriaLabel(name, label));
+ if (name) {
+ const aria = paneExitAriaLabel(name, label);
+ if (tab.dataset.ariaSource !== aria) {
+ tab.dataset.ariaSource = aria;
+ tab.setAttribute('aria-label', aria);
+ }
+ }
if (!label) {
existing?.remove();
return;
@@ -391,16 +400,19 @@ function applyPaneExitBadge(tab, paneExit) {
const badge = document.createElement('span');
badge.className = 'tab-exited-badge';
badge.setAttribute('aria-hidden', 'true');
- // Generated status text, like the status pills: it carries data-i18n-skip
- // rather than a dictionary entry. Without it the translator would rewrite
- // the badge and the next render pass would rewrite it back, because the
- // comparison below is against the English string.
- badge.setAttribute('data-i18n-skip', '');
+ // Translated like any other text (i18n.js has "exited" and its exit-code
+ // forms). The comparison below is with the last English label (data-label),
+ // never the DOM, which holds the translation in zh-CN: a DOM compare would
+ // write the English back on every pass for the translator to redo.
+ badge.dataset.label = label;
badge.textContent = label;
tab.querySelector('.tab-name')?.insertAdjacentElement('afterend', badge);
return;
}
- if (existing.textContent !== label) existing.textContent = label;
+ if (existing.dataset.label !== label) {
+ existing.dataset.label = label;
+ existing.textContent = label;
+ }
}
const DEFAULT_SHORTCUTS = [
@@ -6022,7 +6034,7 @@ class CodemanApp {
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `⚙⧉×`;
- rowHtml.set(id, `
+ rowHtml.set(id, `
${_tabIdx < 9 ? '' + (_tabIdx + 1) + '' : ''}
${loadState ? '' : ''}
@@ -6030,7 +6042,7 @@ class CodemanApp {
${mode === 'shell' ? 'sh' : mode === 'opencode' ? 'oc' : mode === 'codex' ? 'cx' : mode === 'gemini' ? 'gm' : mode === 'antigravity' ? 'ag' : mode === 'pi' ? 'pi' : mode === 'grok' ? 'gk' : mode === 'deepseek' ? 'ds' : mode === 'omp' ? 'om' : ''}
${tabLabel}
- ${paneExitBadge ? `${escapeHtml(paneExitBadge)}` : ''}
+ ${paneExitBadge ? `${escapeHtml(paneExitBadge)}` : ''}
${inlineSessionActions ? tabActionsHtml : ''}
detached
diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js
index 7cbebf3e..1ac614e7 100644
--- a/src/web/public/i18n.js
+++ b/src/web/public/i18n.js
@@ -146,6 +146,9 @@
'The agent exited': '智能体已退出',
'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。',
'Could not attach the session': '无法附加会话',
+ // The tab's exited-agent badge (app.js applyPaneExitBadge, Ark0N/Codeman#446);
+ // its exit-code forms and the tab's accessible name are patterns.
+ exited: '已退出',
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
Notifications: '通知',
@@ -1047,6 +1050,16 @@
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
+ // The tab's exited-agent badge, and the tab's accessible name carrying it.
+ // The session name is user text: it passes through untranslated.
+ [/^exited \((-?\d+)\)$/, (_m, code) => `已退出(${code})`],
+ [/^exited \(signal (\d+)\)$/, (_m, signal) => `已退出(信号 ${signal})`],
+ [
+ /^(.+) session, agent exited \(signal (\d+)\)$/,
+ (_m, name, signal) => `${name} 会话,智能体已退出(信号 ${signal})`,
+ ],
+ [/^(.+) session, agent exited \((-?\d+)\)$/, (_m, name, code) => `${name} 会话,智能体已退出(${code})`],
+ [/^(.+) session, agent exited$/, (_m, name) => `${name} 会话,智能体已退出`],
// A session name is user text: it passes through untranslated.
[
/^(.+) was stopped after crashing repeatedly\. Restart it\?$/,
diff --git a/test/i18n-exit-run-help.test.ts b/test/i18n-exit-run-help.test.ts
new file mode 100644
index 00000000..afe67a39
--- /dev/null
+++ b/test/i18n-exit-run-help.test.ts
@@ -0,0 +1,80 @@
+/**
+ * @fileoverview zh-CN coverage for the tab's exited-agent badge (and the tab's
+ * accessible name that carries it).
+ *
+ * Every form `paneExitLabel()` produces (plain, an exit code, a negative code,
+ * a signal) must translate fully through i18n.js's real `t()`, with the
+ * session name in the accessible name passed through untranslated (a name
+ * that is itself a dictionary word included), and read unchanged in English.
+ *
+ * Port: N/A.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { JSDOM } from 'jsdom';
+import { afterAll, describe, expect, it } from 'vitest';
+
+const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
+const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
+const I18N = read('i18n.js');
+const APP = read('app.js');
+
+type Api = { t(s: string): string; configure(o: Record): void };
+function translator(language: string) {
+ const dom = new JSDOM('', {
+ runScripts: 'outside-only',
+ url: 'http://localhost/',
+ });
+ vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
+ const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
+ api.configure({ language });
+ return { api, dom };
+}
+const zh = translator('zh-CN');
+const en = translator('en');
+afterAll(() => {
+ zh.dom.window.close();
+ en.dom.window.close();
+});
+
+/** Latin words left after removing what may stay (key names). */
+const leftover = (text: string) =>
+ text.replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G)\b/g, '').match(/[A-Za-z]+/g) ?? [];
+
+const helpers = new Function(
+ `${APP.match(/function paneExitLabel\([\s\S]*?\n\}/)![0]}\n${APP.match(/function paneExitAriaLabel\([\s\S]*?\n\}/)![0]}\n` +
+ 'return { paneExitLabel, paneExitAriaLabel };'
+)() as { paneExitLabel(p: unknown): string; paneExitAriaLabel(name: string, label: string): string };
+
+const EXITS = [{}, { status: 0 }, { status: 3 }, { status: 137 }, { status: -1 }, { signal: 9 }];
+
+describe('the exited-agent badge in zh-CN', () => {
+ const labels = EXITS.map((p) => helpers.paneExitLabel({ ...p, at: 1 }));
+
+ it('produces the forms this test covers', () => {
+ expect(labels).toEqual(['exited', 'exited (0)', 'exited (3)', 'exited (137)', 'exited (-1)', 'exited (signal 9)']);
+ });
+
+ it('every badge form translates fully, and reads the same in English', () => {
+ const bad = labels.filter((l) => {
+ const text = zh.api.t(l);
+ return text === l || leftover(text).length > 0 || en.api.t(l) !== l;
+ });
+ expect(bad).toEqual([]);
+ expect(zh.api.t('exited (3)')).toBe('已退出(3)');
+ expect(zh.api.t('exited (signal 9)')).toBe('已退出(信号 9)');
+ });
+
+ it("the tab's accessible name translates around the session name, which stays as typed", () => {
+ for (const name of ['w1-case', 'Open tiles', 'Tiles']) {
+ for (const label of labels) {
+ const source = helpers.paneExitAriaLabel(name, label);
+ const text = zh.api.t(source);
+ expect(text.startsWith(`${name} 会话,智能体已退出`), `${source} -> ${text}`).toBe(true);
+ expect(leftover(text.slice(name.length))).toEqual([]);
+ expect(en.api.t(source)).toBe(source);
+ }
+ }
+ });
+});
diff --git a/test/session-pane-exit-ui.test.ts b/test/session-pane-exit-ui.test.ts
index cd7ffeb1..b673f052 100644
--- a/test/session-pane-exit-ui.test.ts
+++ b/test/session-pane-exit-ui.test.ts
@@ -92,10 +92,27 @@ describe('the exited-agent badge in a tab', () => {
expect(tab.querySelector('.tab-name')?.nextElementSibling?.className).toBe('tab-exited-badge');
});
- it('marks the badge data-i18n-skip, like the other generated status text', () => {
+ it('leaves the badge to the translator (zh-CN) and remembers its English', () => {
const tab = makeTab();
applyPaneExitBadge(tab, { status: 0, at: 1 });
- expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(true);
+ expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(false);
+ expect((badge(tab) as unknown as HTMLElement).dataset.label).toBe('exited (0)');
+ });
+
+ it('a translated badge and accessible name survive an unchanged pass; a change writes the new English', () => {
+ // The translator rewrites both in zh-CN. Comparing with the DOM would write
+ // the English back on every incremental pass for it to translate again.
+ const tab = makeTab();
+ applyPaneExitBadge(tab, { status: 3, at: 1 });
+ const el = badge(tab) as unknown as HTMLElement;
+ el.textContent = '已退出(3)';
+ tab.setAttribute('aria-label', 'w1-case 会话,智能体已退出(3)');
+ applyPaneExitBadge(tab, { status: 3, at: 1 });
+ expect(el.textContent).toBe('已退出(3)');
+ expect(tab.getAttribute('aria-label')).toBe('w1-case 会话,智能体已退出(3)');
+ applyPaneExitBadge(tab, { signal: 9, at: 2 });
+ expect(el.textContent).toBe('exited (signal 9)');
+ expect(tab.getAttribute('aria-label')).toBe('w1-case session, agent exited (signal 9)');
});
it('hides the badge from assistive technology, like its sibling badges', () => {
@@ -115,8 +132,12 @@ describe('the exited-agent badge in a tab', () => {
});
it('builds the full render path accessible name from the same helper', () => {
- expect(appJs).toContain('aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"');
- expect(appJs).toContain('');
+ expect(appJs).toContain(
+ 'aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"'
+ );
+ expect(appJs).toContain(
+ ''
+ );
});
it('updates the text in place rather than stacking a second badge', () => {