diff --git a/CLAUDE.md b/CLAUDE.md index 56e3dbdf..3181b13d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,7 +278,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, 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 on `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order up to `_tileGridLimit()`, the active one focused), never a picker; the picker is on right-click (`oncontextmenu`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ 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 OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, 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 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) @@ -387,7 +387,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog) -**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles picker (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers) +**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers) **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a477ed80..725100e2 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -809,7 +809,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select. -⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, opened EXACTLY (`openTileGrid(..., { mergeSplit: false })`: an open split closes and its sessions do not join), (b) else an open split's two sessions, (c) else the picker's list (tab order, no detached sessions) up to `_tileGridLimit().capacity`, the active session always included and focused. Ctrl/Cmd+click with the grid closed opens the same set plus the clicked session. The picker is on right-click (`oncontextmenu`, which calls `preventDefault` for the browser menu); with the grid open it is preselected with the current tiles and Open REPLACES them (close without reselect, null `activeSessionId`, reopen, as "Open group as tiles" does). +⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy. ⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. @@ -1005,7 +1005,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Z-index layers -**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles picker (1000, `.tile-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it). +**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it). ## Security layers diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 0727a237..b92e5081 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -36,14 +36,60 @@ or settled a question the spec left open. The invariants as built are in one tile, key names untranslated, mouse actions in the Help modal's key column (`Click`, `Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes compare with the last English value, not the translated DOM. -- **The Tiles button opens the grid at once** (owner decision 8): a click (and - `Ctrl+Shift+G`, the same `toggleTileGrid`) opens `tileGridOpenSet` (constants.js): the - grid this tab last had, else an open split's two sessions, else the open sessions in tab - order up to what the grid takes here (the cap, or fewer when the window fits fewer), the - active one always included and focused. A remembered grid wins over an open split: the - split closes and its sessions do not join (the owner's order, read literally). Right-click - opens the picker; with the grid open it shows the current tiles and Open replaces them. - Ctrl/Cmd+click on a tab with the grid closed opens the same set plus that session. +- **The Tiles button opens the grid at once** (owner decision 8, with the count of + decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered + count of tiles (default 6, at most what the window fits). `tileGridOpenSet` + (constants.js) picks the grid this tab last had, else an open split's two sessions, else + the open sessions in tab order, the active one always included and focused, and + `tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it + (from tab order) to the count. A remembered grid comes back with its tiles first, in + their cells, then sessions in tab order, to the count in total: the count is a shape + change under the cell model's rule (`reformTileCells`: the tiles keep their row and + column when all fit, else they pack in reading order) and the added tiles fill the empty + cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A + remembered grid still wins over an open split: the split closes and its sessions are not + seeded first. A page-load restore brings back exactly the stored grid, whatever the + count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that + session among them and focused (owner answer: N, not N+1). +- **Right-click on Tiles is a 2 / 4 / 6 count menu** (owner decision 10; the session + picker is gone). Three counts with their shapes (the grid's own 2x1, 2x2, 3x2 drawn as + cells), the remembered one checked. A count the window cannot fit is greyed out with the + reason ("This window fits N tiles"); a remembered count that does not fit stays checked + but greyed, the keyboard starts on the largest that fits, and a click opens what fits. + Shift+F10 and the Menu key open it too (the browser's contextmenu event). Arrows move + over the counts that fit, Enter or Space picks, Escape closes it alone (the global + Escape handler gives it the key first, like the tab-group menu) and puts the keyboard + back on the Tiles button, Tab and a click elsewhere close it. A pick is remembered per + device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many + tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always + stays, the others leave from the end or join from tab order, filling empty cells first, + every joining tile mounted and laid out before any connects (one fit, one PTY resize + each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab, + "Open group as tiles", Run) still add up to the cap of 6. +- **The grid opens and closes with a short animation, on by default** (owner request: + "when clicking on the tile button first make this animation nicer"). It is the grid's + own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile + fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in + reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added + later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the + load queue reports its first capture done, then fades in whole (160 ms), so no replay + scrolls by. Closing with the toggle (button, Ctrl+Shift+G; owner answer: only these), a + still copy of the tiles (`_ghostTileGrid`: clones, no xterm, socket or listener; inert, + `aria-hidden`, no pointer) dims at once over the stage (so the click is answered) and + stays until the single view's `selectSession` has replayed its session, at most 700 ms, + then fades: no empty single view between the two. A re-form fades the old grid's copy at + once. The count menu fades in (140 ms). Every one animates opacity and transform only, so + FitAddon measures the final cell and each tile still sends one PTY resize; under + `prefers-reduced-motion` nothing moves and no copy is made. A web tab hides the copy. +- **Opening paints the frames first** (owner answer: "paced connect: in"). The six + terminals used to be built inside the click (about 100 of its 140 ms before the first + frame). `_connectTilesPaced` builds one per animation frame, the focused tile's first, + so the click paints its empty tiles in about 20 ms and the entrance plays while they are + built. The time until all tiles have painted is unchanged: the load queue serves one + capture at a time, so only the focused tile's connect is on its path, one frame later. + `openTileGrid` therefore returns before the terminals exist: a selection that focuses a + tile whose terminal is not built yet hands the keyboard over in `_connectTile` + (`focusOnConnect`), never when `focus: false` was asked. - **The grid holds at most 6 tiles** (owner decision 7). `TILE_GRID_MAX` in constants.js is the one cap every limit reads; the layout table keeps 7 to 9 (`TILE_LAYOUT_MAX`), unreachable, so going back to nine is that one line. A stored grid with more ids comes back as its first @@ -59,9 +105,9 @@ or settled a question the spec left open. The invariants as built are in B's close is a tile button (26px). - **No + in the tile header** (owner decision 9): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New session in this case" are gone; tiles are added from the Tiles - button (and its right-click picker), Ctrl/Cmd+click on a tab, a dragged tab, "Open group - as tiles" and Run joining the open grid. Where this spec describes a `+`, it no longer - exists. + button (and its right-click count menu), Ctrl/Cmd+click on a tab, a dragged tab, "Open + group as tiles" and Run joining the open grid. Where this spec describes a `+`, it no + longer exists. - **No SSE terminal stream while tiles own the terminal** (performance pass): the filter names a fixed id no session takes (`TILE_GRID_SSE_FILTER`), not `[activeSessionId]` as "Parking the main terminal" below says; the server's filter gates only terminal @@ -97,9 +143,9 @@ or settled a question the spec left open. The invariants as built are in only: its `ids` are the cells, `null` for an empty one (a build before cells drops the nulls and reads them packed); a reload brings the holes back when the shape is the same, a session gone by then leaves its cell empty, another shape packs, and the old packed format - reads unchanged. A fresh grid (the picker's Open, Ctrl/Cmd+click with the grid closed, - "Open group as tiles") opens packed; only the toggle and the page-load restore bring holes - back. Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so + reads unchanged. A fresh grid (Ctrl/Cmd+click with the grid closed, "Open group as + tiles") opens packed; only the toggle and the page-load restore bring holes back (the + toggle through `reformTileCells` when the count changes the set). Moving is off while a tile is zoomed (the chords still apply there, as a no-op, so their keys never reach the CLI; a tiled tab dropped on the zoomed tile is refused too, as the owner confirmed) and with a single tile. Both arrow chord families, focus and move, skip a text field, where shifted arrows select (owner's answer: best practice). Default @@ -218,9 +264,9 @@ work also fixes gaps the split pane has today. ### Entry points - **Header Tiles button** (its own button, beside Split). As built (decision 8) - a click opens the grid at once, the same as the toggle shortcut; the picker - with checkboxes over open sessions, ordered like the tab strip, is on - right-click. When the grid is open, a click closes it. + a click opens the grid at once, the same as the toggle shortcut; right-click + is the 2 / 4 / 6 count menu (decision 10; the session picker it replaced is + gone). When the grid is open, a click closes it. - **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if closed). - **Drag a tab** from the strip onto a tile to replace it, or onto an empty @@ -246,8 +292,8 @@ Automatic by tile count, computed by a pure helper: | 7-9 | 3x3 | Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px, -roughly 60 columns at the default tile font), so the picker disables additions -the window cannot fit. +roughly 60 columns at the default tile font), so the count menu greys out the +counts the window cannot fit. Column and row dividers are draggable (generalizing the split divider): the grid stores track fractions (`grid-template-columns: fr fr …`), each @@ -678,7 +724,7 @@ and share one tile class: | A second desktop browser shows a tiled session full-size | Last resize wins and only the resizing socket hears `zc` (existing behavior, see follow-up 2) | | Split collapses or a tile is removed mid-divider-drag | Drag teardown first (carried over from the split's mid-drag fix) | | Remote (SSH) and Docker sessions | Work unchanged: their pane is a local tmux pane like any other | -| Multi-user mode | The picker lists only visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side | +| Multi-user mode | The grid only ever opens visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side | | Solo window | Tiles unavailable | ## Server @@ -729,8 +775,9 @@ Separate follow-up PRs worth doing (see "Follow-ups"). - **Per-device setting**: in `displayKeys`, stripped from the PUT, not in the `.strict()` schema; the `--hidden` marker class has a `display: none` rule. - **Palette chords** are swallowed in every xterm key handler. -- **Escape**: the picker's close method returns early when the picker is not - open (the global Escape handler calls every close method). +- **Escape**: the count menu's close method returns early when the menu is not + open, and an open menu owns the Escape (it closes alone and the keyboard goes + back to the Tiles button, like the tab-group menu). - **User text** (names) via `textContent` / attributes, never `innerHTML`. - **No secrets in localStorage**: the stored grid holds ids only. - **Memory**: everything a tile creates is released in `destroy()`. @@ -963,12 +1010,27 @@ exits green. Use the browser runner for those files and read the file count. split's two sessions, else the open sessions in tab order up to the cap with the active one focused; `Ctrl+Shift+G` runs the same function. The picker is on right-click of the button (its title says so, as do the wiki and the - Help modal). + Help modal). Superseded in part by decision 10: right-click is now the count + menu, and a remembered grid is filled to the count instead of opening + exactly as stored. 9. **No + in the tile header.** Decided by the owner ("remove the + button from these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New session in this case" went with it. Tiles are added from the Tiles button - and its right-click picker, Ctrl/Cmd+click on a tab, a dragged tab, "Open - group as tiles" and Run joining the open grid. + and its right-click count menu, Ctrl/Cmd+click on a tab, a dragged tab, + "Open group as tiles" and Run joining the open grid. +10. **Right-click Tiles is a 2 / 4 / 6 count menu.** Decided by the owner + ("give me then the option to choose only HOW many tiles, 2,4,6 default is 6 + so the menu is easier"; asked where it lives: "Click opens 6"): a click + still opens the grid at once, with the remembered count (default 6); the + right-click menu offers 2, 4 and 6, remembered per device; the session + picker is gone, and decision 8's "picker on right-click" is superseded. The + owner's answers on the details: the count wins over a remembered grid's + size (its tiles first, in their cells, holes filled first, then tab order); + Ctrl/Cmd+click with the grid closed opens the count in total, that session + focused; shrinking keeps the focused tile; only the toggle animates the + close; a remembered count larger than the window stays checked but greyed + and a click opens what fits; the close keeps its dimmed still until the + single view has painted (at most 700 ms); paced connect is in. ## Code anchors diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 9928e84e..3996685c 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -49,7 +49,7 @@ Anything you copy is cleaned on the way to the clipboard: each line loses the pa | Drag a tile's header | Move the tile: onto another tile they swap, onto an empty slot it moves there. | | `Alt+Shift+Enter` | Zoom the focused tile, or restore the grid. | | `Ctrl`+click / `Cmd`+click a tab | Add that session to the grid. | -| Right-click the Tiles button | Choose which sessions to show as tiles. | +| Right-click the Tiles button | Choose how many tiles: 2, 4 or 6 (remembered). | While the grid is open, `Ctrl+Tab` and `Alt+[` / `Alt+]` cycle through the tiles, and the terminal shortcuts above act on the focused tile. **Remove Focused Tile** has no key by diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index 31d4b9ba..640b4dc5 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -15,16 +15,21 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`. ## Opening a grid -- **Tiles button**: one click shows the tiles straight away. You get the grid you last - had; if there is none, an open split as two tiles; otherwise your open sessions in tab - order, up to six (fewer if the window is too small), with the session you are on - focused. With the grid open, the same button closes it. -- **Right-click the Tiles button** to choose which sessions: a checkbox per open session in - tab order, starting with the tiles you have (or had), greying out the rest once the grid - is full. **Open tiles** shows them, replacing what the grid showed. +- **Tiles button**: one click shows the tiles straight away, as many as you last chose + (six until you choose; fewer if the window is too small or you have fewer sessions open). + You get the grid you last had, its tiles where they were, topped up with your open + sessions in tab order; if there is none, an open split's two first; otherwise your open + sessions in tab order, with the session you are on focused. With the grid open, the same + button closes it. +- **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles: + **2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device + and is what the next click opens. With the grid open, picking a count re-forms it: the + tile you are in always stays, extra tiles leave from the end, new ones join from your tab + order. A count the window is too small for is greyed out, with the reason. - **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does. - **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With - the grid closed it opens what the Tiles button would show, plus that session. On macOS use + the grid closed it opens what the Tiles button would show, with that session among them + (still the count you chose in total). On macOS use `Cmd`: `Ctrl`+click there opens the tab's rename instead. - **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps running), or onto an empty slot to add it. Dragging a session that is already tiled onto @@ -35,7 +40,12 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`. The layout follows the tile count: 1x1, 2x1, three side by side on a wide screen (else a 2x2 with one empty slot), 2x2, 3x2. The grid holds at most six tiles, fewer when the -window is too small for six; the picker says which limit applies. +window is too small for six; the count menu says which limit applies. + +Opening, the tiles fade in one after another and each terminal appears once its history +has loaded, rather than scrolling through it. Closing with the button, the tiles stay +on screen, dimmed, until the single session behind them has loaded, then fade away. With +reduced motion turned on in your system settings, the grid opens and closes at once. ## A tile