From 18c8b5c2800195f9d4ba9e906cbca5f31d11aac0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 08:44:52 +0200 Subject: [PATCH 1/7] fix(tiles): file drops, focus handoffs, pop-out fallback, split merge, tablet default - A file dragged onto the tile grid navigated the browser away: the single view's drop handler sits on #terminalContainer, hidden while tiles are open. The #tileGrid section now cancels every file dragover and drop (bubble phase, so tab and tile drags stay with _acceptTabDrops), and a drop on a tile uploads its images to THAT tile's session through _uploadAndInsertImages, with the same "Only image files are supported" toast as image-input.js (now in the zh-CN table). - App-driven refocus no longer moves DOM focus into another session's xterm: a remote delete of the focused tile, _reconcileTileGrid and a socket closed with 4003/4004/4010 (_onTileExit) pass focus: false. removeTile gains a focus option; user-initiated removes keep focusing. - Popping out the last tile left the parked terminal's stale content under the popped-out tab (and snapshotted it on the next switch). _selectAfterTileGrid treats a detached session as unusable for both the focused id and the fallback. - "Open group as tiles" and Ctrl/Cmd+click with the grid closed pass mergeSplit: false, so an open split no longer adds its two sessions on top of a set already sized to the group, the count and the window. - Touch-primary devices (primary pointer coarse: iPad, Android tablets) default the Tiles button OFF in getDefaultSettings(); touchscreen laptops (fine primary pointer) keep the desktop default ON. The button, the App Settings chip and the Ctrl+Shift+G gate all resolve an absent key through these defaults, so they agree. CLAUDE.md and the invariants doc say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/i18n.js | 2 + src/web/public/settings-ui.js | 9 +- src/web/public/tile-grid.js | 107 ++++++++++++++---- test/mocks/tile-grid-vm.ts | 4 + test/tile-grid-close-fallback.test.ts | 56 ++++++++- test/tile-grid-file-drop.test.ts | 131 ++++++++++++++++++++++ test/tile-grid-i18n.test.ts | 9 ++ test/tile-grid-per-device-setting.test.ts | 99 +++++++++++++++- test/tile-grid-select-branch.test.ts | 29 +++++ test/tile-grid-split-coexistence.test.ts | 56 +++++++++ 12 files changed, 481 insertions(+), 25 deletions(-) create mode 100644 test/tile-grid-file-drop.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 438ac05e..00988773 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 ON on desktop and OFF on handhelds, 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, harness logo, name, model, menu, zoom, ×; no +, owner decision), 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 with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ 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. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ 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) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), 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, harness logo, name, model, menu, zoom, ×; no +, owner decision), 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 with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ 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. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ 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 240b18a3..fda9d65b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -805,7 +805,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ### Tile grid -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `
` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view. +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `
` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view. ⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`. diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index ff810617..01148ca2 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -153,6 +153,8 @@ 'Restore the grid': '恢复平铺网格', 'Remove tile (the session keeps running)': '移除窗格(会话继续运行)', 'Drop a tab or a tile here': '将标签页或窗格拖放到此处', + // A file dropped on a tile (tile-grid.js) or the single view (image-input.js). + 'Only image files are supported': '仅支持图像文件', // A tile header's tooltip while tiles can move (with the state above it: a pattern below). 'Drag to move the tile': '拖动可移动窗格', 'Resize tile columns': '调整窗格列宽', diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index b1ec2194..e2fb1083 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -3549,7 +3549,14 @@ Object.assign(CodemanApp.prototype, { } // Desktop defaults - rely on ?? operators in apply functions // This allows desktop to have different defaults without duplication - return {}; + // A touch-primary tablet (iPad, an Android tablet: not a handheld, so it + // lands here) keeps the Tiles button opt-in, as Split is: a tile has none of + // the main terminal's touch, IME and soft-keyboard handling. The PRIMARY + // pointer decides, never MobileDetection.isTouchDevice(), which is true on a + // touchscreen laptop too (fine primary pointer: the desktop default stays). + const coarsePrimaryPointer = + typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)')?.matches === true; + return coarsePrimaryPointer ? { showTileGridButton: false } : {}; }, loadAppSettingsFromStorage() { diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index af6960bb..1545f169 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -253,16 +253,65 @@ Object.assign(CodemanApp.prototype, { const wrap = document.querySelector('.terminal-wrap'); wrap?.parentElement?.insertBefore(section, wrap.nextSibling); } + // Once per section (index.html ships it, so not only on create). + if (this._tileFileDropSection !== section) { + this._tileFileDropSection = section; + this._installTileFileDrop(section); + } return section; }, + /** + * A file dragged over the grid. The single view's file drop (image-input.js) + * listens on #terminalContainer, hidden while tiles are open, so nothing + * cancelled a file drag here and the browser opened the file in place of + * Codeman. Anywhere over the grid (a tile, an empty cell, a divider, the + * padding) the drag is cancelled, so the page never navigates; dropped on a + * tile, its images upload to THAT tile's session and their paths are typed + * there, as the single view does for the active one. Bubble phase, files + * only: a tab or tile drag carries none, and its target stops it in the + * capture phase anyway (_acceptTabDrops). + */ + _installTileFileDrop(section) { + const isFileDrag = (e) => { + const types = e.dataTransfer?.types; + return !!types && Array.from(types).includes('Files'); + }; + // The open grid's tile under `target`, or null (an empty cell, a divider, the padding). + const tileAt = (target) => { + const grid = this._tileGrid; + if (!grid?.open || !target) return null; + for (const [id, entry] of grid.tiles) if (entry.el.contains?.(target)) return id; + return null; + }; + section.addEventListener('dragover', (e) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + if (e.dataTransfer && tileAt(e.target)) e.dataTransfer.dropEffect = 'copy'; + }); + section.addEventListener('drop', (e) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + const sessionId = tileAt(e.target); + const files = Array.from(e.dataTransfer?.files || []); + if (!sessionId || files.length === 0) return; + const images = files.filter((f) => String(f?.type || '').startsWith('image/')); + if (images.length === 0) { + this.showToast?.('Only image files are supported', 'error'); + return; + } + this._uploadAndInsertImages?.(images, { sessionId }); + }); + }, + /** * Opens the grid on `ids` (unknown, detached and duplicate ids are skipped; * at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it * adds what is missing and moves focus. `auto: false` makes the focus a human * selection (it acknowledges that session's idle alert). An open split * closes (the two are never open together); `mergeSplit` (default) makes its - * two sessions the first tiles, false opens exactly `ids` (a stored grid). + * two sessions the first tiles, false opens exactly `ids` (a stored grid, a + * group, a Ctrl/Cmd+click: callers that size their own set). * * Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while * its snapshot of the session it shows is still right, and closes its socket. @@ -1015,8 +1064,9 @@ Object.assign(CodemanApp.prototype, { * The tile chord `e` asks for, if it applies right now, else null. The * toggle applies while the grid is open, or where one could open AND the * per-device `showTileGridButton` setting is on (the desktop default; OFF on - * handhelds): with it off the chord is inert and reaches the terminal like - * any unbound key (owner decision 6 in docs/tile-grid-plan.md). An absent key + * handhelds and touch-primary tablets): with it off the chord is inert and + * reaches the terminal like any unbound key (owner decision 6 in + * docs/tile-grid-plan.md). An absent key * resolves through the device defaults exactly as the header button does * (settings-ui.js), so the chord and the button can never disagree. The focus, move, * zoom and remove chords apply only while the grid is open, however it was @@ -1140,10 +1190,14 @@ Object.assign(CodemanApp.prototype, { // replayed fresh (forceReload drops the stale snapshot and nulls // activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved), // or, if that session is gone, the same fallback as closing the active tab. - // Returns the selection's promise (it settles once the replay is written), - // or undefined for the welcome screen. + // A popped-out session counts as gone in both: selectSession would only + // raise its window and return, leaving the parked terminal's pre-grid + // content on screen under its tab (and saved as its snapshot on the next + // switch). Returns the selection's promise (it settles once the replay is + // written), or undefined for the welcome screen. _selectAfterTileGrid(sessionId) { - if (sessionId && this.sessions.has(sessionId)) { + const usable = (id) => this.sessions.has(id) && !this.detachedSessions?.has(id); + if (sessionId && usable(sessionId)) { return this.selectSession(sessionId, { forceReload: true, auto: true }); } this.activeSessionId = null; @@ -1152,7 +1206,7 @@ Object.assign(CodemanApp.prototype, { } catch { /* Nothing stored. */ } - const next = this.sessionOrder.find((id) => this.sessions.has(id)); + const next = this.sessionOrder.find(usable); if (next) return this.selectSession(next, { auto: true }); this.terminal?.clear(); this.showWelcome(); @@ -1213,11 +1267,14 @@ Object.assign(CodemanApp.prototype, { * Removes one tile; the session keeps running. Its cell becomes empty where * it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus` * moves focus to the neighbouring tile (next in grid order, else previous), - * as the app's choice (`auto`: no idle alert is spent). The last tile + * as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps + * DOM focus where it is (an app-driven removal: a socket the server closed), + * so keystrokes never land in the neighbour's PTY unasked. The last tile * leaving closes the grid: with `refocus` the single view then shows that - * session, without it the caller decides what comes next. + * session (or, popped out, the next one: _selectAfterTileGrid), without it + * the caller decides what comes next. */ - removeTile(sessionId, { refocus = true } = {}) { + removeTile(sessionId, { refocus = true, focus = true } = {}) { const grid = this._tileGrid; const entry = grid?.open ? grid.tiles.get(sessionId) : null; if (!entry) return false; @@ -1243,7 +1300,7 @@ Object.assign(CodemanApp.prototype, { this._applyTileLayout(); this._scheduleTileGridRefit(); this.renderSessionTabs?.(); - if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true }); + if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true, focus }); return true; }, @@ -1331,7 +1388,8 @@ Object.assign(CodemanApp.prototype, { * `accepts(id)` is the target's own rule (a tile takes any session but its * own; an empty cell takes any). A session it does not accept * is held there too, but refused (`dropEffect: 'none'`, no highlight, so no - * drop follows). Any other drag (a file) is left alone. + * drop follows). Any other drag (a file) is left to the grid section's own + * guard (_installTileFileDrop). */ _acceptTabDrops(el, onDrop, { accepts = () => true } = {}) { const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null); @@ -1525,7 +1583,9 @@ Object.assign(CodemanApp.prototype, { const n = Math.max(1, Math.min(this._tileGridCount(), capacity)); const base = this._tileGridOpenSet()?.ids || []; const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId]; - this.openTileGrid(ids, { focusedId: sessionId, auto: false }); + // Exactly these: an open split is already in `base` (tileGridOpenSet seeds + // it), and merging it again went past the count (N+1) and the window. + this.openTileGrid(ids, { focusedId: sessionId, auto: false, mergeSplit: false }); return true; }, @@ -1567,7 +1627,9 @@ Object.assign(CodemanApp.prototype, { this.closeTileGrid({ keepStored: false, reselect: false }); this.activeSessionId = null; } - return this.openTileGrid(ids, { focusedId: focus }); + // Exactly the group: an open split closes without joining it (merged, its + // two pushed group members out and the grid past the window's capacity). + return this.openTileGrid(ids, { focusedId: focus, mergeSplit: false }); }, /** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */ @@ -2036,7 +2098,9 @@ Object.assign(CodemanApp.prototype, { /** * A tile's socket stopped for good. 4009 (the session exited) keeps the tile * with its "session ended" marker; 4003 (refused), 4004 (session gone) and - * 4010 (another socket took over) remove it. + * 4010 (another socket took over) remove it. Nobody here asked for that, so + * the neighbour takes focus without the keyboard (`focus: false`): what the + * user is typing never lands in another session's PTY. */ _onTileExit(sessionId, tile, code) { if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return; @@ -2045,7 +2109,7 @@ Object.assign(CodemanApp.prototype, { this._renderTileOverlay(sessionId); return; } - this.removeTile(sessionId); + this.removeTile(sessionId, { focus: false }); }, /** @@ -2560,8 +2624,9 @@ Object.assign(CodemanApp.prototype, { if (!grid.open) return false; // Only a focus that is gone moves: re-selecting the same tile would hide an // active web tab on every SSE blip (_selectTiledSession hides the web layer), - // which the single view's reconnect never does. - if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true }); + // which the single view's reconnect never does. The app's choice, so DOM + // focus stays put (an open modal or text field keeps the keyboard). + if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true, focus: false }); for (const { tile } of grid.tiles.values()) tile.reconnectNow(); return true; }, @@ -2569,7 +2634,9 @@ Object.assign(CodemanApp.prototype, { // A tiled session deleted (here or elsewhere) loses its tile; if it held focus, // the neighbouring tile takes it (`auto`: the app chose, so no idle alert is -// spent). Done BEFORE the original handler, so activeSessionId no longer names +// spent; `focus: false`: the keyboard stays put, so what the user was typing +// never goes on into the neighbour's PTY, as the single view sends it +// nowhere). Done BEFORE the original handler, so activeSessionId no longer names // the deleted id and its welcome-screen handoff stays out of it. The last tile // closes the grid without a reselect, and the original handler then shows the // welcome screen as in the single view. A close started from this tab @@ -2582,7 +2649,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) { const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id); this.removeTile(data.id, { refocus: false }); if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) { - this._selectTiledSession(neighbor, { auto: true }); + this._selectTiledSession(neighbor, { auto: true, focus: false }); } } return _tileGridOriginalOnSessionDeleted.call(this, data); diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index d39c5956..d796b4a1 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -375,6 +375,10 @@ export function resetGridHarness() { // The CLI catalog the server injects (labels for the harness logos); a test sets its own. delete windowStub.__codemanCliCatalog; section.children = []; + // The grid binds its file-drop guard on the section once per app; an earlier + // test's app must not still be listening there. + section.listeners = {}; + section.captureFlags = {}; main.className = 'main'; // A split a test left open moved .terminal-wrap into its container, with // Pane A's header strip in it. diff --git a/test/tile-grid-close-fallback.test.ts b/test/tile-grid-close-fallback.test.ts index 46eb8479..c20968d0 100644 --- a/test/tile-grid-close-fallback.test.ts +++ b/test/tile-grid-close-fallback.test.ts @@ -22,7 +22,7 @@ * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeTile, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js'; +import { FakeTile, flushFrames, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c']; @@ -148,3 +148,57 @@ describe('a tiled session deleted elsewhere', () => { expect(app.showWelcome).toHaveBeenCalled(); }); }); + +// The handoffs above are the APP's choice: focus moves (activeSessionId, the +// focus paint) but the keyboard does not. Moving DOM focus into the +// neighbour's xterm sent whatever the user was still typing, Enter included, +// into another session's agent; the single view sends it nowhere. Only a +// removal the user made (the x button, Remove Focused Tile) carries the +// keyboard along. +describe('an app-driven refocus never moves the keyboard into another session', () => { + const live = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!; + const focusCalls = () => FakeTile.all.reduce((n, t) => n + t.terminal.focus.mock.calls.length, 0); + /** A grid on IDS focused on s-b, every terminal built, no focus recorded yet. */ + function built() { + const app = makeGridApp(IDS); + app.openTileGrid(IDS, { focusedId: 's-b' }); + flushFrames(); + for (const t of FakeTile.all) t.terminal.focus.mockClear(); + return app; + } + + it('a remote delete of the focused tile hands focus to the neighbour, DOM focus untouched', () => { + const app = built(); + app._onSessionDeleted({ id: 's-b' }); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + expect(app._tileGrid.focusedId).toBe('s-c'); + expect(focusCalls()).toBe(0); + // No deferred focus left to land later either. + expect(app._tileGrid.focusOnConnect ?? null).toBeNull(); + }); + + it.each([4003, 4004, 4010])('a socket the server closed (%i) removes the tile, DOM focus untouched', (code) => { + const app = built(); + app._onTileExit('s-b', live('s-b'), code); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + expect(focusCalls()).toBe(0); + }); + + it('a reconcile that finds the focused session gone moves focus, DOM focus untouched', () => { + const app = built(); + app.sessions.delete('s-b'); + app._reconcileTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-a'); + expect(focusCalls()).toBe(0); + }); + + it('a removal the user made still carries the keyboard to the neighbour', () => { + const app = built(); + app.removeFocusedTile(); + expect(app.activeSessionId).toBe('s-c'); + expect(live('s-c').terminal.focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/tile-grid-file-drop.test.ts b/test/tile-grid-file-drop.test.ts new file mode 100644 index 00000000..a718cb39 --- /dev/null +++ b/test/tile-grid-file-drop.test.ts @@ -0,0 +1,131 @@ +/** + * @fileoverview A file dragged onto the tile grid. + * + * The single view's file drop (image-input.js) listens on #terminalContainer, + * which is hidden while tiles are open. Nothing else cancelled a file drag, + * so dropping a screenshot on a tile made the browser open the file in place + * of Codeman. Now the grid section itself takes every file drag (bubble + * phase): anywhere over it (a tile, an empty cell, a divider, its padding) + * dragover and drop are cancelled, so the page never navigates, and a drop on + * a tile uploads its images to THAT tile's session, the same upload and the + * same "Only image files" toast as the single view. Tab and tile drags are + * the targets' own (_acceptTabDrops, capture phase) and stay untouched. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeEl, makeGridApp, resetGridHarness, section, tileEl, type GridApp } from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; + +function fileEvent(target: FakeEl, files: Array<{ type: string; name?: string }>, types = ['Files']) { + return { + target, + dataTransfer: { types, files, dropEffect: 'none' }, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; +} + +/** A grid on `ids`, s-a focused, uploads stubbed. Five tiles make a 3x2 with one empty cell. */ +function gridApp(ids = IDS): GridApp { + const app = makeGridApp(ids); + app._uploadAndInsertImages = vi.fn(); + app.openTileGrid(ids, { focusedId: 's-a' }); + return app; +} + +const slot = () => section.children.find((el) => el.classList.contains('tile-slot'))!; +const PNG = { type: 'image/png', name: 'shot.png' }; + +beforeEach(() => { + resetGridHarness(); +}); + +describe('a file dropped on a tile', () => { + it("uploads its images to that tile's session (not the focused one), the page staying put", () => { + const app = gridApp(); + // Deep inside the tile, as on xterm's rows or its helper textarea. + const inner = tileEl('s-b').querySelector('.tile-body')!; + const over = fileEvent(inner, [PNG]); + section.dispatch('dragover', over); + expect(over.preventDefault).toHaveBeenCalled(); + expect(over.dataTransfer.dropEffect).toBe('copy'); + + const drop = fileEvent(inner, [PNG, { type: 'image/jpeg' }]); + section.dispatch('drop', drop); + expect(drop.preventDefault).toHaveBeenCalled(); + expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1); + expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, { type: 'image/jpeg' }], { sessionId: 's-b' }); + // A drop is not a selection: focus stays where it was. + expect(app.activeSessionId).toBe('s-a'); + }); + + it('only the images of a mixed drop are uploaded', () => { + const app = gridApp(); + section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG])); + expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG], { sessionId: 's-c' }); + }); + + it('a drop with no image says so, as the single view does, and uploads nothing', () => { + const app = gridApp(); + const drop = fileEvent(tileEl('s-b'), [{ type: 'application/pdf' }]); + section.dispatch('drop', drop); + expect(drop.preventDefault).toHaveBeenCalled(); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenCalledWith('Only image files are supported', 'error'); + }); +}); + +describe('a file dragged anywhere else over the grid', () => { + it('an empty cell, a divider or the padding: cancelled (no navigation), nothing uploaded', () => { + const app = gridApp([...IDS, 's-d', 's-e']); + for (const target of [slot(), section]) { + expect(target).toBeTruthy(); + const over = fileEvent(target, [PNG]); + section.dispatch('dragover', over); + expect(over.preventDefault).toHaveBeenCalled(); + const drop = fileEvent(target, [PNG]); + section.dispatch('drop', drop); + expect(drop.preventDefault).toHaveBeenCalled(); + } + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + expect(app.showToast).not.toHaveBeenCalled(); + }); +}); + +describe('tab and tile drags are left to their targets', () => { + it('a drag that carries no files is not touched by the grid section', () => { + const app = gridApp(); + app.draggedTabId = 's-other'; + const over = fileEvent(section, [], ['text/plain']); + section.dispatch('dragover', over); + section.dispatch('drop', over); + expect(over.preventDefault).not.toHaveBeenCalled(); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + }); + + it("a tab dropped on a tile still replaces it through the tile's own handler", () => { + const app = gridApp(); + app.draggedTabId = 's-other'; + const drop = fileEvent(tileEl('s-b'), [], ['text/plain']); + tileEl('s-b').dispatch('drop', drop); + expect(app._tileGrid.ids).toContain('s-other'); + expect(app._tileGrid.ids).not.toContain('s-b'); + expect(app._uploadAndInsertImages).not.toHaveBeenCalled(); + }); +}); + +describe('the guard is installed once', () => { + it('opening and closing the grid again never stacks listeners', () => { + const app = gridApp(); + app.closeTileGrid({ reselect: false }); + app.openTileGrid(IDS); + app.closeTileGrid({ reselect: false }); + app.openTileGrid(IDS); + expect(section.listeners.dragover).toHaveLength(1); + expect(section.listeners.drop).toHaveLength(1); + // Bubble phase: the tab and tile drop targets stop their own drags in capture. + expect(section.captureFlags.drop).toEqual([false]); + }); +}); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 51840818..ffc28293 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -215,6 +215,14 @@ async function exercise() { harvestAll(app, 'zoomed'); app.zoomTile('s-1'); + // A file that is not an image dropped on a tile. + section.dispatch('drop', { + target: section.children.find((el) => el.dataset.sessionId === 's-1'), + dataTransfer: { types: ['Files'], files: [{ type: 'application/pdf' }] }, + preventDefault: vi.fn(), + }); + harvestAll(app, 'file drop'); + // The toasts of a full grid, by the cap and by the window. app._joinTileGridFromRun('s-7'); app.addSessionToTiles('s-7'); @@ -314,6 +322,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'Could not attach the session', 'This group has no session to show as tiles', 'No sessions to show as tiles', + 'Only image files are supported', 'Split: unavailable while tiles are open', 'Tiles: show several sessions side by side (right-click for how many)', 'Tiles: back to a single session (right-click for how many tiles)', diff --git a/test/tile-grid-per-device-setting.test.ts b/test/tile-grid-per-device-setting.test.ts index eedd5ea5..204ca44a 100644 --- a/test/tile-grid-per-device-setting.test.ts +++ b/test/tile-grid-per-device-setting.test.ts @@ -2,7 +2,8 @@ // Port: none (pure static analysis, runs in CI, no browser/server). // // `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is -// a PER-DEVICE setting (default ON on desktop, OFF on handhelds) with the same three-way rule as +// a PER-DEVICE setting (default ON on desktop, OFF on handhelds and touch-primary +// tablets) with the same three-way rule as // showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the // history): in settings-ui.js's displayKeys merge policy, stripped out of the // object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending @@ -14,7 +15,9 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; +import vm from 'node:vm'; import { JSDOM } from 'jsdom'; +import { makeGridApp } from './mocks/tile-grid-vm.js'; const HERE = fileURLToPath(new URL('.', import.meta.url)); const PUBLIC = join(HERE, '../src/web/public'); @@ -59,6 +62,100 @@ describe('showTileGridButton stays per-device: display key, stripped from the PU }); }); +// The real getDefaultSettings() (settings-ui.js) on each kind of device. A +// touch-primary tablet (iPad, an Android tablet: primary pointer coarse) is +// not a handheld, so it used to take the desktop defaults and get the Tiles +// button ON, although a tile has none of the main terminal's touch, IME and +// soft-keyboard handling. It defaults OFF there (opt-in, like Split); a +// touchscreen laptop (primary pointer fine) keeps the desktop default. +describe('the device default: ON on desktop, OFF on handhelds and touch-primary tablets', () => { + const SOURCE = readFileSync(join(PUBLIC, 'settings-ui.js'), 'utf8'); + type Device = { handheld: boolean; touch: boolean; coarse: boolean }; + function defaultsOn({ handheld, touch, coarse }: Device) { + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + CodemanApp, + VoiceInput: {}, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + console, + MobileDetection: { + isHandheldDevice: () => handheld, + isTouchDevice: () => touch, + getDeviceType: () => (handheld ? 'mobile' : 'desktop'), + }, + window: { + matchMedia: (q: string) => ({ + matches: q === '(pointer: coarse)' ? coarse : q === '(pointer: fine)' && !coarse, + }), + }, + }); + vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' }); + const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record }; + return app.getDefaultSettings(); + } + /** What every reader resolves an absent key to: the button, the App Settings chip and the chord. */ + const resolved = (d: Device) => (defaultsOn(d).showTileGridButton ?? true) === true; + + const DESKTOP = { handheld: false, touch: false, coarse: false }; + const TOUCH_LAPTOP = { handheld: false, touch: true, coarse: false }; + const TABLET = { handheld: false, touch: true, coarse: true }; + const PHONE = { handheld: true, touch: true, coarse: true }; + + it('a desktop and a touchscreen laptop (fine primary pointer): ON', () => { + expect(defaultsOn(DESKTOP).showTileGridButton).toBeUndefined(); + expect(resolved(DESKTOP)).toBe(true); + expect(defaultsOn(TOUCH_LAPTOP).showTileGridButton).toBeUndefined(); + expect(resolved(TOUCH_LAPTOP)).toBe(true); + }); + + it('a touch-primary tablet (coarse primary pointer, not a handheld): OFF, and only that key changes', () => { + expect(defaultsOn(TABLET)).toEqual({ showTileGridButton: false }); + expect(resolved(TABLET)).toBe(false); + }); + + it('a handheld: OFF, as before', () => { + expect(defaultsOn(PHONE).showTileGridButton).toBe(false); + }); + + it('a context with no window at all still answers (the desktop default)', () => { + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + CodemanApp, + VoiceInput: {}, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + console, + MobileDetection: { isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, + }); + vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' }); + const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record }; + expect(app.getDefaultSettings()).toEqual({}); + }); + + it('the Ctrl+Shift+G chord follows the same default: inert on a tablet with nothing stored, live on a touchscreen laptop', () => { + const TOGGLE = { + type: 'keydown', + key: 'G', + code: 'KeyG', + ctrlKey: true, + shiftKey: true, + metaKey: false, + altKey: false, + target: { closest: () => null }, + }; + for (const [device, applies] of [ + [TABLET, false], + [TOUCH_LAPTOP, true], + ] as const) { + const app = makeGridApp(['s-a', 's-b']); + app.loadAppSettingsFromStorage = () => ({}); + app.getDefaultSettings = () => defaultsOn(device); + expect(app.tileShortcutFor(TOGGLE)).toBe(applies ? 'toggle-tile-grid' : null); + } + }); +}); + describe('App Settings search finds Split and Tiles', () => { // The real filter (settings-ui.js) over the real markup: it matches each chip // by its own data-search and its text, so the chips carry their own keywords. diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts index 671accaa..6bee81e6 100644 --- a/test/tile-grid-select-branch.test.ts +++ b/test/tile-grid-select-branch.test.ts @@ -201,6 +201,35 @@ describe('moving focus between tiles', () => { expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); }); + // The last tile popped out closes the grid. The single view must never try + // to show that session: selectSession only raises its window, and the parked + // terminal's stale pre-grid content would stay up under its tab (and be + // saved as its snapshot on the next switch). + it('popping out the LAST tile falls through to the next session, never the popped-out one', () => { + const app = makeGridApp(['s-a']); + app.openTileGrid(['s-a']); + app.$ = () => null; + app.selectSession = vi.fn(); + app._markDetached('s-a', true); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app.selectSession.mock.calls).toEqual([['s-other', { auto: true }]]); + // Nulled before the selection, so the parked buffer is not snapshotted as s-a's. + expect(app.activeSessionId).toBeNull(); + }); + + it('popping out the last tile with every other session popped out too: the welcome screen, the parked terminal cleared', () => { + const app = makeGridApp(['s-a']); + app.detachedSessions.add('s-other'); + app.openTileGrid(['s-a']); + app.$ = () => null; + app.selectSession = vi.fn(); + app._markDetached('s-a', true); + expect(app.selectSession).not.toHaveBeenCalled(); + expect(app.activeSessionId).toBeNull(); + expect(app.terminal.clear).toHaveBeenCalled(); + expect(app.showWelcome).toHaveBeenCalled(); + }); + it('going Home leaves the grid, remembered', () => { const app = openGrid(); app.goHome(); diff --git a/test/tile-grid-split-coexistence.test.ts b/test/tile-grid-split-coexistence.test.ts index cbfb14d8..7b4edcd6 100644 --- a/test/tile-grid-split-coexistence.test.ts +++ b/test/tile-grid-split-coexistence.test.ts @@ -100,6 +100,62 @@ describe('opening the grid over an open split', () => { expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']); expect(app.activeSessionId).toBe('s-a'); }); + + // The entry points that size their own set never get the split's two + // prepended on top of it (that went past the group, the count and the + // window's capacity, and auto-zoomed with a "too small" toast). + it("'Open group as tiles' over a split opens exactly the group, the split closed", () => { + const app = makeGridApp([...IDS, 's-d']); + app.tabLayout = { + groups: [ + { + id: 'g', + name: 'G', + refs: [ + { kind: 'session', id: 's-c' }, + { kind: 'session', id: 's-d' }, + ], + }, + ], + }; + const paneB = openSplit(app); + + app.openGroupAsTiles('g'); + + expect(paneB.destroy).toHaveBeenCalledTimes(1); + expect(app._splitPane).toBeNull(); + expect(app._tileGrid.ids).toEqual(['s-c', 's-d']); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('Ctrl/Cmd+click over a split opens the remembered count in total, never one more', () => { + const app = makeGridApp(IDS); + localStore.set('codeman:tile-count', '2'); + openSplit(app); + + app.addSessionToTiles('s-c'); + + expect(app._splitPane).toBeNull(); + // The split's Pane A seeds the set (tileGridOpenSet), the clicked one joins: 2, not 3. + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('Ctrl/Cmd+click over a split with a remembered grid: the remembered grid wins, not the split', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + app.openTileGrid(['s-other', 's-c']); + app.closeTileGrid({ reselect: false }); + app.activeSessionId = 's-a'; + localStore.set('codeman:tile-count', '2'); + openSplit(app); + + app.addSessionToTiles('s-b'); + + expect(app._splitPane).toBeNull(); + expect(app._tileGrid.ids).toEqual(['s-other', 's-b']); + expect(app.activeSessionId).toBe('s-b'); + }); }); describe('while the grid is open', () => { From 37ddcbe2f0b75a3037c1bb740bdd3667a0b32bc5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 08:51:08 +0200 Subject: [PATCH 2/7] fix(voice): dictation with the tile grid open reaches the focused tile With the tile grid open the main terminal is parked (display: none), but local echo stays on, so direct-mode dictation for the focused tile's session (which is activeSessionId) was appended to the main terminal's hidden local-echo overlay. Nothing appeared in the tile, Enter in the tile submitted without the dictated text, and the stranded text was later flushed into whichever tile had focus when the grid closed, or dropped. - _insertText: skip the overlay while _tilesOwnTerminal() is true, so the text goes through _sendToTarget to the session itself. - The post-insert refocus gives the keyboard to the focused tile (only if it is still the dictation target) instead of the parked main terminal. Outside the grid it still focuses the main terminal, so split view keeps its behaviour even when Pane B took focus mid-dictation. - Green send button: with tiles open, send only Enter to the target and leave the parked overlay and main-terminal predictions alone. The gate is _tilesOwnTerminal(), not _focusedPane().isPrimary: in split view a target equal to activeSessionId is Pane A with a visible overlay, and focus read at transcript time could otherwise push Pane A's dictation past its own unflushed overlay text. Tests: tile-grid dictation and green-send cases (both fail without the fix) plus a split-view pin in test/voice-input-target.test.ts. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/voice-input.js | 28 ++++++++--- test/voice-input-target.test.ts | 87 +++++++++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 9 deletions(-) diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index f792242d..741296ab 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -1007,15 +1007,30 @@ const VoiceInput = { } else { // Direct mode: inject into local echo overlay if available, else send to PTY. // The overlay belongs to the ACTIVE session's terminal, so text dictated - // for any other session must not be typed into it. + // for any other session must not be typed into it. It also belongs to the + // MAIN terminal, which the tile grid parks (display: none): with tiles + // open the text would sit in an invisible overlay the focused tile never + // sees, so it goes straight to the session instead. const isActive = target === app.activeSessionId; - if (isActive && app._localEchoEnabled && app._localEchoOverlay) { + const tilesOpen = !!app._tilesOwnTerminal?.(); + if (isActive && !tilesOpen && app._localEchoEnabled && app._localEchoOverlay) { app._localEchoOverlay.appendText(trimmed); } else { this._sendToTarget(target, trimmed).catch(() => {}); } this._showVoiceSendBtn(); - setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150); + setTimeout(() => { + if (!isActive) return; + // With the grid open the keyboard belongs to the focused tile; the + // parked main terminal cannot take focus. Split view keeps the main + // terminal, even if Pane B took focus meanwhile (it is not the target). + if (app._tilesOwnTerminal?.()) { + const pane = app._focusedPane?.(); + if (pane?.sessionId === target) pane.terminal?.focus(); + } else if (app.terminal) { + app.terminal.focus(); + } + }, 150); } }, @@ -1045,9 +1060,10 @@ const VoiceInput = { if (!target) return; // Simulate Enter key: if local echo is active, flush its buffer + send \r; // otherwise just send \r directly to the PTY. Both the overlay and the - // predictions belong to the ACTIVE session's terminal, so a dictation - // for another session just sends its Enter there. - if (target !== app.activeSessionId) { + // predictions belong to the ACTIVE session's MAIN terminal, so a + // dictation for another session, or for a tile while the grid has the + // main terminal parked, just sends its Enter there. + if (target !== app.activeSessionId || app._tilesOwnTerminal?.()) { this._sendToTarget(target, '\r').catch(() => {}); } else if (app._localEchoEnabled && app._localEchoOverlay) { const text = app._localEchoOverlay.pendingText || ''; diff --git a/test/voice-input-target.test.ts b/test/voice-input-target.test.ts index 624431b2..f33fb21e 100644 --- a/test/voice-input-target.test.ts +++ b/test/voice-input-target.test.ts @@ -8,6 +8,12 @@ * session. The target is now captured in `start()` (through `_focusedPane()`, * so a second terminal pane can claim it later) and every send path uses it. * + * With the tile grid open the main terminal (and with it the local-echo + * overlay) is parked with `display: none`, so direct-mode dictation used to be + * typed into an invisible overlay and never reached the focused tile. While + * `_tilesOwnTerminal()` is true the overlay is skipped and the keyboard goes + * back to the focused tile, not the parked main terminal. + * * Loaded via `vm` with a stubbed `app` (no jsdom). */ import { readFileSync } from 'node:fs'; @@ -27,10 +33,13 @@ type Voice = { _targetSessionId: string | null; }; -function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string } = {}) { +type Pane = { sessionId: string; isPrimary?: boolean; terminal?: { focus: () => void } }; + +function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string; tiles?: boolean; pane?: Pane } = {}) { const sendInput = vi.fn(async () => {}); const sendInputAsync = vi.fn(); const appendText = vi.fn(); + const overlayClear = vi.fn(); const showToast = vi.fn(); const gear = { classList: { contains: () => false, add: vi.fn(), remove: vi.fn() }, @@ -53,9 +62,19 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string showToast, terminal: { focus: vi.fn() }, _localEchoEnabled: !!opts.localEcho, - _localEchoOverlay: opts.localEcho ? { appendText, pendingText: '', clear: vi.fn() } : null, + _localEchoOverlay: opts.localEcho + ? { appendText, pendingText: '', clear: overlayClear, suppressBufferDetection: vi.fn() } + : null, + _predictiveEcho: { clearPredictions: vi.fn() }, }; if (opts.focused) app._focusedPane = () => ({ sessionId: opts.focused }); + // A full pane record, as terminal-ui.js _focusedPane() returns it. Reassign + // app._focusedPane in a test to move focus between panes. + if (opts.pane) { + const pane = opts.pane; + app._focusedPane = () => pane; + } + if (opts.tiles) app._tilesOwnTerminal = () => true; const context = vm.createContext({ console, setTimeout: (fn: () => void) => fn(), @@ -81,7 +100,12 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string // Recording itself is out of scope: start() only has to pick the target. voice._resolveProvider = () => 'webspeech'; voice._startWebSpeech = vi.fn(); - return { voice, app, sendInput, sendInputAsync, appendText, showToast, gear }; + return { voice, app, sendInput, sendInputAsync, appendText, overlayClear, showToast, gear }; +} + +function clickGreenSend(gear: { addEventListener: { mock: { calls: unknown[][] } } }) { + const handler = gear.addEventListener.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as () => void; + handler(); } describe('dictation target', () => { @@ -163,6 +187,63 @@ describe('dictation target', () => { expect(showToast).toHaveBeenCalledWith('That session has closed; dictation not sent', 'warning'); }); + it('with the tile grid open, dictation goes to the focused tile, not the parked overlay', () => { + const tileTerminal = { focus: vi.fn() }; + const { voice, app, appendText, sendInput, sendInputAsync } = load({ + localEcho: true, + tiles: true, + pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal }, + }); + voice.start(); + + voice._insertText('into the tile'); + + expect(appendText).not.toHaveBeenCalled(); + expect(sendInput).toHaveBeenCalledWith('into the tile'); + expect(sendInputAsync).not.toHaveBeenCalled(); + // The keyboard goes back to the tile; the main terminal is display: none. + expect(tileTerminal.focus).toHaveBeenCalled(); + expect((app.terminal as { focus: ReturnType }).focus).not.toHaveBeenCalled(); + }); + + it('with the tile grid open, the green send button sends only Enter to the tile session', () => { + const tileTerminal = { focus: vi.fn() }; + const { voice, app, gear, sendInput, overlayClear } = load({ + localEcho: true, + tiles: true, + pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal }, + }); + voice.start(); + voice._insertText('ship it'); + // Something stale in the parked main overlay must not ride along. + (app._localEchoOverlay as { pendingText: string }).pendingText = 'stale main-terminal text'; + + clickGreenSend(gear); + + expect(overlayClear).not.toHaveBeenCalled(); + expect(sendInput).not.toHaveBeenCalledWith('stale main-terminal text'); + expect(sendInput).toHaveBeenLastCalledWith('\r'); + // The parked main terminal's predictions are not this pane's. + expect( + (app._predictiveEcho as { clearPredictions: ReturnType }).clearPredictions + ).not.toHaveBeenCalled(); + }); + + it('split view is unchanged: a Pane A dictation keeps the overlay and refocuses the main terminal', () => { + const paneB = { focus: vi.fn() }; + const { voice, app, appendText, sendInput } = load({ localEcho: true, focused: 'session-a' }); + voice.start(); + // The user clicked into Pane B while speaking. + app._focusedPane = () => ({ sessionId: 'session-b', isPrimary: false, terminal: paneB }); + + voice._insertText('for pane a'); + + expect(appendText).toHaveBeenCalledWith('for pane a'); + expect(sendInput).not.toHaveBeenCalled(); + expect((app.terminal as { focus: ReturnType }).focus).toHaveBeenCalled(); + expect(paneB.focus).not.toHaveBeenCalled(); + }); + it('refuses to start with no session at all', () => { const { voice, app, showToast } = load(); app.activeSessionId = null; From 41643f2b3885f5bcaedb041a9bdf068d661b98f0 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 09:01:31 +0200 Subject: [PATCH 3/7] fix(css): tablet By-case strip, compositor-only needs pulse, phone chip height Tab Layout "By case" at tablet widths (600 to 767px, getDeviceType's 'tablet'): the case boxes could shrink in the tablet's one-row scrolling strip, so they squeezed and wrapped their tabs inside themselves and every tab past a box's first line was clipped under the fixed 48px header. The boxes now dissolve into the chip row as they already do on phones, which keeps the tablet's own 40px chip geometry. The rule sits in its own 600 to 767px block, not the 768px tablet block, so the desktop path at 768px and up (boxes keep their width, the strip wraps box by box) is untouched. Needs-you tile pulse: the glow animated box-shadow on the tile itself, so the whole tile (DOM-rendered terminal rows included, the whole stage when zoomed) was repainted every frame for as long as a prompt waited. The tile keeps its red border; the glow is now a static inset shadow on a .tile--needs::after overlay (inset because .tile is overflow: hidden and clips an outer one, z-index 3 above .tile-attach, pointer-events none) and only its opacity animates. The entering-and-needs animation shorthand is gone, since it would now blink the whole tile's opacity, and reduced motion keeps the static ring and hides the overlay. Welcome chips: the phone block set 36px, below the 40px that styles.css gives touch screens, and it wins on every phone, so phones got shorter chips than tablets. It now restates 40px. Tests: the tablet widths and the 768px boundary in tab-clusters, the opacity-only pulse overlay in tile-grid-motion, and the phone chip height in run-mode-ui; each fails against the previous CSS. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/mobile.css | 30 ++++++++++++-- src/web/public/styles.css | 49 ++++++++++++++-------- test/run-mode-ui.test.ts | 24 +++++++++++ test/tab-clusters.test.ts | 76 +++++++++++++++++++++++++++++++++++ test/tile-grid-motion.test.ts | 64 ++++++++++++++++++++++++++++- 5 files changed, 222 insertions(+), 21 deletions(-) diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index d2a34848..e9cce7ff 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -334,6 +334,27 @@ html.mobile-init .file-browser-panel { } } +/* Tab Layout "By case" at tablet widths, getDeviceType()'s 'tablet' (600 to + 767px). The strip is the tablet block's one scrolling row and + updateTabOverflowMode() never wraps it here, so the boxes, which may shrink + below 768px, squeezed and wrapped their tabs inside themselves instead of + overflowing: the strip never scrolled, and every tab past a box's first + line was clipped under the fixed header. The boxes dissolve into the chip + row as on phones (the 599px block), which keeps the tablet's own 40px chip + geometry; a 44px box would hang below the 48px header. Ends at 767px, not + at the tablet block's 768: from 768 getDeviceType() says 'desktop', and the + desktop rule in styles.css (flex-shrink: 0, wrapping box by box) owns the + strip. */ +@media (min-width: 600px) and (max-width: 767px) { + :where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster { + display: contents; + } + + :where(.header) .tab-cluster-label { + display: none; + } +} + /* Edge fade for the phone's header tab strip (used in the block below). Registered so the keyframes can interpolate them as lengths; @property is only valid at the top level, hence out here. */ @@ -1718,7 +1739,10 @@ html.mobile-init .file-browser-panel { } /* Only reached with the phone overview switched off. The primary spans the - column; the chips keep wrapping, a little taller for a thumb. */ + column; the chips keep wrapping at the finger size styles.css gives touch + screens (40px, its pointer: coarse rule). This rule loads later at equal + specificity, so a lower value here made a phone's chips shorter than a + tablet's; restating 40px also covers a narrow window with a mouse. */ .welcome-primary { width: 100%; justify-content: center; @@ -1727,8 +1751,8 @@ html.mobile-init .file-browser-panel { } .welcome-chip { - height: 36px; - border-radius: 18px; + height: 40px; + border-radius: 20px; } .history-show-more { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 91f345b4..85cb0e1b 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -4519,7 +4519,8 @@ html[data-skin="og"] .welcome-primary:hover { color: var(--green); } -/* Touch screens wide enough for this view (tablets) get finger-sized chips. */ +/* Touch screens get finger-sized chips. Phones too: mobile.css's 599px block + loads later and wins at equal specificity, so it restates the 40px. */ @media (pointer: coarse) { .welcome-chip { height: 40px; @@ -19741,29 +19742,50 @@ body.tile-grid-resizing--row * { color: var(--accent, #4a9eff); } -/* A permission prompt or question, visible across the room. Only the border - colour and a box-shadow animate: anything that moves layout would resize - the tile's PTY every frame. */ +/* A permission prompt or question, visible across the room: a red border and + a glow that pulses for as long as the prompt waits, which can be hours. + Anything that moves layout would resize the tile's PTY every frame, and a + box-shadow animated on the tile itself repainted the whole tile (its DOM + rendered terminal rows included, the whole stage when zoomed) every frame. + So the glow is a STATIC shadow on an overlay and only its opacity + animates, which the compositor runs without repainting the tile. The + shadow is inset because .tile is overflow: hidden and would clip an outer + one on its own pseudo-element. Above .tile-attach (z-index 2), and never + in the way of a click. */ .tile.tile--needs { border-color: var(--red, #ef4444); +} + +.tile.tile--needs::after { + content: ''; + position: absolute; + inset: 0; + z-index: 3; + border-radius: inherit; + pointer-events: none; + box-shadow: inset 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent); + opacity: 0; animation: tile-needs-pulse 1.6s ease-in-out infinite; } @keyframes tile-needs-pulse { 0%, 100% { - box-shadow: 0 0 0 0 color-mix(in srgb, var(--red, #ef4444) 0%, transparent); + opacity: 0; } 50% { - box-shadow: 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent); + opacity: 1; } } @media (prefers-reduced-motion: reduce) { .tile.tile--needs { - animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent); } + + .tile.tile--needs::after { + display: none; + } } /* The tab of a tiled session (the focused tile's tab is .active as usual). */ @@ -20030,13 +20052,6 @@ body.tile-grid-resizing--row * { animation-delay: calc(var(--tile-enter-index, 0) * 24ms); } -/* A tile that needs you enters AND pulses (one animation property). */ -.tile.tile--entering.tile--needs { - animation: - tile-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--tile-enter-index, 0) * 24ms) both, - tile-needs-pulse 1.6s ease-in-out infinite; -} - @keyframes tile-enter { from { opacity: 0; @@ -20121,7 +20136,7 @@ body.tile-grid-resizing--row * { @media (prefers-reduced-motion: reduce) { .tile-count-menu, .tile.tile--entering, - .tile.tile--entering.tile--needs, + .tile.tile--needs::after, .tile.tile--loading .tile-body::after, .tile-grid-ghosts .tile.tile--leaving, .tile-grid-ghosts.tile-grid-ghosts--release .tile.tile--leaving, @@ -20981,7 +20996,9 @@ html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .ta the routes ran through tabs, labels and box borders. Only a case wider than the whole strip still wraps inside its box (max-width: 100%), and the strip wraps around it too (_tabClustersWrapInside). The narrower screens keep - their scrolling strip. */ + their scrolling strip: mobile.css dissolves the boxes into its chip row on + phones (599px block) and on tablets (600 to 767px block), since a box + allowed to shrink there wrapped its tabs out of the one-row strip. */ @media (min-width: 768px) { .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster { flex-shrink: 0; diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 12cc0ef4..64537600 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; +import postcss, { type AtRule } from 'postcss'; import { describe, expect, it } from 'vitest'; function loadRunModeHarness() { @@ -638,6 +639,29 @@ describe('Codex quick start settings', () => { expect(html.indexOf('id="welcomeQr"')).toBeGreaterThan(tunnel); }); + it('never gives a phone shorter launcher chips than a tablet', () => { + // styles.css sizes chips for touch screens under (pointer: coarse); + // mobile.css's phone block loads later at equal specificity and wins on + // every phone, so a lower height there shrank the phone's chips below the + // tablet's (36px against 40px). + const chipHeights = (file: string, media: string) => { + const heights: number[] = []; + const css = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8'); + postcss.parse(css).walkRules('.welcome-chip', (rule) => { + if (rule.parent?.type !== 'atrule' || (rule.parent as AtRule).params !== media) return; + rule.walkDecls('height', (d) => { + heights.push(parseFloat(d.value)); + }); + }); + return heights; + }; + const touch = chipHeights('styles.css', '(pointer: coarse)'); + const phone = chipHeights('mobile.css', '(max-width: 599px)'); + expect(touch).toEqual([40]); + expect(phone.length).toBeGreaterThan(0); + for (const h of phone) expect(h).toBeGreaterThanOrEqual(Math.max(...touch)); + }); + it('falls back to the first ENABLED agent when the chosen run mode is disabled, never a hardcoded claude', () => { const catalog = CATALOG.map((cli) => cli.id === 'claude' || cli.id === 'codex' ? { ...cli, enabled: false } : cli diff --git a/test/tab-clusters.test.ts b/test/tab-clusters.test.ts index 21028e1f..8ff69d57 100644 --- a/test/tab-clusters.test.ts +++ b/test/tab-clusters.test.ts @@ -24,6 +24,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; +import postcss, { type AtRule, type Rule } from 'postcss'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const PUBLIC = join(process.cwd(), 'src/web/public'); @@ -350,4 +351,79 @@ describe('tab layouts by case and ledger (static)', () => { ); expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/); }); + + // The width range a mobile.css rule applies in: the file's own link media + // (max-width: 1023px) narrowed by every @media around the rule. `other` marks + // a rule that also needs a non-width feature, which therefore cannot be + // counted on to apply. + const mobileRuleWidths = (rule: Rule) => { + let min = 0; + let max = 1023; + let other = false; + for (let node = rule.parent; node && node.type !== 'root'; node = node.parent) { + if (node.type !== 'atrule') continue; + const at = node as AtRule; + if (at.name !== 'media') { + other = true; + continue; + } + for (const part of at.params.split(/\s+and\s+/)) { + const m = part.trim().match(/^\((min|max)-width:\s*(\d+)px\)$/); + if (!m) other = true; + else if (m[1] === 'min') min = Math.max(min, Number(m[2])); + else max = Math.min(max, Number(m[2])); + } + } + return { min, max, other }; + }; + const mobileClusterRules = () => { + const rules: Array<{ + selector: string; + decls: Record; + widths: ReturnType; + }> = []; + postcss.parse(mobileCss).walkRules((rule) => { + if (!rule.selector.includes('tab-cluster')) return; + const decls: Record = {}; + rule.walkDecls((d) => { + decls[d.prop] = d.value; + }); + rules.push({ selector: rule.selector, decls, widths: mobileRuleWidths(rule) }); + }); + return rules; + }; + + it('dissolves the boxes on tablet widths too, where the strip is one scrolling row (600 to 767px)', () => { + // getDeviceType() says 'tablet' from 600 to 767px, and updateTabOverflowMode() + // never wraps the strip there. A box allowed to shrink in that one-row strip + // squeezed and wrapped its tabs inside itself, and every tab past a box's + // first line was clipped under the 48px fixed header, unreachable. + const rules = mobileClusterRules(); + const box = '.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster'; + for (const width of [360, 599, 600, 700, 744, 767]) { + const applies = (r: (typeof rules)[number]) => !r.widths.other && r.widths.min <= width && width <= r.widths.max; + expect( + rules.some((r) => applies(r) && r.selector === `:where(.header) ${box}` && r.decls.display === 'contents'), + `box dissolved at ${width}px` + ).toBe(true); + expect( + rules.some( + (r) => applies(r) && r.selector === ':where(.header) .tab-cluster-label' && r.decls.display === 'none' + ), + `label hidden at ${width}px` + ).toBe(true); + } + }); + + it('leaves the desktop strip (768px and up) to styles.css: no mobile.css cluster rule reaches it', () => { + // mobile.css is linked up to 1023px, and from 768 getDeviceType() says + // 'desktop': the boxes keep their width there and the strip wraps box by + // box. The tablet block of mobile.css runs to 768 inclusive, so a cluster + // rule put inside it would dissolve the boxes at exactly 768. + const rules = mobileClusterRules(); + expect(rules.length).toBeGreaterThan(0); + for (const r of rules) { + expect(r.widths.max, `${r.selector} stops below 768px`).toBeLessThan(768); + } + }); }); diff --git a/test/tile-grid-motion.test.ts b/test/tile-grid-motion.test.ts index d2f1985a..d2a93ed9 100644 --- a/test/tile-grid-motion.test.ts +++ b/test/tile-grid-motion.test.ts @@ -24,6 +24,7 @@ */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import postcss, { type AtRule, type Rule } from 'postcss'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeEl, @@ -279,7 +280,15 @@ describe('the CSS', () => { }; it('every new keyframe animates opacity and transform only', () => { - for (const name of ['tile-enter', 'tile-ghost-dim', 'tile-leave', 'tile-leave-now', 'tile-loading-breathe', 'tile-count-menu-in']) { + for (const name of [ + 'tile-enter', + 'tile-ghost-dim', + 'tile-leave', + 'tile-leave-now', + 'tile-loading-breathe', + 'tile-count-menu-in', + 'tile-needs-pulse', + ]) { const body = keyframes(name); expect(body, name).not.toBe(''); const props = [...body.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]); @@ -300,11 +309,62 @@ describe('the CSS', () => { it('nothing moves under prefers-reduced-motion, and a web tab hides the copy', () => { const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n .tile-count-menu,')); const reduced = block.slice(0, block.indexOf('\n}\n')); - for (const sel of ['.tile-count-menu', '.tile.tile--entering', '.tile.tile--loading .tile-body::after', '.tile-grid-ghosts .tile.tile--leaving']) { + for (const sel of [ + '.tile-count-menu', + '.tile.tile--entering', + '.tile.tile--needs::after', + '.tile.tile--loading .tile-body::after', + '.tile-grid-ghosts .tile.tile--leaving', + ]) { expect(reduced).toContain(sel); } expect(reduced).toMatch(/animation: none;/); expect(reduced).toMatch(/\.tile-body \.xterm \{\s*transition: none;/); expect(css).toMatch(/\.main\.webview-active \.tile-grid-ghosts \{\s*display: none;/); }); + + it('the needs-you pulse fades a static glow overlay in and out, never repainting the tile itself', () => { + // A needs tile pulses for as long as its prompt waits, hours at a time. A + // box-shadow animated on the tile repainted the whole tile (its DOM-rendered + // terminal rows with it, the whole stage when zoomed) every frame. + const rules: Array<{ selector: string; media: string; decls: Record }> = []; + postcss.parse(css).walkRules((rule: Rule) => { + if (!rule.selector.includes('tile--needs')) return; + const decls: Record = {}; + rule.walkDecls((d) => { + decls[d.prop] = d.value; + }); + const media = rule.parent?.type === 'atrule' ? (rule.parent as AtRule).params : ''; + for (const selector of rule.selectors) rules.push({ selector, media, decls }); + }); + const find = (selector: string, media = '') => rules.filter((r) => r.selector === selector && r.media === media); + + // The tile keeps its red border and animates nothing. + const [tile] = find('.tile.tile--needs'); + expect(tile.decls['border-color']).toContain('var(--red'); + expect(tile.decls.animation).toBeUndefined(); + expect(tile.decls['box-shadow']).toBeUndefined(); + + // The glow is an overlay: a STATIC inset shadow (.tile is overflow: hidden + // and clips an outer one), above the Attach overlay, out of the pointer's way. + const [glow] = find('.tile.tile--needs::after'); + expect(glow.decls.content).toBe("''"); + expect(glow.decls.position).toBe('absolute'); + expect(glow.decls.inset).toBe('0'); + expect(glow.decls['pointer-events']).toBe('none'); + expect(Number(glow.decls['z-index'])).toBeGreaterThan(2); + expect(glow.decls['box-shadow']).toMatch(/^inset /); + expect(glow.decls.animation).toMatch(/^tile-needs-pulse /); + + // Only that overlay runs the pulse: no rule restates it on the tile (the old + // entering-and-needs shorthand would now blink the whole tile's opacity). + const pulsing = rules.filter((r) => /tile-needs-pulse/.test(r.decls.animation ?? '')); + expect(pulsing.map((r) => r.selector)).toEqual(['.tile.tile--needs::after']); + expect(rules.some((r) => r.selector.includes('tile--entering') && r.selector.includes('tile--needs'))).toBe(false); + + // Reduced motion: no pulse, and a static ring on the tile instead. + const reduce = '(prefers-reduced-motion: reduce)'; + expect(find('.tile.tile--needs::after', reduce).some((r) => r.decls.display === 'none')).toBe(true); + expect(find('.tile.tile--needs', reduce).some((r) => /^0 0 0 2px /.test(r.decls['box-shadow'] ?? ''))).toBe(true); + }); }); From 82c87f56d12bdf94234b3d3999bd7bdae58a52fa Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 09:23:05 +0200 Subject: [PATCH 4/7] fix(i18n): zh-CN for the case picker rows, Bottom bar settings, new toasts, tile and spreadsheet text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Case picker: the "New or link a case…" and "Case settings…" rows that replaced the translated + and gear buttons get zh-CN entries reusing the buttons' wording, plus the list's "No cases match". - App Settings, Bottom bar: the whole group translates as one (heading, the four Git status rows with #543's max repositories and git timeout, and their descriptions), so it never reads half English. The Git panel's two button names, which the new "Git status" key reaches, read naturally too. - Toasts: the three "Could not open a new window for this ..." errors and the dictation-closed warning. - Spreadsheet preview: "Spreadsheet preview failed ()" gets a pattern. Worker refusals map their error code to one user sentence each in the renderer (the raw message goes to the console), feature ids read as words (pivot tables, external links), and the notice bar translates each item before the join; a number format's code passes through untouched. The worker and core are unchanged, so their pinned strings and the asset version hash stay as they were. - Header Stats Style Tiles: the connection tile's value word reads in Chinese through scoped "Connection tile: " keys, never bare-word keys ("retry" is also the orchestrator's Retry button, "LIVE" a resume list badge). The indicator cache now includes the UI language, so a language switch repaints it on the next update. - Tile grid: the "Loading…" label was CSS content text the translator cannot reach; it is now content: attr(data-loading-label), written through the translator when the tile is built and each time it starts loading. Tests pin every new string (zh differs, no English left, English unchanged) and fail without these changes: the tile harvest sees the loading label and the rename field, a CSS guard keeps words out of tile generated content, the Bottom bar group, the case picker rows and toasts read from their source, the connection tile across a language switch, and the spreadsheet error codes, notice bar and status line. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 23 ++++- src/web/public/i18n.js | 60 ++++++++++++ src/web/public/index.html | 2 +- src/web/public/spreadsheet-preview.js | 68 ++++++++++++- src/web/public/styles.css | 6 +- src/web/public/tile-grid.js | 14 +++ test/header-stats-style.test.ts | 60 ++++++++++++ test/i18n-exit-run-help.test.ts | 41 ++++++++ test/spreadsheet-preview.test.ts | 131 ++++++++++++++++++++++++++ test/tab-layout-settings-i18n.test.ts | 67 +++++++++++++ test/tile-grid-i18n.test.ts | 72 +++++++++++++- 11 files changed, 533 insertions(+), 11 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 075d3c14..447a959e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -985,6 +985,7 @@ class CodemanApp { // Last rendered connection-indicator tuple; the hot input path skips DOM // writes when the freshly computed descriptor is identical (COD-136). this._lastIndicatorDescriptor = null; + this._lastIndicatorLanguage = null; // the UI language it was rendered in this._postDraining = new Set(); // sessionIds with an in-flight POST drainer // Terminal sockets OTHER than the primary one (`this._ws`), keyed by the // session they are bound to: the split pane's second terminal registers its @@ -4354,6 +4355,21 @@ class CodemanApp { } } + /** + * The tile's value word as shown: i18n.js's scoped 'Connection tile: ' + * entry in Chinese, the English word otherwise. Never a bare-word key: those + * would also translate other text ("retry" is the orchestrator's Retry button), + * which is why the value span carries data-i18n-skip. + * @param {string} value + * @returns {string} + */ + _connectionTileValueText(value) { + if (!value) return ''; + const key = `Connection tile: ${value}`; + const translated = typeof window.codemanT === 'function' ? window.codemanT(key) : key; + return translated && translated !== key ? translated : value; + } + _updateConnectionIndicator() { const indicator = this.$('connectionIndicator'); const dot = this.$('connectionDot'); @@ -4365,8 +4381,12 @@ class CodemanApp { // writes when nothing changed (COD-136) — the compute above is DOM-free. const next = this._computeConnectionDescriptor(); const prev = this._lastIndicatorDescriptor; + // The tile's value word is written in the UI language, so a language + // switch counts as a change too. + const language = window.CodemanI18n?.language || 'en'; if ( prev && + language === this._lastIndicatorLanguage && prev.display === next.display && prev.dotClass === next.dotClass && prev.text === next.text && @@ -4375,6 +4395,7 @@ class CodemanApp { return; } this._lastIndicatorDescriptor = next; + this._lastIndicatorLanguage = language; indicator.style.display = next.display; if (next.display !== 'none') { @@ -4386,7 +4407,7 @@ class CodemanApp { if (tileLabel && tileValue) { const words = this._connectionTileWords(next); tileLabel.textContent = words.label; - tileValue.textContent = words.value; + tileValue.textContent = this._connectionTileValueText(words.value); tileValue.className = `connection-tile-value ${words.state}`.trim(); } } diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 01148ca2..bf5af327 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -230,6 +230,11 @@ 'Create New Case': '新建案例', 'Create new case': '新建案例', 'Link Existing': '关联现有目录', + // The toolbar case picker's action rows (session-ui.js CASE_PICKER_ACTIONS), + // which replaced the "+" and gear buttons, and its empty state. + 'New or link a case…': '新建或关联案例…', + 'Case settings…': '案例设置…', + 'No cases match': '没有匹配的案例', 'Add Case': '添加案例', 'Open sessions': '打开会话', 'Recent Sessions': '最近会话', @@ -379,6 +384,32 @@ 'As before (bars)': '与以前相同(进度条)', 'Compact (default)': '紧凑(默认)', 'Tiles (label over value)': '磁贴(标签在数值上方)', + // The connection tile's value word in that style (app.js + // _connectionTileValueText). Scoped keys on purpose: the bare words also + // name other things ("retry" is the orchestrator's Retry button, "LIVE" a + // badge in the resume list), and a bare key would translate those too. + 'Connection tile: live': '已连接', + 'Connection tile: fallback': '回退', + 'Connection tile: offline': '离线', + 'Connection tile: queued': '已排队', + 'Connection tile: retry': '重连中', + // App Settings → Bottom bar, translated as one group (the Git status rows, + // #543's two included). Keys are the trimmed label text, without the scope tag. + 'Bottom bar': '底部栏', + 'Git status': 'Git 状态', + "Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.": + '在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。', + 'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件', + 'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.': + '在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。', + 'Git status: max repositories': 'Git 状态:最多仓库数', + "When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.": + '当会话的文件夹包含多个项目(而不是本身就是一个项目)时,Git 窗口最多列出这么多个(1 到 50,默认 12)。每个仓库每次刷新都要运行几条 git 命令。', + 'Git status: git timeout': 'Git 状态:git 超时', + 'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.': + '单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。', + 'Refresh git status': '刷新 Git 状态', + 'Close git status': '关闭 Git 状态', Panels: '面板', Monitor: '监视器', 'Project Insights': '项目洞察', @@ -699,6 +730,13 @@ 'Nothing to copy': '没有可复制的内容', // A `#session=` link whose session never appeared (app.js _armUrlSessionWait). 'Session not found': '未找到会话', + // A native host that would not open a window (app.js openInHostWindow); the + // "dashboard" is a web tab. + 'Could not open a new window for this session': '无法在新窗口中打开此会话', + 'Could not open a new window for this preview': '无法在新窗口中打开此预览', + 'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签', + // Dictation whose session closed before the text was sent (voice-input.js). + 'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送', // Terminal touch-selection bar (long-press to select). The bar is a sibling of // `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply. Copy: '复制', @@ -872,6 +910,24 @@ 'Spreadsheet parser message failed.': '电子表格解析器消息出错。', 'Spreadsheet parser message failed': '电子表格解析器消息出错', 'Spreadsheet preview is unavailable.': '电子表格预览不可用。', + 'Spreadsheet preview must use a same-origin URL': '电子表格预览必须使用同源 URL', + // Worker refusals, one sentence per error code (spreadsheet-preview.js + // WORKER_ERROR_TEXT), and the notice bar's items (renderWarnings). The + // counted ones are patterns in translateDynamic below. + 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.': + '此工作簿受密码保护或为旧版 .xls 格式,无法预览。', + 'This workbook uses ZIP64, which the preview does not support.': '此工作簿使用 ZIP64 格式,预览不支持该格式。', + 'This workbook is too large or complex to preview.': '此工作簿过大或过于复杂,无法预览。', + 'This workbook could not be read. The file may be damaged or not a valid .xlsx file.': + '无法读取此工作簿。文件可能已损坏,或不是有效的 .xlsx 文件。', + charts: '图表', + drawings: '绘图', + 'pivot tables': '数据透视表', + 'external links': '外部链接', + macros: '宏', + 'Formula has no cached result': '公式没有缓存的计算结果', + 'Unsupported cell value': '不支持的单元格值', + 'Unsupported number format': '不支持的数字格式', 'Export as JSON': '导出为 JSON', 'Export as Markdown': '导出为 Markdown', 'Mark all read': '全部标为已读', @@ -1099,6 +1155,10 @@ [/^Selected: (.+)$/, (_m, value) => `已选择:${value}`], [/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`], [/^Will create: (.+)$/, (_m, path) => `将创建:${path}`], + // The spreadsheet preview: an HTTP status, and the notice bar's counts. + [/^Spreadsheet preview failed \((\d+)\)$/, (_m, status) => `电子表格预览失败(${status})`], + [/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`], + [/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`], // Group names are user text: they pass through untranslated. [/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`], [ diff --git a/src/web/public/index.html b/src/web/public/index.html index 005468ba..335d3785 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -154,7 +154,7 @@
diff --git a/src/web/public/spreadsheet-preview.js b/src/web/public/spreadsheet-preview.js index d43334a1..27d6297f 100644 --- a/src/web/public/spreadsheet-preview.js +++ b/src/web/public/spreadsheet-preview.js @@ -31,6 +31,67 @@ workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`, }); + // What a refusal from the worker says on screen. The core's own messages + // (spreadsheet-xlsx-core.js) are developer detail, so each error code maps to + // one sentence with a zh-CN entry in i18n.js, and the raw message goes to the + // console. Any other code (parse-failed carries ExcelJS's own exception text) + // shows the generic failure. + const TOO_LARGE_OR_COMPLEX = 'This workbook is too large or complex to preview.'; + const UNREADABLE = 'This workbook could not be read. The file may be damaged or not a valid .xlsx file.'; + const WORKER_ERROR_TEXT = Object.freeze({ + encrypted: 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.', + zip64: 'This workbook uses ZIP64, which the preview does not support.', + malformed: UNREADABLE, + 'number-format': UNREADABLE, + 'entry-limit': TOO_LARGE_OR_COMPLEX, + 'entry-size': TOO_LARGE_OR_COMPLEX, + 'inflated-size': TOO_LARGE_OR_COMPLEX, + 'compression-ratio': TOO_LARGE_OR_COMPLEX, + 'worksheet-limit': TOO_LARGE_OR_COMPLEX, + 'element-limit': TOO_LARGE_OR_COMPLEX, + 'row-limit': TOO_LARGE_OR_COMPLEX, + 'cell-limit': TOO_LARGE_OR_COMPLEX, + 'merge-limit': TOO_LARGE_OR_COMPLEX, + 'style-limit': TOO_LARGE_OR_COMPLEX, + }); + + // The workbook features the preview leaves out, as the core names them + // (featureForName), in words for the notice bar. + const FEATURE_LABELS = Object.freeze({ + charts: 'charts', + drawings: 'drawings', + pivotTables: 'pivot tables', + externalLinks: 'external links', + macros: 'macros', + }); + const UNSUPPORTED_FORMAT_PREFIX = 'Unsupported number format: '; + + function translate(text) { + return global.codemanT?.(text) || text; + } + + function own(map, key) { + return Object.prototype.hasOwnProperty.call(map, key); + } + + function workerErrorText(payload) { + const code = String(payload?.code || ''); + if (payload?.message) console.warn(`Spreadsheet preview: ${code || 'error'}: ${payload.message}`); + return own(WORKER_ERROR_TEXT, code) ? WORKER_ERROR_TEXT[code] : 'Spreadsheet preview failed'; + } + + // One notice bar item, translated on its own (the bar is one text node, which + // the i18n layer could only match whole). A number format's code is workbook + // text: it is appended as is, never passed through the translator, which + // would read a `{…}` in it as a placeholder. + function warningText(warning) { + const text = String(warning); + if (text.startsWith(UNSUPPORTED_FORMAT_PREFIX)) { + return `${translate('Unsupported number format')}: ${text.slice(UNSUPPORTED_FORMAT_PREFIX.length)}`; + } + return translate(own(FEATURE_LABELS, text) ? FEATURE_LABELS[text] : text); + } + function message(container, text, kind) { container.textContent = ''; const state = document.createElement('div'); @@ -209,9 +270,8 @@ if (!notice) return; const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])]; notice.hidden = warnings.length === 0; - const warningLabel = - global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown'; - notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : ''; + const warningLabel = translate('Some workbook features are not shown'); + notice.textContent = warnings.length ? `${warningLabel}: ${warnings.map(warningText).join(', ')}` : ''; } function pinHeadings() { @@ -459,7 +519,7 @@ clearTimer(); renderMetadata(payload); } else if (payload.type === 'tile') renderTile(payload); - else if (payload.type === 'error') fail(payload.message); + else if (payload.type === 'error') fail(workerErrorText(payload)); }; worker.onerror = () => fail('Spreadsheet parser failed.'); worker.onmessageerror = () => fail('Spreadsheet parser message failed.'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 85cb0e1b..3cf54f08 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19843,9 +19843,11 @@ body.tile-grid-resizing--row * { } /* Quiet "loading" state while the tile's capture waits in the grid's one load - queue (or runs). */ + queue (or runs). The text is the body's data-loading-label, which tile-grid.js + writes in the UI language (_setTileLoadingLabel): generated content is out of + the i18n layer's reach, so no literal words here. */ .tile.tile--loading .tile-body::after { - content: 'Loading\2026'; + content: attr(data-loading-label); position: absolute; top: 6px; right: 10px; diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 1545f169..1216a4b4 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -233,6 +233,8 @@ Object.assign(CodemanApp.prototype, { onChange: (tile, state) => { const entry = grid.tiles.get(tile.sessionId); if (entry?.tile !== tile) return; + // Rewritten on the way in, so a language switched since is picked up. + if (state !== 'idle') this._setTileLoadingLabel(entry.body); entry.el.classList.toggle('tile--loading', state !== 'idle'); // The first capture has landed (or failed): the terminal fades in, // whole, instead of showing its replay scroll by. @@ -243,6 +245,17 @@ Object.assign(CodemanApp.prototype, { return grid.queue; }, + /** + * The "Loading…" label of a tile body. It is CSS generated content (styles.css, + * `content: attr(data-loading-label)`), which the i18n layer never reaches, so + * the text is written here in the UI language. + */ + _setTileLoadingLabel(body) { + if (!body) return; + const t = window.codemanT; + body.dataset.loadingLabel = typeof t === 'function' ? t('Loading…') : 'Loading…'; + }, + _tileGridSection() { let section = document.getElementById('tileGrid'); if (!section) { @@ -1336,6 +1349,7 @@ Object.assign(CodemanApp.prototype, { const header = this._buildTileHeader(sessionId); const body = document.createElement('div'); body.className = 'tile-body'; + this._setTileLoadingLabel(body); el.append(header.el, body); // Pressing a tile is a human selection: it focuses the tile and // acknowledges its idle alert (the already-focused tile hits diff --git a/test/header-stats-style.test.ts b/test/header-stats-style.test.ts index 044d9845..1a7b8d1e 100644 --- a/test/header-stats-style.test.ts +++ b/test/header-stats-style.test.ts @@ -14,6 +14,9 @@ * - The parts only the new styles draw (stat rings, plan rings, meters, tile words) * are rendered with sane values and hidden by default in CSS, which is what * keeps 'classic' looking exactly as before. + * - The connection tile's value word reads in Chinese through i18n.js's real + * t(), from scoped keys (a bare 'retry' or 'live' key would also translate + * other surfaces), and follows a language switch. * * The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'). * @@ -22,6 +25,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import vm from 'node:vm'; import { JSDOM } from 'jsdom'; import postcss from 'postcss'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -195,6 +199,62 @@ describe('the parts the new styles draw', () => { expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected'); }); + it('writes the value word in the UI language, from scoped keys, and follows a language switch', () => { + const translator = (language: string) => { + const dom = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + vm.runInContext(read('i18n.js'), dom.getInternalVMContext(), { filename: 'i18n.js' }); + const api = (dom.window as unknown as { CodemanI18n: { t(s: string): string; configure(o: object): void } }) + .CodemanI18n; + api.configure({ language }); + return { t: api.t, close: () => dom.window.close() }; + }; + const zh = translator('zh-CN'); + const en = translator('en'); + try { + window.codemanT = zh.t; + window.CodemanI18n = { language: 'zh-CN' }; + const app = makeApp(); + app._computeConnectionDescriptor = () => ({ + display: 'flex', + dotClass: 'connection-dot connected', + text: 'WS', + title: 'Terminal connected over WebSocket', + }); + app._updateConnectionIndicator(); + expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS'); + expect(document.getElementById('connectionTileValue')!.textContent).toBe('已连接'); + // The same descriptor after a switch back to English is not skipped as unchanged. + window.codemanT = en.t; + window.CodemanI18n = { language: 'en' }; + app._updateConnectionIndicator(); + expect(document.getElementById('connectionTileValue')!.textContent).toBe('live'); + + // Every value word has its own Chinese, and none is a bare key. + window.codemanT = zh.t; + const values = [ + 'connection-dot connected', + 'connection-dot fallback', + 'connection-dot offline', + 'connection-dot draining', + 'connection-dot reconnecting', + ].map((dotClass) => app._connectionTileWords({ dotClass, text: '' }).value); + expect(values).toEqual(['live', 'fallback', 'offline', 'queued', 'retry']); + const shown = values.map((value: string) => app._connectionTileValueText(value)); + expect(shown.filter((text: string) => /[A-Za-z]/.test(text))).toEqual([]); + expect(values.filter((value: string) => zh.t(value) !== value)).toEqual([]); + // The span the observer must leave alone (its text is set here, already translated). + expect(INDEX).toMatch(/id="connectionTileValue" data-i18n-skip>/); + } finally { + delete window.codemanT; + delete window.CodemanI18n; + zh.close(); + en.close(); + } + }); + it('fills the CPU and MEM rings from the stats poll, clamped, red past 80%', () => { const app = makeApp(); const ring = (id: string) => { diff --git a/test/i18n-exit-run-help.test.ts b/test/i18n-exit-run-help.test.ts index d96b7836..41841c24 100644 --- a/test/i18n-exit-run-help.test.ts +++ b/test/i18n-exit-run-help.test.ts @@ -7,6 +7,10 @@ * session name in the accessible name passed through untranslated (a name * that is itself a dictionary word included), and read unchanged in English. * + * The toolbar's case picker rows (which replaced the translated "+" and gear + * buttons) and the host-window and dictation toasts are read from their source, + * so a renamed label without an entry fails here. + * * Port: N/A. */ import { readFileSync } from 'node:fs'; @@ -183,3 +187,40 @@ describe('the Help modal and the shortcut overlay in zh-CN', () => { expect(overlay).not.toMatch(//); }); }); + +describe('the case picker rows and the host-window and dictation toasts in zh-CN', () => { + const SESSION_UI = read('session-ui.js'); + const actions = SESSION_UI.slice( + SESSION_UI.indexOf('const CASE_PICKER_ACTIONS = ['), + SESSION_UI.indexOf('];', SESSION_UI.indexOf('const CASE_PICKER_ACTIONS = [')) + ); + const pickerLabels = [...actions.matchAll(/label: '([^']+)'/g)].map((m) => m[1]); + const toasts = ['app.js', 'panels-ui.js', 'webview-tabs.js', 'voice-input.js'].flatMap((file) => + [ + ...read(file).matchAll( + /showToast\??\.?\(\s*'(Could not open a new window for this \w+|That session has closed; dictation not sent)'/g + ), + ].map((m) => m[1]) + ); + + it('finds the strings it checks (the check is not vacuous)', () => { + expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026']); + expect(SESSION_UI).toContain('
No cases match
'); + expect(toasts.sort()).toEqual([ + 'Could not open a new window for this dashboard', + 'Could not open a new window for this preview', + 'Could not open a new window for this session', + 'That session has closed; dictation not sent', + ]); + }); + + it('each one reads in Chinese with no English left, and unchanged in English', () => { + const bad = [...pickerLabels, 'No cases match', ...toasts].filter((s) => { + const text = zh.api.t(s); + return text === s || leftover(text).length > 0 || en.api.t(s) !== s; + }); + expect(bad).toEqual([]); + // The rows reuse the wording of the buttons they replaced. + expect(zh.api.t('Case settings\u2026')).toBe(`${zh.api.t('Case settings')}\u2026`); + }); +}); diff --git a/test/spreadsheet-preview.test.ts b/test/spreadsheet-preview.test.ts index 45961c5d..e4329294 100644 --- a/test/spreadsheet-preview.test.ts +++ b/test/spreadsheet-preview.test.ts @@ -5,6 +5,12 @@ * The renderer runs against a standalone JSDOM window (like i18n-branding.test.ts) * rather than the `jsdom` vitest environment. Workbook strings are asserted to * land as text, never markup. + * + * What the user reads is the renderer's, not the worker's: each worker error + * code is one sentence (the raw message goes to the console), feature ids are + * words, and every one of those, the HTTP status line and the notice bar's + * items read in Chinese through i18n.js's real t(); a number format's code is + * workbook text and passes through as is. */ import { readFileSync } from 'node:fs'; @@ -14,6 +20,12 @@ import { JSDOM } from 'jsdom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview.js'), 'utf8'); +const coreSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8'); +const workerSource = readFileSync( + resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview-worker.js'), + 'utf8' +); +const i18nSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8'); const panelsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8'); // A real origin: the renderer resolves its fetch URL against `location.href`. @@ -392,6 +404,125 @@ describe('spreadsheet preview renderer', () => { }); }); +describe('spreadsheet preview text the user reads', () => { + type Translate = (s: string) => string; + const translator = (language: string): Translate => { + const i18nDom = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + vm.runInContext(i18nSource, i18nDom.getInternalVMContext(), { filename: 'i18n.js' }); + const api = (i18nDom.window as any).CodemanI18n as { t: Translate; configure(o: object): void }; + api.configure({ language }); + return api.t; + }; + const zh = translator('zh-CN'); + const en = translator('en'); + /** What may stay Latin in a translation: file formats, the URL acronym, digits. */ + const leftover = (text: string) => text.replace(/\b(xlsx|xls|ZIP64|URL)\b/g, '').match(/[A-Za-z]+/g) ?? []; + + beforeEach(() => { + WorkerMock.instances = []; + document.body.innerHTML = '
'; + delete (window as any).codemanT; + window.requestAnimationFrame = (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) as unknown as number; + window.cancelAnimationFrame = (id: number) => clearTimeout(id); + }); + + async function openLoaded() { + document.body.innerHTML = '
'; + const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) })); + loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); + const worker = WorkerMock.instances.at(-1)!; + worker.emit({ type: 'ready' }); + await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled()); + return worker; + } + + const shown = () => document.querySelector('.spreadsheet-preview-message')?.textContent ?? ''; + + it('shows one sentence per worker error code, never the raw message, and each reads in Chinese', async () => { + const codes = [ + ...new Set( + [...`${coreSource}\n${workerSource}`.matchAll(/(?:\bfail|XlsxPreviewError)\('([a-z0-9-]+)'/g)].map((m) => m[1]) + ), + ]; + // Not vacuous: the refusals the core and the worker raise. + expect(codes).toEqual(expect.arrayContaining(['encrypted', 'zip64', 'malformed', 'cell-limit', 'style-limit'])); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const texts = new Map(); + for (const code of [...codes, 'parse-failed']) { + const worker = await openLoaded(); + worker.emit({ type: 'error', code, message: `raw ${code} detail` }); + texts.set(code, shown()); + } + expect([...texts.values()].filter((text) => /raw .* detail/.test(text))).toEqual([]); + // Every code the core and worker raise has its own sentence; only an + // unlisted one (ExcelJS's own failures) shows the generic failure. + expect(codes.filter((code) => texts.get(code) === 'Spreadsheet preview failed')).toEqual([]); + expect(texts.get('parse-failed')).toBe('Spreadsheet preview failed'); + expect(texts.get('encrypted')).toBe( + 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.' + ); + // The developer detail is kept, in the console. + expect(warn).toHaveBeenCalledWith(expect.stringContaining('raw encrypted detail')); + const bad = [...new Set(texts.values())].filter((text) => { + const t = zh(text); + return t === text || leftover(t).length > 0 || en(text) !== text; + }); + expect(bad).toEqual([]); + } finally { + warn.mockRestore(); + } + }); + + it('words the notice bar: feature ids as words, each item translated, a format code as it is', async () => { + const notice = async () => { + const worker = await openLoaded(); + worker.emit({ ...metadata(), warnings: ['pivotTables', 'externalLinks', 'charts', 'drawings', 'macros'] }); + const request = worker.postMessage.mock.calls.at(-1)?.[0]; + worker.emit({ + type: 'tile', + requestId: request.requestId, + sheetId: '1', + cells: [], + warnings: [ + 'View truncated to the first 2500 cells', + 'Formula has no cached result', + 'Unsupported cell value', + 'Unsupported number format: {name}"x"', + '3 unsupported number formats', + ], + }); + return document.querySelector('.spreadsheet-preview-notice')!.textContent!; + }; + const english = await notice(); + expect(english).toBe( + 'Some workbook features are not shown: pivot tables, external links, charts, drawings, macros, ' + + 'View truncated to the first 2500 cells, Formula has no cached result, Unsupported cell value, ' + + 'Unsupported number format: {name}"x", 3 unsupported number formats' + ); + (window as any).codemanT = zh; + const chinese = await notice(); + // The format code is workbook text: kept as is ({name} is not a placeholder here). + expect(chinese).toContain('不支持的数字格式: {name}"x"'); + expect(leftover(chinese.replace('{name}"x"', ''))).toEqual([]); + expect(chinese.startsWith(`${zh('Some workbook features are not shown')}: 数据透视表, 外部链接, 图表`)).toBe(true); + }); + + it('the HTTP status line reads in Chinese with its status', async () => { + const fetchMock = vi.fn(async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) })); + loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 }); + WorkerMock.instances.at(-1)!.emit({ type: 'ready' }); + await vi.waitFor(() => expect(shown()).toBe('Spreadsheet preview failed (404)')); + expect(zh(shown())).toBe('电子表格预览失败(404)'); + expect(en(shown())).toBe(shown()); + expect(zh('Spreadsheet preview must use a same-origin URL')).toBe('电子表格预览必须使用同源 URL'); + }); +}); + /** * The overlay wiring in panels-ui.js: which route the renderer is pointed at, * and that the worker/fetch lifecycle is torn down on close and on re-open. diff --git a/test/tab-layout-settings-i18n.test.ts b/test/tab-layout-settings-i18n.test.ts index 0dc27a36..55b68ac4 100644 --- a/test/tab-layout-settings-i18n.test.ts +++ b/test/tab-layout-settings-i18n.test.ts @@ -12,6 +12,9 @@ * literal silently replaces the first). * - The state-row headings the strip draws (CodemanTabTriage's labels) * translate too. + * - The Bottom bar group (the Git status rows, #543's max repositories and + * git timeout among them) translates as one group: heading, every label and + * description, and the Git panel's names that the group's key now reaches. * * The rows' `desktop` tag is a generic tag shared with rows older than #538 * (WebGL Renderer) and is left out of the check. @@ -115,3 +118,67 @@ describe('the Tab Layout settings rows in zh-CN', () => { expect(labels.filter((s) => api.t(s) === s || leftover(api.t(s)).length)).toEqual([]); }); }); + +/** The Bottom bar group: its heading, then each row's label (without its tag) and description. */ +function bottomBarStrings(doc: Document): Map { + const out = new Map(); + const group = doc.getElementById('appSettingsShowGitStatus')!.closest('.set-group')!; + out.set('heading', group.querySelector('h4')!.textContent!.trim()); + for (const row of group.querySelectorAll('.set-row')) { + const id = row.querySelector('input, select')!.id; + const label = row.querySelector('.set-row-label')!.cloneNode(true) as Element; + label.querySelector('.set-scope')?.remove(); + out.set(`${id} label`, label.textContent!.trim()); + out.set(`${id} description`, row.querySelector('.set-row-desc')!.textContent!.trim()); + } + return out; +} + +describe('the Bottom bar settings group in zh-CN', () => { + const englishGroup = bottomBarStrings(source.window.document); + const chineseGroup = bottomBarStrings(dom.window.document); + /** What may stay Latin here: the git and Docker names, and the product name. */ + const left = (text: string) => text.replace(/\b(Git|git|Docker|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? []; + + it("finds the heading and the four Git status rows, #543's two included (the check is not vacuous)", () => { + expect(englishGroup.size).toBe(9); + expect(englishGroup.get('heading')).toBe('Bottom bar'); + expect(englishGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git status: max repositories'); + expect(englishGroup.get('appSettingsGitStatusTimeout label')).toBe('Git status: git timeout'); + }); + + it('translates the heading, every label and every description, with no English left', () => { + const bad: string[] = []; + for (const [where, text] of chineseGroup) { + if (text === englishGroup.get(where) || left(text).length) bad.push(`${where}: "${text}"`); + } + expect(bad).toEqual([]); + expect(chineseGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git 状态:最多仓库数'); + }); + + it('reads exactly as before in English, and adds each key once', () => { + const en = new JSDOM('', { + runScripts: 'outside-only', + url: 'http://localhost/', + }); + vm.runInContext(I18N, en.getInternalVMContext(), { filename: 'i18n.js' }); + const t = (en.window as unknown as { CodemanI18n: Api }).CodemanI18n; + t.configure({ language: 'en' }); + expect([...englishGroup.values()].filter((s) => t.t(s) !== s)).toEqual([]); + en.window.close(); + const repeated = [...englishGroup.values()].filter((s) => { + const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1; + }); + expect(repeated).toEqual([]); + }); + + it("the Git panel's names, which the group's 'Git status' key reaches too, read naturally", () => { + const doc = dom.window.document; + expect(doc.getElementById('gitStatusPanel')!.getAttribute('aria-label')).toBe('Git 状态'); + const labels = [...doc.querySelectorAll('#gitStatusPanel .git-status-actions button')].map((b) => + b.getAttribute('aria-label') + ); + expect(labels).toEqual(['刷新 Git 状态', '关闭 Git 状态']); + }); +}); diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index ffc28293..51ee0695 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -8,12 +8,15 @@ * writes text: the count menu (cap and window wording), 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. + * confirm, the Tiles and Split button titles, the loading label (a body + * attribute, shown as CSS generated content) and the rename field's name. + * 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). + * - No words in the tile CSS: generated content (`content: '...'`) is out of + * the translator's reach, so a tile rule may carry glyphs, never text. * - User text stays as typed: session names (tile header) and group names * carry data-i18n-skip, and a session name inside the * confirm passes through the pattern untranslated. @@ -24,6 +27,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; +import postcss from 'postcss'; import { afterAll, describe, expect, it, vi } from 'vitest'; import { FakeEl, @@ -33,6 +37,7 @@ import { makeGridApp, resetGridHarness, section, + tileEl, windowStub, type GridApp, } from './mocks/tile-grid-vm.js'; @@ -102,6 +107,8 @@ function harvest(root: FakeEl | null | undefined, where: string) { add(el.title, 'title'); add(el.attrs['aria-label'], 'aria-label'); add(el.attrs.title, 'title attribute'); + // The loading label: CSS shows it (content: attr(data-loading-label)). + add(el.dataset.loadingLabel, 'loading label'); for (const child of el.children) walk(child, skip); }; if (root) walk(root, false); @@ -215,6 +222,11 @@ async function exercise() { harvestAll(app, 'zoomed'); app.zoomTile('s-1'); + // Renaming a tile: the input in the name's place, then Escape. + app.startTileRename('s-1'); + harvestAll(app, 'renaming'); + tileEl('s-1').querySelector('.tile-rename-input')!.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() }); + // A file that is not an image dropped on a tile. section.dispatch('drop', { target: section.children.find((el) => el.dataset.sessionId === 's-1'), @@ -327,6 +339,8 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( 'Tiles: show several sessions side by side (right-click for how many)', 'Tiles: back to a single session (right-click for how many tiles)', '6 tiles was stopped after crashing repeatedly. Restart it?', + 'Loading…', + 'Session name', ]; const missing = expected.filter((s) => !seen.has(s)); expect(missing).toEqual([]); @@ -471,3 +485,55 @@ describe('user text in the tile code', () => { expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?'); }); }); + +describe('the loading label: CSS generated content, written in the UI language', () => { + it('goes through the translator when the tile is built, and again each time the tile starts loading', () => { + setUp(); + const Queue = windowStub.TileLoadQueue; + const queue: { onChange?: (tile: unknown, state: string) => void } = {}; + windowStub.TileLoadQueue = class { + constructor(opts: { onChange: (tile: unknown, state: string) => void }) { + queue.onChange = opts.onChange; + } + schedule() {} + drop() {} + }; + try { + windowStub.codemanT = zh.api.t; + const app = makeGridApp(EIGHT); + app.openTileGrid(EIGHT.slice(0, 2)); + const entry = app._tileGrid.tiles.get('s-1'); + expect(entry.body.dataset.loadingLabel).toBe('加载中…'); + // A language switched since is picked up the next time the tile loads. + windowStub.codemanT = en.api.t; + app._tileLoadQueue(); + queue.onChange!(entry.tile, 'queued'); + expect(entry.el.classList.contains('tile--loading')).toBe(true); + expect(entry.body.dataset.loadingLabel).toBe('Loading…'); + queue.onChange!(entry.tile, 'idle'); + expect(entry.el.classList.contains('tile--loading')).toBe(false); + } finally { + windowStub.TileLoadQueue = Queue; + delete windowStub.codemanT; + } + }); + + it('no tile rule carries words in generated content (the translator cannot reach it)', () => { + const bad: string[] = []; + const contents = new Map(); + postcss.parse(read('styles.css')).walkRules((rule) => { + if (!/\.tile\b/.test(rule.selector)) return; + rule.walkDecls('content', (decl) => { + contents.set(rule.selector, decl.value); + // CSS escapes are glyphs (\2026, \00B7), not words. + const literals = [...decl.value.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => + (m[1] ?? m[2]).replace(/\\[0-9a-fA-F]{1,6}\s?/g, '') + ); + if (literals.some((text) => /[A-Za-z]/.test(text))) bad.push(`${rule.selector} { content: ${decl.value} }`); + }); + }); + // Not vacuous: the loading label is one of the rules read. + expect(contents.get('.tile.tile--loading .tile-body::after')).toBe('attr(data-loading-label)'); + expect(bad).toEqual([]); + }); +}); From ecd577157b21f44fd72fc55c52669b818d353a7c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 09:35:56 +0200 Subject: [PATCH 5/7] fix(cli-registry): codex launch defaults as registry data, ultra footer, schema doc defaults - Codex footer model detection (c28): the modelDetect.screenLine effort alternation is now built from CODEX_REASONING_EFFORTS plus 'default', so 'ultra' (offered by the codexReasoningEffort App Setting and codex's own /model picker) is read and the launch enum and the footer reader cannot drift again. Still one capture group, 125 characters, no new quantifier. New session-display-model case loops every effort level, ultra included. - No CLI-id branching for launch defaults (c27): the two mode === 'codex' branches the synced codex model/effort defaults added to the create and quick-start routes are replaced by a registry capability, capabilities.launchDefaults (launch param -> settings key, values from a closed enum), declared on the codex entry only. The resolver moved from web/codex-launch-defaults.ts to web/launch-defaults.ts as applyLaunchDefaults(mode, configs, customEndpoint), filling the entry's legacyConfigField object through legacyConfigAliases, still re-validating with SettingsUpdateSchema and never overwriting a caller's value. The route exclusions are unchanged (create: not remote; quick-start: not remote, not Docker, not a custom model endpoint), and quick-start still derives the session model from a bag without ompConfig, as before. schema.ts refuses an undeclared param, an unknown settings key, an empty map, and launchDefaults on an entry with no legacyConfigField. - The no-id-branching guard now carries an exact occurrence count per allowlisted key, so a new copy of an already approved expression fails instead of riding the old approval, with a synthetic anti-vacuity case. - SettingsUpdateSchema JSDoc (c21/c29): 'classic' is the tabArrangement default and 'compact' the headerStatsStyle default, matching the resolvers and the pre-paint script; state/case/ledger are marked opt-in. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cli-registry.md | 3 + src/config/cli-registry/schema.ts | 37 ++- src/config/cli-registry/stock.ts | 11 +- src/config/cli-registry/types.ts | 22 ++ src/web/codex-launch-defaults.ts | 35 --- src/web/launch-defaults.ts | 49 ++++ src/web/routes/session-routes.ts | 81 ++++--- src/web/schemas.ts | 13 +- test/cli-registry-no-id-branching.test.ts | 225 ++++++++++++++---- test/cli-registry-schema.test.ts | 51 ++++ .../session-routes-codex-defaults.test.ts | 49 +++- test/session-display-model.test.ts | 15 ++ 12 files changed, 456 insertions(+), 135 deletions(-) delete mode 100644 src/web/codex-launch-defaults.ts create mode 100644 src/web/launch-defaults.ts diff --git a/docs/cli-registry.md b/docs/cli-registry.md index e72eefd8..4f2aaafb 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -51,6 +51,9 @@ interface CliEntry { // that ended waiting for workers it will resume from // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) + // .launchDefaults?: { [launchParam]: settingsKey } + // (synced App Settings that seed a LOCAL launch's params the caller left unset; + // codex's model and reasoning effort, via src/web/launch-defaults.ts) overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store } ``` diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 90760fb1..5159a203 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -15,7 +15,7 @@ import { z } from 'zod'; import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; -import type { McpConfigFormat, ModelConfigResolverName } from './types.js'; +import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js'; /** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ const cliId = z @@ -409,6 +409,17 @@ const capabilitiesSchema = z 'rejectWords has nothing to filter without a screenLine' ) .optional(), + // Launch param -> synced App Settings key. The values are a closed enum, like + // configResolver: a clis.json override names one of the settings this build knows + // how to validate, never an arbitrary key. Params are checked against the declared + // ones in the superRefine below. + launchDefaults: z + .record( + z.string(), + z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[]) + ) + .refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params') + .optional(), privilegedParams: z .array( z @@ -627,6 +638,30 @@ export const CliEntrySchema = z } }); + // Same silent-no-op class again: a launch default for a param the entry never declared + // would be filled into the config object and then read by nothing. And without a + // `legacyConfigField` the entry's params are read off the request body itself, where a + // filled `model` would be a different field (claude's per-session one), so refuse it. + const { launchDefaults } = entry.capabilities; + if (launchDefaults !== undefined) { + if (entry.launch.legacyConfigField === undefined) { + ctx.addIssue({ + code: 'custom', + message: 'launchDefaults needs launch.legacyConfigField to fill', + path: ['capabilities', 'launchDefaults'], + }); + } + for (const param of Object.keys(launchDefaults)) { + if (!declaredParams.has(param)) { + ctx.addIssue({ + code: 'custom', + message: `launchDefaults param "${param}" is not a declared launch param`, + path: ['capabilities', 'launchDefaults', param], + }); + } + } + } + const { setenvProfile } = entry.env; if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) { ctx.addIssue({ diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 8126a7eb..35b042e9 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -671,6 +671,11 @@ const CODEX: CliEntry = { // popup or a bare line of prose does not have that shape. A footer without an effort // word (a model with no reasoning setting) is not read, and the session keeps its // last known or launch model. + // The effort words are built from CODEX_REASONING_EFFORTS, the same list the + // `reasoningEffort` launch param above admits, plus `default` (what codex prints when + // no effort is configured). A hand-kept copy once left out `ultra`, so a session at + // that level never named its model. Every word is plain letters, so the join adds no + // quantifier and only a few characters to the 200-character compileVersionRegex cap. // ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row // under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and // drops it again while a prompt is being typed. With a one-row window the footer was @@ -681,9 +686,13 @@ const CODEX: CliEntry = { // and the `›` composer, and a forged footer-shaped transcript line is not followed by // an indented row, so it is not read. modelDetect: { - screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · [^\n]*(?:\n {2}[^\n]*)?$`, + screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`, screenLines: 2, }, + // App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's + // codexConfig wherever the caller left the field unset. Launch-only: nothing writes + // codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts. + launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }, // Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠` // markers sit in the gutter, prose continuations sit at 2, and a nested YAML block // the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120, diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 114b4c40..09cc3136 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -96,6 +96,14 @@ export type NewlineSequence = 'line-feed' | 'esc-enter'; /** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */ export type ModelConfigResolverName = 'deepseek-route'; +/** + * The synced App Settings keys `capabilities.launchDefaults` may name (src/web/launch-defaults.ts). + * A closed list rather than any settings key, so a clis.json override cannot feed an + * arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema` + * key, which the resolver's typing enforces. + */ +export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort'; + /** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */ export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; @@ -529,6 +537,20 @@ export interface CliCapabilities { rejectWords?: string[]; configResolver?: ModelConfigResolverName; }; + /** + * Synced App Settings that seed this CLI's launch params when the caller left them unset, + * keyed by LAUNCH PARAM name (`{ model: 'codexModel' }`), never the legacy wire name; the + * resolver translates through `launch.legacyConfigAliases` like every other `param`. + * + * Filled into the entry's `launch.legacyConfigField` object at create time by + * `applyLaunchDefaults()` (src/web/launch-defaults.ts), which re-validates each value + * with `SettingsUpdateSchema` and never overwrites a value the caller sent. Which + * launches get it is the CALLER's decision (local ones only: never remote, Docker or a + * custom model endpoint). `schema.ts` refuses an undeclared param, and an entry without + * a `legacyConfigField`, whose params would otherwise be read off the request body itself. + * Absent = no launch defaults. + */ + launchDefaults?: Record; /** * Params a non-granted multi-user owner may not set freely, and what they are forced to. * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. diff --git a/src/web/codex-launch-defaults.ts b/src/web/codex-launch-defaults.ts deleted file mode 100644 index 43a059e9..00000000 --- a/src/web/codex-launch-defaults.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @fileoverview Launch-time defaults for Codex sessions. - * - * Resolves the synced App Settings `codexModel` / `codexReasoningEffort` into the - * `codexConfig` a launch uses, filling ONLY the fields the caller left unset. - * Persisted values are re-validated with `SettingsUpdateSchema`, so a hand-edited - * settings.json can never smuggle an unchecked value onto the codex command line. - * - * Scope is the caller's decision: the create and quick-start routes apply it to - * local launches only, never to remote, Docker or custom-endpoint launches. - * Nothing here writes Codex's own config files. - */ - -import type { CodexConfig } from '../types.js'; -import { SettingsUpdateSchema } from './schemas.js'; -import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js'; - -/** Resolve launch-only defaults without changing Codex's own configuration files. */ -export async function resolveCodexLaunchDefaults( - config: CodexConfig | undefined, - customEndpoint = false -): Promise { - if (customEndpoint) return config; - const settings = await readJsonConfig>(SETTINGS_PATH, 'Codex launch defaults', {}); - const model = SettingsUpdateSchema.shape.codexModel.safeParse(settings.codexModel); - const effort = SettingsUpdateSchema.shape.codexReasoningEffort.safeParse(settings.codexReasoningEffort); - const defaultModel = model.success ? model.data || undefined : undefined; - const defaultEffort = effort.success ? effort.data || undefined : undefined; - if (!defaultModel && !defaultEffort) return config; - return { - ...config, - model: config?.model ?? defaultModel, - reasoningEffort: config?.reasoningEffort ?? defaultEffort, - }; -} diff --git a/src/web/launch-defaults.ts b/src/web/launch-defaults.ts new file mode 100644 index 00000000..3fc5a183 --- /dev/null +++ b/src/web/launch-defaults.ts @@ -0,0 +1,49 @@ +/** + * @fileoverview Launch-time defaults from synced App Settings, driven by registry data. + * + * A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today + * only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and + * `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField` + * object, setting ONLY the fields the caller left unset. Persisted values are re-validated + * with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an + * unchecked value onto the command line. + * + * Scope is the caller's decision: the create and quick-start routes apply it to local + * launches only, never to remote, Docker or custom-endpoint launches. Nothing here writes + * a CLI's own config files. + */ + +import { getCli } from '../config/cli-registry/registry.js'; +import { SettingsUpdateSchema } from './schemas.js'; +import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js'; + +/** + * Return `configs` with the launch defaults of `mode`'s registry entry filled into its + * legacy config object (e.g. `codexConfig`). Every other field of `configs` is passed + * through untouched, and `configs` itself comes back unchanged (same object) when the entry + * declares no defaults, `customEndpoint` is set, or no setting names a value. + */ +export async function applyLaunchDefaults( + mode: string, + configs: T, + customEndpoint = false +): Promise { + const entry = getCli(mode); + const declared = entry?.capabilities.launchDefaults; + const field = entry?.launch.legacyConfigField; + if (customEndpoint || !declared || field === undefined) return configs; + + const settings = await readJsonConfig>(SETTINGS_PATH, 'CLI launch defaults', {}); + const aliases = entry.launch.legacyConfigAliases ?? {}; + const current = (configs as Record)[field] as Record | undefined; + const defaults: Record = {}; + for (const [param, settingKey] of Object.entries(declared)) { + const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]); + // '' is the settings' "leave it to the CLI" value, the same as unset. + const value = parsed.success ? parsed.data || undefined : undefined; + const wireKey = aliases[param] ?? param; + if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value; + } + if (Object.keys(defaults).length === 0) return configs; + return { ...configs, [field]: { ...current, ...defaults } }; +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 51a30dc9..8affbeb0 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -116,7 +116,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; import type { NewlineSequence } from '../../config/cli-registry/types.js'; import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; -import { resolveCodexLaunchDefaults } from '../codex-launch-defaults.js'; +import { applyLaunchDefaults } from '../launch-defaults.js'; import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; @@ -1150,8 +1150,10 @@ export function registerSessionRoutes( const globalNice = await ctx.getGlobalNiceConfig(); const modelConfig = await ctx.getModelConfig(); const mode = body.mode || 'claude'; - const launchCodexConfig = - mode === 'codex' && !remote ? await resolveCodexLaunchDefaults(body.codexConfig) : body.codexConfig; + // Synced App Settings launch defaults (capabilities.launchDefaults, codex's model and + // effort today) fill the CLI's own config object where the caller left it unset. + // Local launches only: a remote attach runs whatever the remote pane already runs. + const launchBody = remote ? body : await applyLaunchDefaults(mode, body); // Where a model override comes from is a capability, and the three answers are // genuinely different mechanisms: // 'flag' — the CLI takes --model, so read the value the caller sent @@ -1166,10 +1168,9 @@ export function registerSessionRoutes( const modelSource = getCli(mode)?.capabilities.model; const model = modelSource?.source === 'flag' - ? (legacyConfigForMode(mode, { ...body, codexConfig: launchCodexConfig } as unknown as Record< - string, - unknown - >)?.[modelSource.param ?? 'model'] as string | undefined) + ? (legacyConfigForMode(mode, launchBody as unknown as Record)?.[ + modelSource.param ?? 'model' + ] as string | undefined) : modelSource?.source === 'claude-settings-file' ? body.model || modelConfig?.defaultModel || undefined : undefined; @@ -1186,12 +1187,12 @@ export function registerSessionRoutes( deepSeekConfig: gatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner( owner, - launchCodexConfig, - body.geminiConfig, - body.antigravityConfig, - body.piConfig, - body.grokConfig, - body.deepSeekConfig + launchBody.codexConfig, + launchBody.geminiConfig, + launchBody.antigravityConfig, + launchBody.piConfig, + launchBody.grokConfig, + launchBody.deepSeekConfig ); const terminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const session = new Session({ @@ -1204,14 +1205,14 @@ export function registerSessionRoutes( model, claudeMode: effectiveClaudeMode, allowedTools: claudeModeConfig.allowedTools, - openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, + openCodeConfig: mode === 'opencode' ? launchBody.openCodeConfig : undefined, codexConfig: mode === 'codex' ? gatedCodexConfig : undefined, geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined, antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined, piConfig: mode === 'pi' ? gatedPiConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, - ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig), + ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig), resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, @@ -3875,23 +3876,31 @@ export function registerSessionRoutes( // Apply global Nice priority config and model config from settings const niceConfig = await ctx.getGlobalNiceConfig(); const qsModelConfig = await ctx.getModelConfig(); - const qsLaunchCodexConfig = - mode === 'codex' && !remote && !docker - ? await resolveCodexLaunchDefaults(codexConfig, !!customModel) - : codexConfig; + // Synced App Settings launch defaults, as on the create path: local launches only, so + // never a remote or Docker case, and never a custom model endpoint launch. + const qsRequestConfigs = { + openCodeConfig, + codexConfig, + geminiConfig, + antigravityConfig, + piConfig, + grokConfig, + deepSeekConfig, + ompConfig, + }; + const qsLaunchConfigs = + remote || docker ? qsRequestConfigs : await applyLaunchDefaults(mode, qsRequestConfigs, !!customModel); + // ⚠️ The model is read from a bag WITHOUT ompConfig, as it always was here: quick-start + // has never taken omp's session model from ompConfig (the create path does). Kept as + // found rather than changed in passing. + const { ompConfig: qsLaunchOmpConfig, ...qsModelConfigs } = qsLaunchConfigs; // See the create path for why this is a capability rather than a mode ladder. const qsModelSource = getCli(mode)?.capabilities.model; const qsModel = qsModelSource?.source === 'flag' - ? (legacyConfigForMode(mode, { - openCodeConfig, - codexConfig: qsLaunchCodexConfig, - geminiConfig, - antigravityConfig, - piConfig, - grokConfig, - deepSeekConfig, - } as unknown as Record)?.[qsModelSource.param ?? 'model'] as string | undefined) + ? (legacyConfigForMode(mode, qsModelConfigs as unknown as Record)?.[ + qsModelSource.param ?? 'model' + ] as string | undefined) : qsModelSource?.source === 'claude-settings-file' ? qsModelConfig?.defaultModel || undefined : undefined; @@ -3907,16 +3916,16 @@ export function registerSessionRoutes( deepSeekConfig: qsGatedDeepSeekConfig, } = await _clampExternalCliBypassForOwner( owner, - qsLaunchCodexConfig, - geminiConfig, - antigravityConfig, - piConfig, - grokConfig, - deepSeekConfig + qsLaunchConfigs.codexConfig, + qsLaunchConfigs.geminiConfig, + qsLaunchConfigs.antigravityConfig, + qsLaunchConfigs.piConfig, + qsLaunchConfigs.grokConfig, + qsLaunchConfigs.deepSeekConfig ); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides); - const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig); + const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig); // Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md) // rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom- @@ -4073,7 +4082,7 @@ export function registerSessionRoutes( claudeMode: qsEffectiveClaudeMode, allowedTools: qsClaudeModeConfig.allowedTools, owner, - openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, + openCodeConfig: mode === 'opencode' ? qsLaunchConfigs.openCodeConfig : undefined, codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined, geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined, antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c1938cfb..68f0e174 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1406,14 +1406,14 @@ export const SettingsUpdateSchema = z /** * Tab layout, the arrangement of the tab list (Discussion #426). Display key * (per-device). + * 'classic' = one flat list in tab order, as before. The default. * 'state' = a row per state in the header strip (needs you, waiting, * working, idle; option C), sections in the flat side rail and - * the sidebar. The default. + * the sidebar. Opt-in. * 'case' = one cluster per case (option A): a labelled box in the strip, - * a section in the side rail and the sidebar. + * a section in the side rail and the sidebar. Opt-in. * 'ledger' = the flat list on an aligned column grid with a status bar - * per cell (option B). Header strip on desktop only. - * 'classic' = one flat list in tab order, as before. + * per cell (option B). Header strip on desktop only. Opt-in. */ tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(), /** @@ -1457,8 +1457,9 @@ export const SettingsUpdateSchema = z * How the header draws its WS / CPU / MEM / plan-usage cluster. Display key * (per-device), desktop only (the cluster is hidden below 768px). * 'classic' = the bars and the 5H · 7D chip, as before - * 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value - * 'tiles' = label over value with a bar underneath, no icons. The default. + * 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value. + * The default. + * 'tiles' = label over value with a bar underneath, no icons */ headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(), showTokenCount: z.boolean().optional(), diff --git a/test/cli-registry-no-id-branching.test.ts b/test/cli-registry-no-id-branching.test.ts index 0c8cd1f1..e35faeca 100644 --- a/test/cli-registry-no-id-branching.test.ts +++ b/test/cli-registry-no-id-branching.test.ts @@ -33,6 +33,13 @@ * CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in * `config/cli-registry/profiles.ts` as a named profile. * + * ⚠️ Each entry also carries how many times its expression occurs in that file, compared + * EXACTLY. The key is only `::`, so without a count an approved branch + * approved every later copy of the same text in the same file: the codex launch defaults + * added two more `mode === 'codex'` branches to session-routes.ts and passed silently, + * because the legacy-plumbing entry already covered that string. A new copy now fails as + * an unapproved branch would, and a removed one fails as stale until the count drops. + * * Port: none (pure static analysis). */ @@ -67,47 +74,67 @@ const EXEMPT_FILES = new Set( ].map((p) => p.split('/').join(sep)) ); +/** One approved branch: how many copies of it the file holds, and why it is not a capability. */ +interface Allowance { + count: number; + reason: string; +} + +const allow = (count: number, reason: string): Allowance => ({ count, reason }); + /** - * Specific surviving branches, each with the reason it is not a capability. + * Specific surviving branches, each with the reason it is not a capability and the exact + * number of times the expression occurs in that file (see the header). * Keyed `::`. */ -const ALLOWED_BRANCHES: Record = { +const ALLOWED_BRANCHES: Record = { // --- Legacy Config plumbing (public wire shape, see the header) --- - "web/routes/session-routes.ts::mode === 'opencode'": 'legacy Config plumbing', - "web/routes/session-routes.ts::mode === 'codex'": 'legacy Config plumbing', - "web/routes/session-routes.ts::mode === 'gemini'": 'legacy Config plumbing', - "web/routes/session-routes.ts::mode === 'antigravity'": 'legacy Config plumbing', - "web/routes/session-routes.ts::mode === 'pi'": 'legacy Config plumbing', - "web/routes/session-routes.ts::mode === 'grok'": 'legacy Config plumbing', - "web/routes/session-routes.ts::mode === 'deepseek'": 'legacy Config plumbing', - "web/server.ts::mode === 'opencode'": 'legacy Config plumbing (session recovery)', - "web/server.ts::mode === 'codex'": 'legacy Config plumbing (session recovery)', - "web/server.ts::mode === 'gemini'": 'legacy Config plumbing (session recovery)', - "web/server.ts::mode === 'antigravity'": 'legacy Config plumbing (session recovery)', - "web/server.ts::mode === 'pi'": 'legacy Config plumbing (session recovery)', - "web/server.ts::mode === 'grok'": 'legacy Config plumbing (session recovery)', - "web/server.ts::mode === 'deepseek'": 'legacy Config plumbing (session recovery)', - "web/server.ts::mode === 'omp'": 'legacy Config plumbing (session recovery)', + "web/routes/session-routes.ts::mode === 'opencode'": allow(2, 'legacy Config plumbing'), + "web/routes/session-routes.ts::mode === 'codex'": allow(2, 'legacy Config plumbing'), + "web/routes/session-routes.ts::mode === 'gemini'": allow(2, 'legacy Config plumbing'), + "web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy Config plumbing'), + "web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy Config plumbing'), + "web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy Config plumbing'), + "web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy Config plumbing'), + "web/server.ts::mode === 'opencode'": allow(1, 'legacy Config plumbing (session recovery)'), + "web/server.ts::mode === 'codex'": allow(1, 'legacy Config plumbing (session recovery)'), + "web/server.ts::mode === 'gemini'": allow(1, 'legacy Config plumbing (session recovery)'), + "web/server.ts::mode === 'antigravity'": allow(1, 'legacy Config plumbing (session recovery)'), + "web/server.ts::mode === 'pi'": allow(1, 'legacy Config plumbing (session recovery)'), + "web/server.ts::mode === 'grok'": allow(1, 'legacy Config plumbing (session recovery)'), + "web/server.ts::mode === 'deepseek'": allow(1, 'legacy Config plumbing (session recovery)'), + "web/server.ts::mode === 'omp'": allow(1, 'legacy Config plumbing (session recovery)'), // --- Claude's remote/docker command construction --- - "tmux-manager.ts::mode === 'claude'": + "tmux-manager.ts::mode === 'claude'": allow( + 2, "claude's remote pane command carries per-session permission flags, and its docker form is " + - '`--session-id … || resume`; neither fits a static overlays.command string', - "tmux-manager.ts::mode === 'omp'": + '`--session-id … || resume`; neither fits a static overlays.command string' + ), + "tmux-manager.ts::mode === 'omp'": allow( + 1, 'remote omp respawn needs the pinned/continue --resume override threaded through ' + - '(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' + - 'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' + - 'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode', + '(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' + + 'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' + + 'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode' + ), // --- Per-CLI prose and launch handling not yet generalised --- - "web/session-wait-registry.ts::mode === 'deepseek'": - 'an error message explaining why THIS mode in particular will never deliver a stop signal', - "web/routes/approval-routes.ts::mode === 'deepseek'": - 'the DeepSeek status bridge is the only non-claude source of approval items', - "cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised', - "cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised', - "web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id', - "cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour', + "web/session-wait-registry.ts::mode === 'deepseek'": allow( + 1, + 'an error message explaining why THIS mode in particular will never deliver a stop signal' + ), + "web/routes/approval-routes.ts::mode === 'deepseek'": allow( + 1, + 'the DeepSeek status bridge is the only non-claude source of approval items' + ), + "cron/cron-service.ts::mode === 'claude'": allow(1, 'cron launch handling, not yet generalised'), + "cron/cron-service.ts::mode === 'shell'": allow(1, 'cron launch handling, not yet generalised'), + "web/routes/session-routes.ts::mode === 'claude'": allow( + 2, + 'docker case bookkeeping keyed on the claude conversation id' + ), + "cli.ts::mode === 'shell'": allow(1, 'a CLI-table label, not behaviour'), // --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) --- // @@ -121,43 +148,58 @@ const ALLOWED_BRANCHES: Record = { // there, the shared predicate silently widened both to a mode with no transcript to read. // CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a // capability here would be actively wrong. - "web/routes/readmymind-routes.ts::mode !== 'claude'": - 'deliberately mode-not-capability; pinned by deepseek-mode.test.ts', - "web/server.ts::mode !== 'claude'": + "web/routes/readmymind-routes.ts::mode !== 'claude'": allow( + 1, + 'deliberately mode-not-capability; pinned by deepseek-mode.test.ts' + ), + "web/server.ts::mode !== 'claude'": allow( + 2, "intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " + - 'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)', + 'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)' + ), // The TUI is a CLIENT of the server, and these two are about what it can offer for a row: // resume builds a `claude --resume`, and the mode badge is suppressed for the default mode // purely so the common case reads clean. The badge one is cosmetic and not a capability at // all; the resume one would need a "resumable from a claude transcript" field that nothing // else would read. - "tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction', - "tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode', + "tui/tui-app.ts::mode !== 'claude'": allow( + 1, + 'TUI resume builds a claude --resume; claude-transcript-only by construction' + ), + "tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'), // Push approve/deny BUTTONS are withheld for dsh because the answer route refuses // keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose // answer would be refused is worse than none. Arguably wants an "answerable dialogs" // capability; deliberately not invented here. - "web/routes/hook-event-routes.ts::mode !== 'deepseek'": - 'push buttons withheld where the answer route refuses keystrokes', + "web/routes/hook-event-routes.ts::mode !== 'deepseek'": allow( + 1, + 'push buttons withheld where the answer route refuses keystrokes' + ), // Legacy Config plumbing, same category as the `===` entries above. - "web/routes/session-routes.ts::mode !== 'omp'": 'legacy Config plumbing (resolveOmpConfigForCreate)', + "web/routes/session-routes.ts::mode !== 'omp'": allow( + 2, + 'legacy Config plumbing (resolveOmpConfigForCreate), and one link of the scaffolded-case hooks ' + + 'chain (see the opencode entry below)' + ), // ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its // own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a // Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this // change; expressing the chain as a capability would have to pick a side and would // therefore be a behaviour change. Left exactly as found, and named here so it is visible. - "web/routes/session-routes.ts::mode !== 'opencode'": + "web/routes/session-routes.ts::mode !== 'opencode'": allow( + 2, 'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' + - 'so any capability form would change behaviour — see PR discussion', - "web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)', - "web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)', - "web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)', - "web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)', - "web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)', + 'so any capability form would change behaviour — see PR discussion' + ), + "web/routes/session-routes.ts::mode !== 'codex'": allow(1, 'scaffolded-case hooks (see the opencode entry)'), + "web/routes/session-routes.ts::mode !== 'gemini'": allow(1, 'scaffolded-case hooks (see the opencode entry)'), + "web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'), + "web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'), + "web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'), }; /** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */ @@ -246,6 +288,43 @@ function scan(): { findings: Finding[]; filesScanned: number } { const { findings, filesScanned } = scan(); +interface CountMismatch { + key: string; + allowed: number; + found: number; + lines: string[]; +} + +/** + * Allowlisted keys whose occurrence count differs from the approved one: `grown` holds the + * keys with MORE copies than approved (a new branch riding an old approval), `shrunk` the + * ones with fewer (a stale approval that would let the next copy back in unseen). + * Unallowlisted keys are not this function's business; the offenders check covers them. + */ +function countMismatches( + found: Finding[], + allowed: Record +): { grown: CountMismatch[]; shrunk: CountMismatch[] } { + const byKey = new Map(); + for (const f of found) byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]); + const grown: CountMismatch[] = []; + const shrunk: CountMismatch[] = []; + for (const [key, allowance] of Object.entries(allowed)) { + const hits = byKey.get(key) ?? []; + const mismatch = { + key, + allowed: allowance.count, + found: hits.length, + lines: hits.map((f) => `${f.file}:${f.line}`), + }; + if (hits.length > allowance.count) grown.push(mismatch); + else if (hits.length < allowance.count) shrunk.push(mismatch); + } + return { grown, shrunk }; +} + +const { grown, shrunk } = countMismatches(findings, ALLOWED_BRANCHES); + describe('no CLI-id branching outside the stock catalog', () => { it('scans a meaningful number of source files (sanity)', () => { // If this collapses toward zero the walker or the exemption list drifted and every @@ -293,12 +372,56 @@ describe('no CLI-id branching outside the stock catalog', () => { ).toEqual([]); }); + it('has no new copy of an allowlisted branch', () => { + // An approval covers the copies that were reviewed, not every later line that happens + // to spell the same expression in the same file. + const detail = grown + .map((m) => ` ${m.key}: ${m.found} found, ${m.allowed} approved\n ${m.lines.join('\n ')}`) + .join('\n'); + expect( + grown, + grown.length === 0 + ? '' + : `Found more copies of an allowlisted CLI-id branch than were approved:\n${detail}\n\n` + + 'Express the new copy as a CliCapabilities field (or a named profile) rather than raising ' + + "the count. Raise it only for another branch of the SAME kind, and read this file's header first." + ).toEqual([]); + }); + it('has no stale allowlist entries', () => { // An allowlisted branch that no longer exists is a lie about the codebase, and the next - // person to reintroduce that exact branch would sail straight through. - const present = new Set(findings.map((f) => f.key)); - const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key)); - expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]); + // person to reintroduce that exact branch would sail straight through. The same holds + // for an approved count above what the file still has. + const stale = shrunk.map((m) => `${m.key}: ${m.found} found, ${m.allowed} approved`); + expect( + stale, + `ALLOWED_BRANCHES entries no longer (fully) present; delete them or lower the count:\n ${stale.join('\n ')}` + ).toEqual([]); + }); + + it('counts copies per key, so one extra copy of an approved branch fails (anti-vacuity)', () => { + const at = (line: number): Finding => ({ + file: 'web/example.ts', + expression: "mode === 'codex'", + line, + key: "web/example.ts::mode === 'codex'", + }); + const approved = { "web/example.ts::mode === 'codex'": allow(1, 'synthetic') }; + expect(countMismatches([at(10)], approved)).toEqual({ grown: [], shrunk: [] }); + const extra = countMismatches([at(10), at(42)], approved); + expect(extra.grown).toEqual([ + { + key: "web/example.ts::mode === 'codex'", + allowed: 1, + found: 2, + lines: ['web/example.ts:10', 'web/example.ts:42'], + }, + ]); + expect(countMismatches([], approved).shrunk.map((m) => m.key)).toEqual(["web/example.ts::mode === 'codex'"]); + // Every live entry carries a positive whole count, or the comparison means nothing. + for (const [key, { count }] of Object.entries(ALLOWED_BRANCHES)) { + expect(Number.isInteger(count) && count > 0, key).toBe(true); + } }); }); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 7feb42cb..6424c6c8 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -291,6 +291,57 @@ describe('cross-field integrity', () => { }); }); +describe('capabilities.launchDefaults', () => { + /** Codex with its shipped launch defaults replaced by `launchDefaults` (or with them unchanged). */ + function codexWith(mutate: (entry: Record) => void): boolean { + const entry = baseEntry('codex'); + mutate(entry); + return CliEntrySchema.safeParse(entry).success; + } + const setDefaults = (value: unknown) => (e: Record) => { + (e.capabilities as Record).launchDefaults = value; + }; + + it('ships on codex alone, keyed by launch param', () => { + const declaring = STOCK_CLIS.filter((e) => e.capabilities.launchDefaults !== undefined).map((e) => e.id); + expect(declaring).toEqual(['codex']); + expect(codexWith(() => {})).toBe(true); + }); + + it('rejects a param the entry never declared', () => { + // Filled into the config object and then read by nothing: a silent no-op, like a + // privilegedParams clamp naming the wrong param. + expect(codexWith(setDefaults({ effort: 'codexReasoningEffort' }))).toBe(false); + }); + + it('rejects a settings key outside the closed list', () => { + // An override must not be able to pour an arbitrary setting onto a command line. + expect(codexWith(setDefaults({ model: 'claudeModel' }))).toBe(false); + expect(codexWith(setDefaults({ model: 'codexmodel' }))).toBe(false); + }); + + it('rejects an empty map', () => { + expect(codexWith(setDefaults({}))).toBe(false); + }); + + it('refuses an entry with no legacyConfigField to fill', () => { + // Without one the params are read off the request body itself, where `model` is + // claude's per-session field, not this CLI's. + expect( + codexWith((e) => { + delete (e.launch as Record).legacyConfigField; + }) + ).toBe(false); + // The same entry without launch defaults is fine: the refusal is about the pair. + expect( + codexWith((e) => { + delete (e.launch as Record).legacyConfigField; + delete (e.capabilities as Record).launchDefaults; + }) + ).toBe(true); + }); +}); + describe('the env allowlist cannot be widened by config', () => { it('requires a prefix to end with an underscore', () => { expectRejected((e) => { diff --git a/test/routes/session-routes-codex-defaults.test.ts b/test/routes/session-routes-codex-defaults.test.ts index 6a2ceb9c..f37fe671 100644 --- a/test/routes/session-routes-codex-defaults.test.ts +++ b/test/routes/session-routes-codex-defaults.test.ts @@ -7,18 +7,26 @@ import { createRouteTestHarness, type RouteTestHarness } from './_route-test-uti import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js'; -import { resolveCodexLaunchDefaults } from '../../src/web/codex-launch-defaults.js'; +import { applyLaunchDefaults } from '../../src/web/launch-defaults.js'; import { buildCodexCommand } from '../../src/tmux-manager.js'; import { Session } from '../../src/session.js'; import { safeRmHomeTree } from '../mocks/index.js'; import { getDataDir } from '../../src/config/instance.js'; import { SettingsUpdateSchema } from '../../src/web/schemas.js'; +import { getCli } from '../../src/config/cli-registry/registry.js'; +import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js'; +import type { CodexConfig } from '../../src/types.js'; vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) }; }); +/** The codexConfig a launch gets from the synced defaults alone (the caller sent `config`). */ +async function resolveCodexDefaults(config: CodexConfig | undefined, customEndpoint = false) { + return (await applyLaunchDefaults('codex', { codexConfig: config }, customEndpoint)).codexConfig; +} + describe('Codex launch defaults', () => { let harness: RouteTestHarness; const workingDir = join(homedir(), 'codex-default-test'); @@ -76,14 +84,45 @@ describe('Codex launch defaults', () => { { codexModel: 'bad;command', codexReasoningEffort: 'invalid' }, ]) { await writeFile(SETTINGS_PATH, JSON.stringify(settings)); - expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined(); - expect(buildCodexCommand(await resolveCodexLaunchDefaults(undefined))).toBe('codex'); + expect(await resolveCodexDefaults(undefined)).toBeUndefined(); + expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe('codex'); } }); it('keeps defaults out of custom endpoint launches', async () => { const config = { model: 'local-model', animations: false }; - expect(await resolveCodexLaunchDefaults(config, true)).toBe(config); + expect(await resolveCodexDefaults(config, true)).toBe(config); + }); + + it('is driven by the registry: codex declares the defaults, and a CLI without them is untouched', async () => { + // The routes call applyLaunchDefaults for every mode and never ask which CLI it is, so + // what codex gets is exactly what its entry declares. + expect(getCli('codex')!.capabilities.launchDefaults).toEqual({ + model: 'codexModel', + reasoningEffort: 'codexReasoningEffort', + }); + for (const entry of STOCK_CLIS) { + if ((entry.id as string) === 'codex') continue; + expect(entry.capabilities.launchDefaults, entry.id).toBeUndefined(); + const configs = { codexConfig: undefined, geminiConfig: { model: 'g' }, piConfig: undefined }; + expect(await applyLaunchDefaults(entry.id, configs), entry.id).toBe(configs); + } + // Only the entry's own config object is filled; the rest of the bag passes through. + const bag = { codexConfig: { animations: false }, geminiConfig: { model: 'g' }, name: 'n' }; + const filled = await applyLaunchDefaults('codex', bag); + expect(filled).toEqual({ + codexConfig: { animations: false, model: 'gpt-6.1', reasoningEffort: 'high' }, + geminiConfig: { model: 'g' }, + name: 'n', + }); + expect(filled.geminiConfig).toBe(bag.geminiConfig); + expect(bag.codexConfig).toEqual({ animations: false }); + // A field the caller sent is never overwritten, one at a time. + expect(await resolveCodexDefaults({ model: 'mine' })).toEqual({ model: 'mine', reasoningEffort: 'high' }); + expect(await resolveCodexDefaults({ reasoningEffort: 'low' })).toEqual({ + model: 'gpt-6.1', + reasoningEffort: 'low', + }); }); it('does not record unused defaults for a Docker quick-start', async () => { @@ -150,7 +189,7 @@ describe('Codex launch defaults', () => { expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400); expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400); expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200); - expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined(); + expect(await resolveCodexDefaults(undefined)).toBeUndefined(); } finally { await system.app.close(); } diff --git a/test/session-display-model.test.ts b/test/session-display-model.test.ts index 10b293ae..1cef731c 100644 --- a/test/session-display-model.test.ts +++ b/test/session-display-model.test.ts @@ -25,6 +25,7 @@ import { sanitizeModelName, } from '../src/session-display-model.js'; import { IDLE_SILENCE_MS } from '../src/session-activity.js'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!; const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!; @@ -223,6 +224,20 @@ describe('readScreenModel', () => { expect(readScreenModel(codexPane(null, [], ' ← for agents · ? for shortcuts'), CODEX, CODEX_ROWS)).toBeUndefined(); }); + it("reads codex's model at every reasoning effort Codeman can launch it with, ultra included", () => { + // `ultra` is offered by the codexReasoningEffort App Setting and codex's own /model + // picker; a hand-kept effort list in the pattern once left it out, so an ultra + // session's header never named its model. The footer prints the lowercase level. + const ultra = codexPane(' GPT-6-Astra ultra · ~/codeman-cases/testcase', [], ' ? for shortcuts'); + expect(readScreenModel(ultra, CODEX, CODEX_ROWS)).toBe('GPT-6-Astra'); + for (const effort of [...CODEX_REASONING_EFFORTS, 'default']) { + const atRest = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`, [], ' ? for shortcuts'); + expect(readScreenModel(atRest, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra'); + const typing = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`); + expect(readScreenModel(typing, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra'); + } + }); + it('never takes a transcript line shaped like the footer', () => { // The agent printed a line exactly like each CLI's footer, and the real footer is // hidden (a dsh status bar switched off; a codex popup over its last row). The From 9d38cbf51aa6f6c04c5166abbacef09d5ac55c33 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 09:47:22 +0200 Subject: [PATCH 6/7] fix(build,docs): dependency preflight before the build wipes dist, docs drift for 1.40.0 - scripts/build.mjs resolves exceljs/dist/exceljs.min.js and fflate first, before tsc and before rm -rf dist/web/public. A tree whose node_modules predate those devDependencies (pulled but never ran npm install) used to fail in prepare-spreadsheet-assets.mjs with the live dist assets already deleted, so the running server served an index.html whose hashed files were gone. It now exits 1 with "run `npm install` first", nothing touched. test/spreadsheet-assets.test.ts pins the order, that the list covers every require.resolve in the prepare script, and runs a relocated copy of the build to prove the exit and message. - CLAUDE.md: the header visibility rule's stock desktop default now lists Tiles (1180px and wider), which ships ON on desktop. - docs/wiki/Agent-CLIs.md: "Before 1.36.0" becomes "Before 1.40.0" (four places); 1.36.0 never ships. - docs/wiki/Home.md: the "Everything in the manual" index lists Tile Grid and Custom Model Endpoints, matching the sidebar. test/wiki-home-index fails when a sidebar page is missing from that index. - docs/wiki/Tile-Grid.md: the Tiles default is off on tablets too since the touch-primary default landed, not only on phones. - docs/browser-testing-guide.md: the fixed port table and new WebServer(PORT) snippet give way to the port-0 pattern (new WebServer(0, false, true), server.boundPort) that test/test-ports-guard.test.ts enforces; the examples that opened localhost:3000, the live instance, use BASE_URL. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/browser-testing-guide.md | 37 +++++++++------- docs/wiki/Agent-CLIs.md | 8 ++-- docs/wiki/Home.md | 2 + docs/wiki/Tile-Grid.md | 3 +- scripts/build.mjs | 27 ++++++++++++ test/spreadsheet-assets.test.ts | 76 ++++++++++++++++++++++++++++++++- test/wiki-home-index.test.ts | 37 ++++++++++++++++ 8 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 test/wiki-home-index.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 00988773..9e6e7518 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -353,7 +353,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `
` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `