diff --git a/CLAUDE.md b/CLAUDE.md index 8f2b1f01..e3341d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9d6669cf..497cd7cb 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 37559654..f83f84fe 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -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 diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index a38d3f31..7cbebf3e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts new file mode 100644 index 00000000..437edf96 --- /dev/null +++ b/test/tile-grid-i18n.test.ts @@ -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; + configure(o: Record): void; + start(): 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(); +}); + +// 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(); +/** Text in a data-i18n-skip subtree (user text): must never be translated. */ +const userText = new Set(); + +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).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).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 = + 'Open tiles' + + 'Tiles' + + 'Tiles'; + 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 因反复崩溃已被停止。要重启吗?'); + }); +});