mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(i18n): the tab's exited-agent badge in Chinese (zh-CN)
Owner request: translate the EXITED badge. The badge carried data-i18n-skip on purpose, because its in-place update compared the DOM with the English label: a translated badge would never have matched, and every incremental tab pass would have written English back for the translator to redo. The tab's accessible name (which carries the exit, the badge being aria-hidden) was set unconditionally on every pass, the same trap once it is translated. Now the badge is left to the translator. applyPaneExitBadge remembers the last English badge text (data-label) and accessible name (data-aria-source), both also seeded by the full render, and compares with those, never the DOM. The tab strip's incremental path updates tabs in place (no row-HTML comparison), so nothing else re-renders on a translated badge. i18n.js: "exited" -> 已退出, patterns for "exited (N)" -> 已退出(N) and "exited (signal N)" -> 已退出(信号 N), and for the accessible name "<name> session, agent exited ..." -> "<name> 会话,智能体已退出 ...", the session name passed through untranslated. The header strip, the session sidebar and the vertical rail all host the same tab markup, so this covers all three. English reads exactly as before. Tests: session-pane-exit-ui pins the new markup, the remembered English and that a translated badge and accessible name survive an unchanged pass; i18n-exit-run-help runs every paneExitLabel form (and the accessible name, with names that are dictionary words) through the real translator in both languages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+21
-9
@@ -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 = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</button></span>`;
|
||||
|
||||
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
@@ -6030,7 +6042,7 @@ class CodemanApp {
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||
${paneExitBadge ? `<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
|
||||
${paneExitBadge ? `<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">${escapeHtml(paneExitBadge)}</span>` : ''}
|
||||
${inlineSessionActions ? tabActionsHtml : ''}
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
</span>
|
||||
|
||||
@@ -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\?$/,
|
||||
|
||||
@@ -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<string, string>): void };
|
||||
function translator(language: string) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('<span class="tab-exited-badge" data-i18n-skip aria-hidden="true">');
|
||||
expect(appJs).toContain(
|
||||
'aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"'
|
||||
);
|
||||
expect(appJs).toContain(
|
||||
'<span class="tab-exited-badge" data-label="${escapeHtml(paneExitBadge)}" aria-hidden="true">'
|
||||
);
|
||||
});
|
||||
|
||||
it('updates the text in place rather than stacking a second badge', () => {
|
||||
|
||||
Reference in New Issue
Block a user