feat(i18n): the tile grid in Chinese (zh-CN)

Owner request: with App Settings language set to 简体中文, the grid reads
fully in Chinese. Every string the grid puts on screen gets its own
ZH_CN entry, so none reaches the generic leading-verb fallback: the Tiles
button (both states, with the right-click hint), the Tiles and Split
chips, the grid region, the Help modal's Tiles rows, the shortcut
registry's Tiles group and labels (overlay and App Settings list, and
"not bound"), "Open group as tiles", the picker, a tile's +, the header
buttons, the Attach overlay (not attached, attaching, exited, ended, the
hint), the empty slot, the dividers, the Split button while tiles are
open, and the toasts. Strings with a count, an exit code or a duration
are translateDynamic patterns: the cap texts (both wordings, with and
without ": the new session opens on its own"), "This window fits N
tile(s)", the auto-zoom hint, "The agent exited (N)" / "(signal N)", the
crash-restart confirm (the existing confirm wrapper runs it through t();
the session name passes through untranslated, in the single view too),
and the tile header tooltip ("idle 3m"), which requires the duration so
bare state words stay out of the table (they collide with other
surfaces, see mobile-overview.js).

Wording: 平铺 for the feature, 窗格 for one tile, 附加 for attach, 智能体,
案例, as the table already has them. Key names stay; Click, Right-click
(mouse actions) and Arrows in the Help modal's key column are translated.
English reads exactly as before (only additions to the table).

test/tile-grid-i18n.test.ts drives the real tile code through every
state that writes text, harvests each string and requires Chinese with
no Latin word left beyond key names and durations, and the same English
in en; plus the markup through the real translator in JSDOM, and session
and group names (also when they equal a UI word) staying untranslated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 01:15:43 +02:00
parent 6c5b4a7a25
commit c848e7cf27
5 changed files with 475 additions and 1 deletions
+1 -1
View File
@@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid)
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid)
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
+2
View File
@@ -809,6 +809,8 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does).
⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again.
⚠️ **Chrome.** The header is a fixed 28px sibling of the body (its buttons are 26px targets with 16 to 19px glyphs, the app header's own icon-button size), refreshed in place on every tab render (`_renderSessionTabsImmediate` wrapper), never by rewriting the tile (that would take its xterm along) and never growing (#464: the body holds the xterm). Header buttons stop pointerdown, so acting on an unfocused tile does not focus it. × removes the tile only; killing stays behind the session menu's Close session. The `needs` pulse animates `box-shadow` only. Dividers are their own 6px grid tracks (`gap: 0`), tiles placed explicitly; a drag uses pointer capture, reflows the affected tiles locally per animation frame and sends ONE `fit()` (one PTY resize) per affected tile at pointer-up; closing the grid or removing a tile mid-drag tears it down. A tab dragged onto a tile replaces it (or swaps two tiles); tiles and empty slots handle `dragover`/`drop` in the CAPTURE phase and stop it, because the drag carries the session id as text and xterm's helper textarea would type it into the PTY.
⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session.
+4
View File
@@ -32,6 +32,10 @@ or settled a question the spec left open. The invariants as built are in
- **Tile header buttons are 26px targets with 16 to 19px glyphs** (owner feedback: the
first build's 12px glyphs read as tiny next to the name), the size of the app header's own
icon buttons; the header grew from 24 to 28px to hold them.
- **The grid is translated for 简体中文 (zh-CN)** (owner request): 平铺 for the feature, 窗格 for
one tile, key names untranslated, mouse actions in the Help modal's key column (`Click`,
`Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes
compare with the last English value, not the translated DOM.
- **The Tiles button opens the grid at once** (owner decision 8): a click (and
`Ctrl+Shift+G`, the same `toggleTileGrid`) opens `tileGridOpenSet` (constants.js): the
grid this tab last had, else an open split's two sessions, else the open sessions in tab
+85
View File
@@ -96,6 +96,56 @@
'Split: close the second session': '分屏:关闭第二个会话',
'Close split': '关闭分屏',
'No other sessions to split with': '没有其他可用于分屏的会话',
// Tile grid (tile-grid.js, docs/tile-grid-plan.md). 平铺 is the feature (the
// button, the setting, the grid), 窗格 one tile in it. Key names stay as
// they are; Click / Right-click are mouse actions, Arrows the arrow keys.
// Counts, exit codes and durations are patterns in translateDynamic.
Tiles: '平铺',
Split: '分屏',
'Tiled sessions': '平铺的会话',
'Tiles: show several sessions side by side (right-click to choose which)':
'平铺:并排显示多个会话(右键单击可选择会话)',
'Tiles: back to a single session (right-click to choose which sessions)':
'平铺:返回单个会话(右键单击可选择会话)',
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
'Toggle Tile Grid': '切换平铺网格',
'Focus Tile Left': '聚焦左侧窗格',
'Focus Tile Right': '聚焦右侧窗格',
'Focus Tile Up': '聚焦上方窗格',
'Focus Tile Down': '聚焦下方窗格',
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
'Zoom Focused Tile': '放大聚焦的窗格',
'Remove Focused Tile': '移除聚焦的窗格',
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
'Choose Which Sessions to Tile': '选择要平铺的会话',
'a tab': '标签页',
'the Tiles button': '平铺按钮',
Click: '单击',
'Right-click': '右键单击',
Arrows: '方向键',
'not bound': '未绑定',
'Open group as tiles': '以平铺方式打开分组',
'Show sessions as tiles': '以平铺方式显示会话',
'Open tiles': '打开平铺',
'No sessions to show as tiles': '没有可平铺显示的会话',
'This group has no session to show as tiles': '此分组没有可平铺显示的会话',
'Add a session to the grid': '向平铺网格添加会话',
'Every open session is already tiled': '所有打开的会话都已平铺',
'New session in this case': '在此案例中新建会话',
'This session is not in a case': '此会话不属于任何案例',
'Zoom this tile': '放大此窗格',
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab here': '将标签页拖放到此处',
'Resize tile columns': '调整窗格列宽',
'Resize tile rows': '调整窗格行高',
Attach: '附加',
'Attaching…': '正在附加…',
'Not attached': '未附加',
'The session ended': '会话已结束',
'The agent exited': '智能体已退出',
'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。',
'Could not attach the session': '无法附加会话',
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
Notifications: '通知',
@@ -975,6 +1025,41 @@
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
],
// Tile grid: counts, exit codes and durations pass through.
[/^Up to (\d+) tiles$/, (_m, n) => `最多 ${n} 个窗格`],
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
[
/^The grid already holds what this window fits \((\d+)\)$/,
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n})`,
],
[
/^The grid holds at most (\d+) tiles: the new session opens on its own$/,
(_m, n) => `平铺网格最多容纳 ${n} 个窗格:新会话将单独打开`,
],
[
/^The grid already holds what this window fits \((\d+)\): the new session opens on its own$/,
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n}):新会话将单独打开`,
],
[
/^The window is too small for (\d+) tiles: showing the focused one$/,
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
// A session name is user text: it passes through untranslated.
[
/^(.+) was stopped after crashing repeatedly\. Restart it\?$/,
(_m, name) => `${name} 因反复崩溃已被停止。要重启吗?`,
],
// A tile header's tooltip: a state and how long ("idle 3m"). The duration
// is required: bare state words stay out of the table, they collide with
// state strings on other surfaces (see mobile-overview.js).
[
/^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/,
(_m, state, duration) =>
`${{ 'needs you': '需要你', error: '错误', waiting: '等待中', working: '工作中', idle: '空闲', done: '已完成', exited: '已退出' }[state]} ${duration}`,
],
];
for (const [pattern, replacement] of patterns) {
const match = source.match(pattern);
+383
View File
@@ -0,0 +1,383 @@
/**
* @fileoverview The tile grid in Chinese (zh-CN): every string it puts on
* screen has its own entry or pattern in i18n.js, so nothing is left in
* English and nothing falls through to the generic leading-verb fallback.
*
* - Runtime strings are HARVESTED from the real tile code (the shared vm
* harness, test/mocks/tile-grid-vm.ts) driven through every state that
* writes text: the picker (cap and window wording), a tile's +, the Attach
* overlay (not attached, attaching, exited, ended), zoom, the header
* tooltip, the dividers, the empty slot, every toast, the crash-restart
* confirm, the Tiles and Split button titles. Each must translate to text
* with no Latin word left beyond key names and durations, and read
* unchanged in English.
* - Static strings: the shortcut registry's tile entries (overlay and App
* Settings list), and index.html run through the real translator in JSDOM
* (the Tiles button, the App Settings chips, the Help modal's Tiles rows).
* - User text stays as typed: session names (tile header, picker, + menu)
* and group names carry data-i18n-skip, and a session name inside the
* confirm passes through the pattern untranslated.
*
* 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, vi } from 'vitest';
import {
FakeEl,
body,
bySelector,
fetchSpy,
makeGridApp,
resetGridHarness,
section,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
const I18N = read('i18n.js');
const INDEX = read('index.html');
type Api = {
t(s: string, v?: Record<string, string>): string;
configure(o: Record<string, string>): void;
start(): void;
};
function translator(language: string) {
const dom = new JSDOM('<!doctype html><html><head></head><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();
});
// What may stay Latin in a translation: key names, compact durations, glyphs.
const ALLOWED = /\b(Ctrl|Cmd|Shift|Alt|Option|Enter|G)\b|<1m|\b\d+[dhm]\b/g;
const leftover = (text: string) => text.replace(ALLOWED, '').match(/[A-Za-z]+/g) ?? [];
/** Every string a translation must handle, with where it was seen. */
const seen = new Map<string, string>();
/** Text in a data-i18n-skip subtree (user text): must never be translated. */
const userText = new Set<string>();
function harvest(root: FakeEl | null | undefined, where: string) {
const walk = (el: FakeEl, inSkip: boolean) => {
const skip = inSkip || 'data-i18n-skip' in el.attrs;
const add = (value: unknown, kind: string) => {
if (typeof value !== 'string' || !/[A-Za-z]/.test(value)) return;
if (skip) userText.add(value.trim());
else if (!seen.has(value.trim())) seen.set(value.trim(), `${where} (${kind})`);
};
add(el.textContent, 'text');
add(el.title, 'title');
add(el.attrs['aria-label'], 'aria-label');
add(el.attrs.title, 'title attribute');
for (const child of el.children) walk(child, skip);
};
if (root) walk(root, false);
}
function harvestAll(app: GridApp, where: string) {
harvest(section, where);
harvest(body, where);
harvest(bySelector.get('.btn-tile-grid'), where);
harvest(bySelector.get('.btn-split'), where);
for (const [msg] of (app.showToast as ReturnType<typeof vi.fn>).mock.calls) {
if (typeof msg === 'string' && !seen.has(msg)) seen.set(msg, `${where} (toast)`);
}
}
let wrapRect = { width: 2400, height: 1200 };
function setUp() {
resetGridHarness();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
for (const cls of ['btn-tile-grid', 'btn-split']) {
const btn = new FakeEl();
btn.className = `btn-icon-header ${cls}`;
bySelector.set(`.${cls}`, btn);
}
}
const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`);
/** Drives the real code through every state that writes text, harvesting as it goes. */
async function exercise() {
setUp();
const app = makeGridApp(EIGHT);
let pill = 'idle';
app._sidebarRichRow = () => ({ state: pill, pill, since: { at: 1 } });
app._mobileOverviewStampText = () => '3m';
app.cases = [{ name: 'proj', path: '/w' }];
app._mobileOverviewCaseFor = (_dir: string, cases: Array<{ name: string }>) => cases[0] ?? null;
app.sessions.get('s-2').pid = null;
app.sessions.get('s-3').paneExit = { status: 3 };
app.sessions.get('s-4').paneExit = { signal: 9 };
// A session named like a UI string: user text, never translated.
app.sessions.get('s-5').name = 'Open tiles';
// The picker, the cap wording, then the window wording.
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
const boxes = () =>
body.children.find((c) => c.id === 'tilePickerMenu')!.children[0].children.map((r) => r.children[0]);
for (const b of boxes()) {
if (b.checked || b.disabled) continue;
b.checked = true;
b.dispatch('change');
}
harvestAll(app, 'picker, cap');
app.closeTilePicker();
wrapRect = { width: 1200, height: 900 };
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
for (const b of boxes()) {
if (b.checked || b.disabled) continue;
b.checked = true;
b.dispatch('change');
}
harvestAll(app, 'picker, window');
app.closeTilePicker();
// A window that fits one tile words it in the singular.
wrapRect = { width: 700, height: 500 };
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'picker, one tile');
app.closeTilePicker();
wrapRect = { width: 2400, height: 1200 };
// The grid: five tiles first (an empty slot), then the sixth.
app.openTileGrid(EIGHT.slice(0, 5));
harvestAll(app, 'grid of five');
app.addTile('s-6');
app._renderTileChrome();
harvestAll(app, 'grid of six');
for (const state of ['needs you', 'error', 'waiting', 'working', 'done', 'exited']) {
pill = state;
app._renderTileChrome();
harvestAll(app, `tooltip ${state}`);
}
pill = 'idle';
// Zoom and back.
app.zoomTile('s-1');
harvestAll(app, 'zoomed');
app.zoomTile('s-1');
// A tile's + on a full grid, from a session in a case and from one outside.
app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1');
harvestAll(app, '+ menu, full');
app.closeTileAddMenu();
app._mobileOverviewCaseFor = () => null;
app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1');
harvestAll(app, '+ menu, no case');
app.closeTileAddMenu();
// The toasts of a full grid, by the cap and by the window.
app._joinTileGridFromRun('s-7');
app.addSessionToTiles('s-7');
wrapRect = { width: 1200, height: 900 };
section.getBoundingClientRect = () => ({ width: 1200, height: 900, top: 0, left: 0, right: 1200, bottom: 900 });
app._joinTileGridFromRun('s-7');
app.addSessionToTiles('s-8');
// Too small for six: the auto-zoom hint.
section.getBoundingClientRect = () => ({ width: 900, height: 400, top: 0, left: 0, right: 900, bottom: 400 });
app._applyTileLayout();
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
wrapRect = { width: 2400, height: 1200 };
harvestAll(app, 'full grid');
// Attach: in flight, failed, the crash-restart confirm, a socket that ended.
let release: (v: unknown) => void = () => {};
fetchSpy.mockImplementationOnce(() => new Promise((r) => (release = r)));
const pending = app.attachTileSession('s-2');
harvestAll(app, 'attaching');
release({ ok: false, json: async () => ({ success: false }) });
await pending;
harvestAll(app, 'attach failed');
const confirmText: string[] = [];
windowStub.confirm = vi.fn((msg: string) => {
confirmText.push(msg);
return false;
});
app.sessions.get('s-5').pid = null;
app.sessions.get('s-5').respawnBlocked = true;
app._renderTileChrome();
await app.attachTileSession('s-5');
for (const msg of confirmText) seen.set(msg, 'confirm');
const tile6 = app._tileFor('s-6');
tile6._stoppedCode = 4009;
app._renderTileChrome();
harvestAll(app, 'socket ended');
// A group with no live session, and a toggle with nothing to open.
app.tabLayout = { groups: [{ id: 'g', name: 'Tiles', refs: [{ kind: 'session', id: 'gone' }] }] };
app.openGroupAsTiles('g');
harvestAll(app, 'empty group');
app.closeTileGrid({ reselect: false });
harvestAll(app, 'grid closed');
const empty = makeGridApp([]);
empty.sessions.clear();
empty.sessionOrder = [];
empty.toggleTileGrid();
harvestAll(empty, 'nothing to open');
return { confirmText };
}
// The harvest runs once, in the first test; the others read what it saw.
let confirmText: string[] = [];
describe('every tile grid string the code puts on screen translates to zh-CN', () => {
it('harvests the strings it should (the harvest itself is not vacuous)', async () => {
({ confirmText } = await exercise());
// ('Tiled sessions', the grid region's label, is markup: the JSDOM test below.)
const expected = [
'Show sessions as tiles',
'Open tiles',
'Up to 6 tiles',
'The grid holds at most 6 tiles',
'This window fits 4 tiles',
'This window fits 1 tile',
'The grid already holds what this window fits (4)',
'Drop a tab here',
'Resize tile columns',
'Resize tile rows',
'Zoom this tile',
'Restore the grid',
'Session actions',
'Add a session to the grid',
'Remove tile (the session keeps running)',
'idle 3m',
'needs you 3m',
'exited 3m',
'Not attached',
'Attach',
'Attaching…',
'The agent exited (3)',
'The agent exited (signal 9)',
'The session ended',
'It cannot be restarted in place: close it from ⋯ (Close session).',
'New session in this case',
'This session is not in a case',
'The grid holds at most 6 tiles: the new session opens on its own',
'The grid already holds what this window fits (4): the new session opens on its own',
'The grid already holds what this window fits (4)',
'The window is too small for 6 tiles: showing the focused one',
'Could not attach the session',
'This group has no session to show as tiles',
'No sessions to show as tiles',
'Split: unavailable while tiles are open',
'Tiles: show several sessions side by side (right-click to choose which)',
'Tiles: back to a single session (right-click to choose which sessions)',
'Open tiles was stopped after crashing repeatedly. Restart it?',
];
const missing = expected.filter((s) => !seen.has(s));
expect(missing).toEqual([]);
});
it('each one: Chinese with no English left, and no half-translated fallback', () => {
const bad: string[] = [];
for (const [source, where] of seen) {
const text = zh.api.t(source);
// The session name inside the confirm is user text, allowed to stay.
const words = leftover(text.replace('Open tiles', ''));
if (text === source || words.length) bad.push(`${where}: "${source}" -> "${text}"`);
}
expect(bad).toEqual([]);
});
it('each one reads exactly as before in English', () => {
const changed = [...seen.keys()].filter((s) => en.api.t(s) !== s);
expect(changed).toEqual([]);
});
it('the shortcut registry entries: the Tiles group, its labels, "not bound", the group menu item', () => {
const app = read('app.js');
const labels = [...app.matchAll(/group: 'Tiles',\s*label: '([^']+)'/g)].map((m) => m[1]);
expect(labels).toHaveLength(7);
expect(app).toContain("label: 'Open group as tiles'");
expect(app).toContain('not bound');
const bad = ['Tiles', 'not bound', 'Open group as tiles', ...labels].filter((s) => {
const text = zh.api.t(s);
return text === s || leftover(text).length > 0;
});
expect(bad).toEqual([]);
});
});
describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const doc = dom.window.document;
const extra = doc.createElement('div');
extra.innerHTML =
'<span id="u-session" data-i18n-skip>Open tiles</span>' +
'<span id="u-group" class="tab-layout-group-name" data-i18n-skip>Tiles</span>' +
'<span id="control">Tiles</span>';
doc.body.appendChild(extra);
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
it('the Tiles header button: title and accessible name', () => {
const btn = doc.querySelector('.btn-tile-grid')!;
for (const text of [btn.getAttribute('title')!, btn.getAttribute('aria-label')!]) {
expect(text).toContain('平铺');
expect(leftover(text)).toEqual([]);
}
});
it('the App Settings chips (Tiles, and Split beside it) and the grid region', () => {
const chip = (id: string) => doc.getElementById(id)!.closest('label')!.textContent!.trim();
expect(chip('appSettingsShowTileGridButton')).toBe('平铺');
expect(chip('appSettingsShowSplitButton')).toBe('分屏');
expect(doc.getElementById('tileGrid')!.getAttribute('aria-label')).toBe('平铺的会话');
});
it("the Help modal's Tiles rows: Chinese around the key names", () => {
const tiles = [...doc.querySelectorAll('#helpModal .shortcut-section')].find(
(s) => s.querySelector('h4')!.textContent === '平铺'
);
expect(tiles).toBeTruthy();
expect(leftover(tiles!.textContent!)).toEqual([]);
expect(tiles!.textContent).toContain('切换平铺网格');
expect(tiles!.textContent).toContain('右键单击');
});
it('user text stays as typed: a session name and a group name that are also UI words', () => {
expect(doc.getElementById('u-session')!.textContent).toBe('Open tiles');
expect(doc.getElementById('u-group')!.textContent).toBe('Tiles');
expect(doc.getElementById('control')!.textContent).toBe('平铺');
});
});
describe('user text in the tile code', () => {
it('session names in a tile header, the picker and the + menu are marked data-i18n-skip', () => {
// The harvest saw them only inside skipped subtrees.
expect(userText.has('Open tiles')).toBe(true);
expect([...seen.keys()]).not.toContain('s-1');
const src = read('tile-grid.js');
expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(3);
});
it('the group names of the grouped rail are marked data-i18n-skip', () => {
expect(read('tab-layout-browser.js')).toContain('class="tab-layout-group-name" id="${nameId}" data-i18n-skip>');
});
it('a session name inside the crash-restart confirm passes through the pattern untranslated', () => {
expect(confirmText).toEqual(['Open tiles was stopped after crashing repeatedly. Restart it?']);
expect(zh.api.t(confirmText[0])).toBe('Open tiles 因反复崩溃已被停止。要重启吗?');
});
});