diff --git a/CLAUDE.md b/CLAUDE.md index b58dc92c..5d898d5d 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 9 live sessions side by side, each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default OFF, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, name, menu, zoom, +, ×), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **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 dd29ca63..eac5367f 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -797,7 +797,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 OFF, 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 9 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, 3x3; `fits` against a 480x240 minimum tile), `tileGridCapacity`, `sanitizeTileGridState`, `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000). 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 OFF, 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 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/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 1d2e99c7..17496f1e 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -29,6 +29,10 @@ or settled a question the spec left open. The invariants as built are in B too), and a refresh clears the screen at its turn in the queue, so a waiting tile keeps its last frame. - **4009 lands on the Attach overlay**, and 4003/4004/4010 remove the tile. +- **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 + six. Where this spec says nine, read six. - **"+ / New session in this case"** runs the normal Run for that case and joins through the same auto-join as any Run from this tab. - **A tile that joins before its pane exists resends its size when the pid appears** @@ -869,6 +873,12 @@ exits green. Use the browser runner for those files and read the file count. chord is inert while `showTileGridButton` is off (it passes through like any unbound key) and toggles the grid while it is on, so one setting governs both the button and the chord. +7. **The tile cap.** Decided by the owner: at most 6 tiles for now. Six was + tested and is smooth on the owner's desktop; nine missed the headless frame + bar (p95 33 ms at 6 and 9 tiles under load, 16.8 ms at 4) and is untested on + real hardware. The cap is one constant (`TILE_GRID_MAX`), the layout table + keeps 7 to 9 working but unreachable, and the user-facing texts say "at most + 6 tiles" when the cap, not the window, is what limits the grid. ## Code anchors diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 94aa65b6..f51dc9a3 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -118,7 +118,7 @@ The right side of the header. Almost all of these are off until you enable them | Cron ⏰ | Off | Scheduled jobs. | | Multi-monitor | Off, macOS | Opens a window spanning every display. | | Split | Off, desktop only | View a second session beside the active one, with a draggable divider. | -| Tiles | Off, desktop only | Up to nine live sessions side by side. See [Tile Grid](Tile-Grid). | +| Tiles | Off, desktop only | Up to six live sessions side by side. See [Tile Grid](Tile-Grid). | | Tunnel indicator | When a tunnel runs | Cloudflare tunnel status. | | Admin panel | Multi-user only | User administration. | diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index 1959bdae..65fcf021 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -1,6 +1,6 @@ # Tile Grid -Watch and drive up to nine sessions at once, side by side in one window. Each tile is a +Watch and drive up to six sessions at once, side by side in one window. Each tile is a full live terminal: it reads, it takes your keystrokes, and it shows at a glance whether its agent is working, idle, or waiting on you. @@ -32,7 +32,8 @@ button in the header, beside Split, and enables `Ctrl+Shift+G`. joins it. Sessions started elsewhere (an agent, another device, a cron job) do not. 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, 3x3. +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 and a tile's + say which limit applies. ## A tile diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 0be0d606..edab60ec 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1636,11 +1636,19 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds // ── Tile grid (tile-grid.js) ─────────────────────────────────────────────── // // Pure layout and state helpers for the tile grid (docs/tile-grid-plan.md): -// 1 to 9 live sessions side by side, each in its own TerminalTile. Desktop -// only, behind the same 1180px gate as the split pane. +// 1 to TILE_GRID_MAX live sessions side by side, each in its own TerminalTile. +// Desktop only, behind the same 1180px gate as the split pane. -/** Hard cap on tiles in one grid. */ -const TILE_GRID_MAX = 9; +/** + * Hard cap on tiles in one grid: the ONE place it is set (owner decision 7 in + * docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed + * the headless frame bar and is untested on hardware. Everything that limits + * the grid reads this, and the layout table still covers up to TILE_LAYOUT_MAX, + * so raising the cap is this one line. + */ +const TILE_GRID_MAX = 6; +/** The largest count the layout table covers (3x3). Never a cap by itself. */ +const TILE_LAYOUT_MAX = 9; // The smallest tile worth showing: about 60 columns and a dozen rows at the // default tile font. Bounds how many tiles a window can hold. const TILE_MIN_W = 480; @@ -1648,7 +1656,7 @@ const TILE_MIN_H = 240; // Three tiles go side by side (3x1) only when each still gets ~600px; // otherwise they take three cells of a 2x2. const TILE_GRID_WIDE_3X1 = 1800; -// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: nine DOM +// A tile's xterm keeps this many lines, not DEFAULT_SCROLLBACK: a grid of DOM // renderers at 50k lines each is a real memory cost, and a tile's load is a // bounded 1 MiB window anyway, so more scrollback only fills with live output. const TILE_SCROLLBACK = 10000; @@ -1664,7 +1672,7 @@ const TILE_FONT_SIZE_DEFAULT = 13; * @returns {{cols: number, rows: number, fits: boolean}} */ function computeTileLayout({ count, width = Infinity, height = Infinity, minTileW = TILE_MIN_W, minTileH = TILE_MIN_H }) { - const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_GRID_MAX); + const n = Math.min(Math.max(0, Math.floor(Number(count) || 0)), TILE_LAYOUT_MAX); let cols; let rows; if (n === 0) return { cols: 0, rows: 0, fits: true }; @@ -2109,6 +2117,7 @@ if (typeof window !== 'undefined') { tileInDirection, cycleTile, TILE_GRID_MAX, + TILE_LAYOUT_MAX, TILE_MIN_W, TILE_MIN_H, TILE_SCROLLBACK, diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index b53949e9..4fddd583 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -338,6 +338,25 @@ Object.assign(CodemanApp.prototype, { }); }, + /** + * How many tiles the grid takes here and now: what the window fits, at most + * the cap (TILE_GRID_MAX), never less than one. The texts say which of the + * two binds, so a large monitor never reads "this window fits 6". + */ + _tileGridLimit() { + const max = window.CodemanTileGrid.TILE_GRID_MAX; + const fits = this._tileGridCapacityNow(); + const capacity = Math.max(1, Math.min(fits, max)); + const byCap = fits >= max; + return { + capacity, + hint: byCap ? `Up to ${max} tiles` : `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`, + full: byCap + ? `The grid holds at most ${max} tiles` + : `The grid already holds what this window fits (${capacity})`, + }; + }, + /** * The Tiles button: with the grid open it closes it (back to the single view * of the focused session); otherwise it opens a picker with a checkbox per @@ -359,7 +378,8 @@ Object.assign(CodemanApp.prototype, { } if (!this.canOpenTileGrid()) return; const T = window.CodemanTileGrid; - const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX)); + const limit = this._tileGridLimit(); + const capacity = limit.capacity; const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions); const remembered = (this._readStoredTileGrid()?.ids || []).filter((id) => candidates.some((c) => c.id === id)); const seed = remembered.length @@ -394,7 +414,7 @@ Object.assign(CodemanApp.prototype, { footer.className = 'tile-picker-footer'; const hint = document.createElement('span'); hint.className = 'tile-picker-hint'; - hint.textContent = `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`; + hint.textContent = limit.hint; const open = document.createElement('button'); open.type = 'button'; open.className = 'tile-picker-open'; @@ -413,7 +433,7 @@ Object.assign(CodemanApp.prototype, { const count = boxes.filter((b) => b.checked).length; for (const b of boxes) { b.disabled = !b.checked && count >= capacity; - b.title = b.disabled ? `This window fits ${capacity} tiles` : ''; + b.title = b.disabled ? limit.full : ''; } open.disabled = count === 0; }; @@ -477,8 +497,8 @@ Object.assign(CodemanApp.prototype, { } this.closeTileAddMenu(); const T = window.CodemanTileGrid; - const capacity = Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX); - const full = grid.ids.length >= Math.max(capacity, 1); + const limit = this._tileGridLimit(); + const full = grid.ids.length >= limit.capacity; const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions, grid.tiles); const menu = document.createElement('div'); menu.className = 'tab-rail-action-menu tile-add-menu'; @@ -497,7 +517,7 @@ Object.assign(CodemanApp.prototype, { item.setAttribute('data-i18n-skip', ''); item.textContent = c.label; item.disabled = full; - if (full) item.title = `The grid already holds what this window fits (${capacity})`; + if (full) item.title = limit.full; item.addEventListener('click', () => { this.closeTileAddMenu(); if (this.addTile(c.id)) this.selectSession(c.id); @@ -515,7 +535,7 @@ Object.assign(CodemanApp.prototype, { create.textContent = 'New session in this case'; create.disabled = full || !theCase; if (!theCase) create.title = 'This session is not in a case'; - else if (full) create.title = `The grid already holds what this window fits (${capacity})`; + else if (full) create.title = limit.full; create.addEventListener('click', () => { this.closeTileAddMenu(); if (theCase) void this.runInCaseForTiles(theCase.name); @@ -570,10 +590,9 @@ Object.assign(CodemanApp.prototype, { _joinTileGridFromRun(sessionId) { const grid = this._tileGrid; if (!grid?.open || grid.tiles.has(sessionId) || !this.sessions.has(sessionId)) return false; - const T = window.CodemanTileGrid; - const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX)); - if (grid.ids.length >= capacity) { - this.showToast?.(`The grid holds what this window fits (${capacity}): the new session opens on its own`, 'info'); + const limit = this._tileGridLimit(); + if (grid.ids.length >= limit.capacity) { + this.showToast?.(`${limit.full}: the new session opens on its own`, 'info'); return false; } // Run starts the session right after creating it: no Attach overlay @@ -899,13 +918,13 @@ Object.assign(CodemanApp.prototype, { if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) { return false; } - const T = window.CodemanTileGrid; - const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX)); + const limit = this._tileGridLimit(); + const capacity = limit.capacity; const grid = this._tileGrid; if (grid?.open) { if (!grid.tiles.has(sessionId)) { if (grid.ids.length >= capacity) { - this.showToast?.(`The grid already holds what this window fits (${capacity})`, 'info'); + this.showToast?.(limit.full, 'info'); return true; } this.addTile(sessionId); @@ -929,8 +948,7 @@ Object.assign(CodemanApp.prototype, { openGroupAsTiles(groupId) { const group = (this.tabLayout?.groups || []).find((g) => g.id === groupId); if (!group || !this.canOpenTileGrid()) return false; - const T = window.CodemanTileGrid; - const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX)); + const capacity = this._tileGridLimit().capacity; const ids = (group.refs || []) .filter((ref) => ref.kind === 'session') .map((ref) => ref.id) diff --git a/test/tile-grid-cap.test.ts b/test/tile-grid-cap.test.ts new file mode 100644 index 00000000..ced5c79e --- /dev/null +++ b/test/tile-grid-cap.test.ts @@ -0,0 +1,121 @@ +/** + * @fileoverview The grid holds at most TILE_GRID_MAX tiles: 6, owner decision 7 + * (docs/tile-grid-plan.md). Six was tested smooth on a real desktop; nine missed + * the headless frame bar. The layout table still covers 7 to 9 (unreachable). + * + * Every way into the grid stops at the cap even where the window would fit + * nine (the harness window is 2400x1200): opening, adding, a tile's +, a + * session Run makes, Ctrl/Cmd+click, the picker and "Open group as tiles". + * The texts say which limit binds: "at most 6" for the cap, "what this window + * fits" for a smaller window. + * + * 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, body, bySelector, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js'; + +const EIGHT = Array.from({ length: 8 }, (_, i) => `s-${i + 1}`); +const SIX = EIGHT.slice(0, 6); +const AT_MOST = 'The grid holds at most 6 tiles'; + +let wrapRect = { width: 2400, height: 1200 }; +beforeEach(() => { + resetGridHarness(); + wrapRect = { width: 2400, height: 1200 }; + const wrap = new FakeEl(); + wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height }); + bySelector.set('.terminal-wrap', wrap); +}); + +function fullGrid(): GridApp { + const app = makeGridApp(EIGHT); + app.openTileGrid(EIGHT); + return app; +} + +describe('the cap', () => { + it('is 6, worded as the cap where the window would fit more', () => { + const app = makeGridApp(EIGHT); + expect(app._tileGridLimit()).toEqual({ capacity: 6, hint: 'Up to 6 tiles', full: AT_MOST }); + }); + + it('a smaller window is worded by the window', () => { + const app = makeGridApp(EIGHT); + wrapRect = { width: 1200, height: 900 }; + expect(app._tileGridLimit()).toEqual({ + capacity: 4, + hint: 'This window fits 4 tiles', + full: 'The grid already holds what this window fits (4)', + }); + }); + + it('opening on eight sessions shows the first six', () => { + const app = fullGrid(); + expect(app._tileGrid.ids).toEqual(SIX); + expect(app._tileGrid.tiles.size).toBe(6); + }); + + it('a full grid takes no more tiles', () => { + const app = fullGrid(); + expect(app.addTile('s-7')).toBe(false); + expect(app._tileGrid.ids).toEqual(SIX); + }); +}); + +describe('every way in stops at the cap', () => { + it("a tile's + offers the rest, disabled, and says why", () => { + const app = fullGrid(); + app.openTileAddMenu({ stopPropagation: vi.fn(), preventDefault: vi.fn(), currentTarget: null }, 's-1'); + const menu = body.children.find((c) => c.className.includes('tile-add-menu'))!; + const items = menu.children.filter((c) => c.textContent === 's-7' || c.textContent === 's-8'); + expect(items).toHaveLength(2); + for (const item of items) { + expect(item.disabled).toBe(true); + expect(item.title).toBe(AT_MOST); + } + expect(menu.children.find((c) => c.className === 'tile-add-new')!.disabled).toBe(true); + }); + + it('a session Run makes opens on its own, with a toast', () => { + const app = fullGrid(); + expect(app._joinTileGridFromRun('s-7')).toBe(false); + expect(app._tileGrid.ids).toEqual(SIX); + expect(app.showToast).toHaveBeenCalledWith(`${AT_MOST}: the new session opens on its own`, 'info'); + }); + + it('Ctrl/Cmd+click on another tab says the grid is full', () => { + const app = fullGrid(); + expect(app.addSessionToTiles('s-7')).toBe(true); + expect(app._tileGrid.ids).toEqual(SIX); + expect(app.showToast).toHaveBeenCalledWith(AT_MOST, 'info'); + }); + + it('the picker greys out the seventh box', () => { + const app = makeGridApp(EIGHT); + app.openTilePicker({ stopPropagation: vi.fn() }); + const picker = body.children.find((c) => c.id === 'tilePickerMenu')!; + const boxes = picker.children[0].children.map((row) => row.children[0]); + expect(picker.children[1].children[0].textContent).toBe('Up to 6 tiles'); + for (const box of boxes) { + // A disabled box cannot be ticked (the browser ignores the click). + if (!EIGHT.includes(box.value) || box.checked || box.disabled) continue; + box.checked = true; + box.dispatch('change'); + } + const checked = boxes.filter((b) => b.checked).map((b) => b.value); + expect(checked).toHaveLength(6); + const left = boxes.filter((b) => !b.checked); + expect(left.length).toBeGreaterThan(0); + for (const box of left) { + expect(box.disabled).toBe(true); + expect(box.title).toBe(AT_MOST); + } + }); + + it('"Open group as tiles" shows the first six of a larger group', () => { + const app = makeGridApp(EIGHT); + app.tabLayout = { groups: [{ id: 'g', name: 'G', refs: EIGHT.map((id) => ({ kind: 'session', id })) }] }; + app.openGroupAsTiles('g'); + expect(app._tileGrid.ids).toEqual(SIX); + }); +}); diff --git a/test/tile-grid-layout.test.ts b/test/tile-grid-layout.test.ts index 4734ff35..4eeb2814 100644 --- a/test/tile-grid-layout.test.ts +++ b/test/tile-grid-layout.test.ts @@ -26,6 +26,7 @@ type TileGrid = { tileInDirection(ids: string[], focused: string, dir: string, cols: number): string | null; cycleTile(ids: string[], focused: string, delta: number): string | null; TILE_GRID_MAX: number; + TILE_LAYOUT_MAX: number; TILE_MIN_W: number; TILE_MIN_H: number; TILE_SCROLLBACK: number; @@ -61,7 +62,8 @@ describe('computeTileLayout', () => { expect(T.computeTileLayout({ count: 3, width: 1799, height: 900 })).toMatchObject({ cols: 2, rows: 2 }); }); - it('caps the count at 9 and treats nothing as an empty grid', () => { + it('lays out up to 9 (past the cap, unreachable but kept) and treats nothing as an empty grid', () => { + expect(T.TILE_LAYOUT_MAX).toBe(9); expect(T.computeTileLayout({ count: 12, ...BIG })).toMatchObject({ cols: 3, rows: 3 }); expect(T.computeTileLayout({ count: 0, ...BIG })).toMatchObject({ cols: 0, rows: 0 }); }); @@ -75,8 +77,10 @@ describe('computeTileLayout', () => { }); describe('tileGridCapacity', () => { - it('holds all nine on a large monitor', () => { - expect(T.tileGridCapacity(BIG)).toBe(T.TILE_GRID_MAX); + it('never holds more than the cap of 6 (owner decision 7), even where nine would fit', () => { + expect(T.TILE_GRID_MAX).toBe(6); + expect(T.computeTileLayout({ count: 9, ...BIG }).fits).toBe(true); + expect(T.tileGridCapacity(BIG)).toBe(6); }); it('stops at the first count whose layout does not fit', () => { @@ -137,10 +141,18 @@ describe('sanitizeTileGridState', () => { expect(out).toMatchObject({ open: false, ids: [], focused: null }); }); - it('caps the list at nine tiles', () => { + it('caps the list at the cap (6): the extras are dropped', () => { const many = Array.from({ length: 12 }, (_, i) => `s${i}`); const out = T.sanitizeTileGridState({ v: 1, open: true, ids: many }, many); - expect(out?.ids).toEqual(many.slice(0, 9)); + expect(out?.ids).toEqual(many.slice(0, 6)); + }); + + it('a stored 3x3 keeps its focus if it survives the cap, and loses a zoom that did not', () => { + const nine = Array.from({ length: 9 }, (_, i) => `s${i}`); + const kept = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's4', zoomed: 's7' }, nine); + expect(kept).toMatchObject({ ids: nine.slice(0, 6), focused: 's4', zoomed: null }); + const lost = T.sanitizeTileGridState({ v: 1, open: true, ids: nine, focused: 's8', zoomed: 's2' }, nine); + expect(lost).toMatchObject({ focused: 's0', zoomed: 's2' }); }); it('drops malformed track fractions', () => { diff --git a/test/tile-grid-restore.test.ts b/test/tile-grid-restore.test.ts index 6ef46b27..bcc9eb26 100644 --- a/test/tile-grid-restore.test.ts +++ b/test/tile-grid-restore.test.ts @@ -152,6 +152,20 @@ describe('page load with a stored open grid', () => { expect(app.activeSessionId).toBe('s-b'); }); + it('a stored 3x3 (before the cap of 6) comes back as its first six, focus kept, a dropped zoom cleared', () => { + const nine = Array.from({ length: 9 }, (_, i) => `n-${i + 1}`); + storeGrid({ ids: nine, focused: 'n-5', zoomed: 'n-8', colFr: [2, 1, 1], rowFr: [1, 1, 1] }); + const app = pageLoad(nine); + expect(app._tileGrid.ids).toEqual(nine.slice(0, 6)); + expect(FakeTile.all.filter((t) => !t._destroyed)).toHaveLength(6); + expect(app.activeSessionId).toBe('n-5'); + expect(app._tileGrid.zoomedId).toBeNull(); + // 3x2 now: the columns still match, the three stored rows do not. + expect(app._tileGrid.colFr).toEqual([2, 1, 1]); + expect(app._tileGrid.rowFr).toEqual([1, 1]); + expect(stored().ids).toEqual(nine.slice(0, 6)); + }); + it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => { localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' })); const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));