From 4341d3dca883f115a85eba49bdea7b78eb59b37b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 16:20:05 +0200 Subject: [PATCH] feat(tiles): the grid controller, and the main terminal parked while it is open tile-grid.js (load order 7.6) adds the grid to CodemanApp: openTileGrid, closeTileGrid, addTile, removeTile and _selectTiledSession, over a
that is a SIBLING of .terminal-wrap and takes its place under .main.tiles-active. Every tile is a TerminalTile with the grid's one load queue, TILE_SCROLLBACK, a bounded load and its own per-device font size (codeman-tile-font-size; Ctrl +/- sizes the tiles while the grid is open). Layout comes from computeTileLayout; one ResizeObserver on the section refits each tile (xterm and PTY together) on the trailing edge. Opening parks the main terminal: _cleanupPreviousSession runs once (its snapshot is right at that moment, and it closes the main socket), and activeSessionId always names the focused tile's session, so the panels follow focus. With the main socket closed, every main-terminal path that would write the focused tile's output into the hidden xterm, fetch a capture for it, resize it or reopen its socket now stands aside through _tilesOwnTerminal(): the SSE terminal, clear and refresh handlers, the dropped-output recovery, the completion/error writelns, retryConnection and handleInit (both re-arm the tiles instead; handleInit keeps live tiles and drops dead ones), sendResize, throttledResize, the history re-pull, and the WebGL long-task observer, which watches the whole page and must not count tile renders toward the main terminal's sticky WebGL disable. The header connection state comes from the tile sockets. Closing destroys every tile, invalidates the main terminal's cached content (snapshot, codeman-xs key, buffer cache) for every tiled id, since it predates the grid, and replays the focused session fresh in the single view. _focusedPane() answers with the focused tile and _forEachTile reaches every grid tile. A tile whose socket stops for good is removed (4003, 4004, 4010) or keeps its "session ended" marker (4009). No entry point yet: the grid is opened from the shortcut registry in a later commit. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 45 ++- src/web/public/index.html | 7 + src/web/public/styles.css | 72 ++++ src/web/public/terminal-tile.js | 3 + src/web/public/terminal-ui.js | 56 ++- src/web/public/tile-grid.js | 481 +++++++++++++++++++++++++ test/tile-grid-park-guards.test.ts | 559 +++++++++++++++++++++++++++++ 7 files changed, 1207 insertions(+), 16 deletions(-) create mode 100644 src/web/public/tile-grid.js create mode 100644 test/tile-grid-park-guards.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index d41eeed8..cda1a41f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1197,6 +1197,11 @@ class CodemanApp { try { this._webglLongTaskObserver = new PerformanceObserver((list) => { if (!this._webglAddon) return; + // ⚠️ The observer sees EVERY long task on the page. While the tile grid + // owns the terminal the main terminal is parked and draws nothing; the + // long tasks are tile renders and replays (DOM renderers), and counting + // them would write the sticky 7-day WebGL disable for no WebGL reason. + if (this._tilesOwnTerminal?.()) return; const now = performance.now(); if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return; if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) { @@ -2202,6 +2207,10 @@ class CodemanApp { } _onSessionTerminal(data) { + // Tile grid open: the main terminal is parked and its socket closed, so the + // SSE fallback would write the focused tile's output into a hidden xterm. + // The tiles carry their own output over their own sockets. + if (this._tilesOwnTerminal?.()) return; if (data.id === this.activeSessionId) { if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`); @@ -2251,6 +2260,8 @@ class CodemanApp { */ _scheduleDroppedOutputRecovery(sessionId, attempt = 0, queuedBytes) { if (!sessionId || this._clientDropRecoveryTimer) return; + // Nothing of the main terminal's to recover while the tile grid owns it. + if (this._tilesOwnTerminal?.()) return; // Behind the debounce guard: one line per window, not per dropped frame. if (Number.isFinite(queuedBytes)) _crashDiag.log(`TERMINAL DROP: ${(queuedBytes / 1024).toFixed(0)}KB queued`); this._clientDropRecoveryTimer = setTimeout(async () => { @@ -2928,6 +2939,10 @@ class CodemanApp { async _onSessionNeedsRefresh(event = {}) { // Server sends this after SSE backpressure clears — terminal data was dropped, // so reload the buffer to recover from any display corruption. + // Tile grid open: the main terminal is parked, so this would fetch a capture + // for a hidden xterm. Each tile refreshes itself through the grid's queue. + // `false`, never undefined: the drop recovery reads the result. + if (this._tilesOwnTerminal?.()) return false; const sessionId = this.activeSessionId; if (event?.id && event.id !== sessionId) return false; if (!sessionId || !this.terminal) return false; @@ -3030,6 +3045,8 @@ class CodemanApp { } async _onSessionClearTerminal(data) { + // The tiles get the clear over their own sockets; the parked main terminal must not refetch. + if (this._tilesOwnTerminal?.()) return; if (data.id === this.activeSessionId) { // Skip if selectSession is already loading the buffer — clearTerminal arriving // during buffer load would clear the terminal mid-write, causing visible flicker @@ -3075,14 +3092,15 @@ class CodemanApp { _onSessionCompletion(data) { this.totalCost += data.cost || 0; this.updateCost(); - if (data.id === this.activeSessionId) { + // Not into the parked main terminal while the tile grid owns the screen. + if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) { this.terminal.writeln(''); this.terminal.writeln(`\x1b[1;32m Done (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`); } } _onSessionError(data) { - if (data.id === this.activeSessionId) { + if (data.id === this.activeSessionId && !this._tilesOwnTerminal?.()) { this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`); } this._notifySession(data.id, 'critical', 'session-error', 'Session Error', data.error || 'Unknown error'); @@ -4034,9 +4052,12 @@ class CodemanApp { } // With an active terminal, show its transport (WebSocket vs HTTP fallback). + // While the tile grid owns the terminal the main socket is parked on + // purpose, so the state comes from the tiles' sockets: all open is + // connected, any still coming back is reconnecting. if (this.activeSessionId) { let cls, label, detail; - switch (this._wsState) { + switch (this._tilesOwnTerminal?.() ? this._tileGridSocketState() : this._wsState) { case 'connected': cls = 'connected'; label = 'WS'; detail = 'Terminal connected over WebSocket'; break; @@ -4217,8 +4238,11 @@ class CodemanApp { this._updateConnectionLossUi(); this.connectSSE(); // The terminal socket does not always come back on its own (planWsReconnect - // 'give-up'), so the same button re-arms it. - if (this.activeSessionId && this._wsState !== 'connected') { + // 'give-up'), so the same button re-arms it. With the tile grid open the + // main socket is parked on purpose: re-arm the tiles' sockets instead. + if (this._tilesOwnTerminal?.()) { + for (const { tile } of this._tileGrid.tiles.values()) tile.reconnectNow(); + } else if (this.activeSessionId && this._wsState !== 'connected') { this._wsReconnectAttempts = 0; this._connectWs(this.activeSessionId); } @@ -4561,6 +4585,11 @@ class CodemanApp { return; } + // Tile grid open (tile-grid.js): tiles whose sessions are gone are removed, + // the live ones are kept as they are and reconnect now, and the main + // terminal stays parked. Before the link below, which may leave the grid. + const tilesOpen = this._reconcileTileGrid?.() === true; + // A `#session=` link wins over restoring the last active tab. if (this._urlSessionId && this.sessions.has(this._urlSessionId)) { this.activeSessionId = null; @@ -4570,6 +4599,10 @@ class CodemanApp { // Not listed yet: its wait starts now that the list has loaded, and the // last active tab is restored meanwhile. if (this._urlSessionId) this._armUrlSessionWait(this._urlSessionId); + // The grid holds the focused session: nothing below may reconnect or reload + // the parked main terminal (its keepTerminal branch would reopen the main + // socket onto a session a tile already shows). + if (tilesOpen) return; const previousActiveId = this.activeSessionId; if (this.sessionOrder.length === 0) { @@ -7665,6 +7698,8 @@ class CodemanApp { const sessionId = this.activeSessionId; if (!sessionId || this._fullHistoryRepullInFlight || this._isLoadingBuffer) return; if (this.detachedSessions?.has(sessionId)) return; + // The parked main terminal has no history to pull while tiles own the screen. + if (this._tilesOwnTerminal?.()) return; const session = this.sessions.get(sessionId); // A shell's full capture can be many megabytes, and replaying all of it from // an ordinary scroll gesture blocks xterm's main thread. So a shell scroll diff --git a/src/web/public/index.html b/src/web/public/index.html index 9cc86457..2e83db29 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -435,6 +435,12 @@ autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"> + +
+ @@ -3920,6 +3926,7 @@ + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b2718c82..ab9f2f73 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19347,6 +19347,78 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle min-height: 0; } +/* ── Tile grid (tile-grid.js, docs/tile-grid-plan.md) ────────────────────── + 1 to 9 sessions side by side, each a TerminalTile. The section is a SIBLING of + .terminal-wrap; .main.tiles-active swaps one for the other (the main terminal + is parked while the grid is open). Columns and rows are set inline by + _applyTileLayout() from the tile count. */ +.tile-grid { + display: none; +} + +.main.tiles-active .terminal-wrap { + display: none; +} + +.main.tiles-active .tile-grid { + display: grid; + flex: 1; + min-width: 0; + min-height: 0; + gap: 4px; + padding: 4px; + background: var(--bg-dark); +} + +/* A web tab takes the stage from the grid exactly as from the single view: the + tiles stay connected, and a hidden tile measures nothing and sends no + resize. AFTER the rule above, which has the same specificity. */ +.main.webview-active .tile-grid { + display: none; +} + +.tile { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + border: 1px solid var(--border-color, #333); + border-radius: 4px; + background: var(--term-bg, #161b23); +} + +/* The focused tile has the keyboard and is the active session. */ +.tile.focused { + border-color: var(--accent-color, #4a9eff); +} + +.tile-body { + flex: 1 1 auto; + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.tile-body .xterm { + width: 100%; + height: 100%; +} + +/* Quiet "loading" state while the tile's capture waits in the grid's one load + queue (or runs). */ +.tile.tile--loading .tile-body::after { + content: 'Loading\2026'; + position: absolute; + top: 6px; + right: 10px; + font-size: 11px; + opacity: 0.6; + pointer-events: none; +} + /* Split-picker menu: a small popover listing sessions to split with, appended to document.body and positioned `fixed` by openSplitPicker() (JS sets top/right against the Split header button's own rect). z-index above the diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 6061a3f7..76b3b445 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -541,6 +541,8 @@ this._inputHandle = { ws: this.ws, lastRecvAt: 0 }; app._registerInputSocket(this.sessionId, this._inputHandle); app._onWsReady?.(this.sessionId); + // The header's connection dot reads tile sockets while the grid is open. + app._updateConnectionIndicator?.(); } // Leaves the map; only this pane's own handle is removed (a replacement @@ -549,6 +551,7 @@ if (!this._inputHandle) return; global.app?._unregisterInputSocket?.(this.sessionId, this._inputHandle); this._inputHandle = null; + global.app?._updateConnectionIndicator?.(); } // Settles a marker the pane owes: set when a close lands during a load (the diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 88cc6a54..d11e7f92 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1328,6 +1328,9 @@ Object.assign(CodemanApp.prototype, { // Same yield as sendResize: never resize a PTY whose session is showing // in its own window. Dragging the dashboard's border must not reshape it. const detachedElsewhere = !this.isSoloWindow && this.detachedSessions?.has(this.activeSessionId); + // The tile grid parks the main terminal: its session is sized by its + // tile, which this same timer refits below (_forEachTile). + const tilesOwnTerminal = this._tilesOwnTerminal?.(); // ⚠️ Whether to fit is the SAME question as whether to send (issue #464). // This block used to fit unconditionally and skip only the SIGWINCH, // which is the one combination that cannot be right: it moves xterm to @@ -1335,7 +1338,9 @@ Object.assign(CodemanApp.prototype, { // repaints from the shape it was told. Withhold both, or neither — // a reflow nothing is rendering for buys nothing and costs correctness. const dims = - this.activeSessionId && !keyboardUp && !detachedElsewhere ? this._geometryForResizeRequest() : null; + this.activeSessionId && !keyboardUp && !detachedElsewhere && !tilesOwnTerminal + ? this._geometryForResizeRequest() + : null; // ⚠️ A null measurement is NOT a reason to report the floor. It used to // fall back to a bare 40x10, which tells the PTY a shape nothing measured // and xterm does not hold — the write-only guess this whole change exists @@ -4512,7 +4517,12 @@ Object.assign(CodemanApp.prototype, { * it from its own terminal's focus; the primary terminal's focus gives it back. */ _focusedPane() { - const tile = this._focusedTile; + let tile = this._focusedTile; + // With the tile grid open the main terminal is parked, so the pane is the + // focused tile even when DOM focus sits on a button or a panel. + if ((!tile || tile._destroyed || !tile.terminal) && this._tilesOwnTerminal?.()) { + tile = this._tileFor(this.activeSessionId); + } if (tile && !tile._destroyed && tile.terminal) { return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile }; } @@ -4525,13 +4535,19 @@ Object.assign(CodemanApp.prototype, { }, /** - * Run `fn(tile)` for every secondary terminal pane on screen: today the split - * pane's second terminal, when one is open. Font, weight, family and skin - * changes go through here so they reach every pane without a special case - * per pane kind. Agent Teams terminals size themselves and are not tiles. + * Run `fn(tile)` for every secondary terminal pane on screen: the split + * pane's second terminal and every tile of the tile grid (never both: the two + * modes are not open together). Font, weight, family and skin changes go + * through here so they reach every pane without a special case per pane kind. + * `{ grid: false }` skips grid tiles (they keep their own font size). + * Agent Teams terminals size themselves and are not tiles. */ - _forEachTile(fn) { + _forEachTile(fn, { grid = true } = {}) { if (this._splitPane?.terminal) fn(this._splitPane); + if (!grid || !this._tileGrid?.open) return; + for (const { tile } of this._tileGrid.tiles.values()) { + if (tile.terminal) fn(tile); + } }, // Clears the pane the keyboard is in. The chord itself also reaches that @@ -4614,6 +4630,9 @@ Object.assign(CodemanApp.prototype, { this.showToast('No active session', 'warning'); return; } + // Backstop: _focusedPane() answers with the focused tile while the grid is + // open, so this is reached only if that tile is gone mid-call. + if (this._tilesOwnTerminal?.()) return; // The pane belongs to the popup showing it, so this window has nothing to // restore. Say so rather than reporting a size that was never sent — the @@ -5742,11 +5761,20 @@ Object.assign(CodemanApp.prototype, { }, increaseFontSize() { + // With the tile grid open, Ctrl +/- sizes the tiles (their own font size). + if (this._tilesOwnTerminal?.()) { + this.setTileFontSize(Math.min(this._tileGridFontSize() + 2, 24)); + return; + } const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.min(current + 2, 24)); }, decreaseFontSize() { + if (this._tilesOwnTerminal?.()) { + this.setTileFontSize(Math.max(this._tileGridFontSize() - 2, 10)); + return; + } const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.max(current - 2, 10)); }, @@ -5759,10 +5787,13 @@ Object.assign(CodemanApp.prototype, { // Update overlay font cache and re-render at new cell dimensions this._localEchoOverlay?.refreshFont(); this._predictiveEcho?.refreshFont(); - this._forEachTile?.((tile) => { - tile.terminal.options.fontSize = size; - tile.fit(); // a font change is a size change: tell its PTY too (#464) - }); + this._forEachTile?.( + (tile) => { + tile.terminal.options.fontSize = size; + tile.fit(); // a font change is a size change: tell its PTY too (#464) + }, + { grid: false } + ); }, /** @@ -6066,6 +6097,9 @@ Object.assign(CodemanApp.prototype, { // settle-time refit through this call and has no fallback, which is correct: // a pane it does not own is not its to refit either. if (!this.isSoloWindow && this.detachedSessions?.has(sessionId)) return false; + // Backstop: while the tile grid owns the terminal, a tile sizes this PTY and + // the parked main terminal measures nothing worth sending. + if (this._tilesOwnTerminal?.()) return false; // Fit, floor, and apply in one step so the numbers below are the numbers // xterm is actually holding (or, while another device holds the width, // the numbers this container would hold if the PTY followed). diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js new file mode 100644 index 00000000..adf15291 --- /dev/null +++ b/src/web/public/tile-grid.js @@ -0,0 +1,481 @@ +// src/web/public/tile-grid.js + +/** + * @fileoverview The tile grid: 1 to 9 live sessions side by side in one + * window, each in its own TerminalTile (terminal-tile.js), laid out by count + * (window.CodemanTileGrid, constants.js). Desktop only; see + * docs/tile-grid-plan.md. + * + * While the grid is open the main terminal (terminal-ui.js) is PARKED: hidden, + * its socket closed, and every path that would write to it, fetch for it or + * reconnect it stands aside (`_tilesOwnTerminal()`). `activeSessionId` always + * names the FOCUSED tile's session, so everything keyed on it (files panel, + * respawn and Ralph panels, subagent windows, voice, image paste, the tab + * highlight) follows focus without knowing tiles exist. + * + * Every capture a tile fetches goes through the grid's one TileLoadQueue, + * because each is a synchronous tmux call on the server. + * + * @dependency terminal-tile.js (window.TerminalTile, window.TileLoadQueue) + * @dependency constants.js (window.CodemanTileGrid, SPLIT_PANE_MIN_WIDTH) + * @loadorder 7.6 of 16, loaded after terminal-split.js and before respawn-ui.js + */ + +// Per-device tile font size (a tile is a fraction of the screen). +const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; +// Trailing debounce for refitting tiles after the grid area changes size, so a +// window drag sends each tile's PTY one resize, not one per frame. +const TILE_GRID_REFIT_MS = 150; + +/** The grid's state. `has(id)` answers only while it is open. */ +class TileGridModel { + constructor() { + this.open = false; + // Session ids in reading order (row-major). + this.ids = []; + // id -> { tile: TerminalTile, el: HTMLElement } + this.tiles = new Map(); + this.focusedId = null; + this.cols = 0; + this.rows = 0; + this.queue = null; + this.resizeObserver = null; + this.refitTimer = null; + } + + has(id) { + return this.open && this.tiles.has(id); + } +} + +Object.assign(CodemanApp.prototype, { + /** + * True while the grid owns the terminal area and the main terminal is parked. + * Every main-terminal path that would write, fetch, resize or reconnect checks + * this and stands aside. + */ + _tilesOwnTerminal() { + return !!this._tileGrid?.open; + }, + + /** The open grid's TerminalTile for a session, or null. */ + _tileFor(sessionId) { + return this._tileGrid?.open ? (this._tileGrid.tiles.get(sessionId)?.tile ?? null) : null; + }, + + /** Desktop only, never in a solo (popped-out) window; same gate as the split. */ + canOpenTileGrid() { + return !this.isSoloWindow && window.innerWidth >= SPLIT_PANE_MIN_WIDTH; + }, + + _tileGridFontSize() { + let saved = NaN; + try { + saved = parseInt(localStorage.getItem(TILE_GRID_FONT_KEY), 10); + } catch { + /* Storage unavailable: the default below. */ + } + return saved >= 10 && saved <= 24 ? saved : window.CodemanTileGrid.TILE_FONT_SIZE_DEFAULT; + }, + + /** Ctrl +/- while the grid is open: every tile, then each tile's PTY (a font change is a size change, #464). */ + setTileFontSize(size) { + try { + localStorage.setItem(TILE_GRID_FONT_KEY, String(size)); + } catch { + /* Per-device convenience only. */ + } + for (const { tile } of this._tileGrid?.tiles.values() || []) { + tile.fontSize = size; + if (!tile.terminal) continue; + tile.terminal.options.fontSize = size; + tile.fit(); + } + }, + + _tileLoadQueue() { + const grid = this._tileGrid; + if (!grid.queue) { + grid.queue = new window.TileLoadQueue({ + // The focused tile first, then reading order. + rank: (tile) => (tile.sessionId === grid.focusedId ? -1 : grid.ids.indexOf(tile.sessionId)), + // A quiet "loading" state on a tile until its capture has landed. + onChange: (tile, state) => { + const entry = grid.tiles.get(tile.sessionId); + if (entry?.tile === tile) entry.el.classList.toggle('tile--loading', state !== 'idle'); + }, + }); + } + return grid.queue; + }, + + _tileGridSection() { + let section = document.getElementById('tileGrid'); + if (!section) { + section = document.createElement('section'); + section.id = 'tileGrid'; + section.className = 'tile-grid'; + section.setAttribute('aria-label', 'Tiled sessions'); + const wrap = document.querySelector('.terminal-wrap'); + wrap?.parentElement?.insertBefore(section, wrap.nextSibling); + } + return section; + }, + + /** + * 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). + * + * Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while + * its snapshot of the session it shows is still right, and closes its socket. + * + * @returns {boolean} whether the grid is open afterwards + */ + openTileGrid(ids, { focusedId = null, auto = true } = {}) { + if (!this.canOpenTileGrid()) return false; + const grid = (this._tileGrid ||= new TileGridModel()); + const max = window.CodemanTileGrid.TILE_GRID_MAX; + const wanted = []; + for (const id of ids || []) { + if (typeof id !== 'string' || wanted.includes(id)) continue; + if (!this.sessions.has(id) || this.detachedSessions?.has(id)) continue; + wanted.push(id); + if (wanted.length === max) break; + } + if (wanted.length === 0) return grid.open; + const focus = wanted.includes(focusedId) ? focusedId : wanted[0]; + + if (grid.open) { + for (const id of wanted) this.addTile(id); + if (grid.has(focus)) this._selectTiledSession(focus, { auto }); + return true; + } + + this._cleanupPreviousSession(focus); + grid.open = true; + grid.ids = []; + grid.focusedId = focus; + document.querySelector('.main')?.classList.add('tiles-active'); + const section = this._tileGridSection(); + this.hideWelcome(); + // Mount every tile and lay the grid out BEFORE any tile connects, so each + // first fit measures its real cell; the focused tile connects first, so + // its capture is the one the queue starts with. + for (const id of wanted) this._mountTile(id); + this._applyTileLayout(); + for (const id of [focus, ...wanted.filter((id) => id !== focus)]) this._connectTile(id); + if (!grid.resizeObserver && typeof ResizeObserver !== 'undefined') { + // The main terminal's observer watches a node that is now hidden; this one + // catches window resizes, sidebar toggles and rail drags for the grid. + grid.resizeObserver = new ResizeObserver(() => this._scheduleTileGridRefit()); + grid.resizeObserver.observe(section); + } + this._installTileGridWidthGate(); + this._selectTiledSession(focus, { auto }); + this._updateConnectionIndicator?.(); + return true; + }, + + /** + * Leaves the grid: every tile destroyed (sockets closed, xterms disposed, + * queued loads dropped), the main terminal unparked. + * + * `keepStored` remembers the grid for one-click return (toggleTileGrid). + * `reselect` shows the focused session in the single view through a forced + * reload; pass false when the caller selects something itself. + * + * The main terminal's cached content for EVERY tiled id is invalidated: it was + * written before the grid opened, possibly hours ago, and selectSession paints + * a snapshot as its first frame. + */ + closeTileGrid({ keepStored = true, reselect = true } = {}) { + const grid = this._tileGrid; + if (!grid?.open) return; + const focusedId = grid.focusedId; + const ids = grid.ids.slice(); + this._tileGridRemembered = keepStored ? { ids, focusedId } : null; + // Closed BEFORE the tiles go: each destroy() updates the header's connection + // state, which must read the main terminal again, not half-destroyed tiles. + grid.open = false; + clearTimeout(grid.refitTimer); + grid.refitTimer = null; + grid.resizeObserver?.disconnect(); + grid.resizeObserver = null; + for (const { tile, el } of grid.tiles.values()) { + grid.queue?.drop(tile); + tile.destroy(); + el.remove(); + } + grid.tiles.clear(); + grid.ids = []; + grid.focusedId = null; + document.querySelector('.main')?.classList.remove('tiles-active'); + const section = document.getElementById('tileGrid'); + if (section) { + section.style.gridTemplateColumns = ''; + section.style.gridTemplateRows = ''; + } + // As _redock does: the tiles sized these PTYs, so the main terminal's + // record of the last size it sent no longer describes them. + this._lastResizeDims = null; + for (const id of ids) { + this._xtermSnapshots?.delete(id); + try { + localStorage.removeItem(`codeman-xs-${id}`); + } catch { + /* Nothing stored. */ + } + this.terminalBufferCache?.delete(id); + } + this._updateConnectionIndicator?.(); + if (reselect) this._selectAfterTileGrid(focusedId); + }, + + // The single view after the grid closes: the session the grid was focused on, + // 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. + _selectAfterTileGrid(sessionId) { + if (sessionId && this.sessions.has(sessionId)) { + this.selectSession(sessionId, { forceReload: true, auto: true }); + return; + } + this.activeSessionId = null; + try { + localStorage.removeItem('codeman-active-session'); + } catch { + /* Nothing stored. */ + } + const next = this.sessionOrder.find((id) => this.sessions.has(id)); + if (next) { + this.selectSession(next, { auto: true }); + } else { + this.terminal?.clear(); + this.showWelcome(); + } + }, + + /** Adds one session as a tile (open grid only). Returns whether it was added. */ + addTile(sessionId) { + const grid = this._tileGrid; + if (!grid?.open || grid.tiles.has(sessionId)) return false; + if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false; + if (!this._mountTile(sessionId)) return false; + this._applyTileLayout(); + this._connectTile(sessionId); + this._scheduleTileGridRefit(); + return true; + }, + + /** + * Removes one tile; the session keeps running. When it held focus, `refocus` + * moves focus to the neighbouring tile (next in grid order, else previous). + * The last tile leaving closes the grid: with `refocus` the single view then + * shows that session, without it the caller decides what comes next. + */ + removeTile(sessionId, { refocus = true, auto = true } = {}) { + const grid = this._tileGrid; + const entry = grid?.open ? grid.tiles.get(sessionId) : null; + if (!entry) return false; + if (grid.ids.length === 1) { + this.closeTileGrid({ keepStored: false, reselect: refocus }); + return true; + } + const wasFocused = grid.focusedId === sessionId; + const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId); + grid.queue?.drop(entry.tile); + entry.tile.destroy(); + entry.el.remove(); + grid.tiles.delete(sessionId); + grid.ids.splice(grid.ids.indexOf(sessionId), 1); + this._applyTileLayout(); + this._scheduleTileGridRefit(); + if (wasFocused) { + grid.focusedId = null; + if (refocus && neighbor) this._selectTiledSession(neighbor, { auto }); + } + return true; + }, + + _mountTile(sessionId) { + const grid = this._tileGrid; + const session = this.sessions.get(sessionId); + if (!session || this.detachedSessions?.has(sessionId)) return false; + const el = document.createElement('div'); + el.className = 'tile'; + el.dataset.sessionId = sessionId; + const body = document.createElement('div'); + body.className = 'tile-body'; + el.appendChild(body); + this._tileGridSection().appendChild(el); + const tile = new window.TerminalTile(sessionId, body, { + mode: session.mode, + fontSettings: this.loadAppSettingsFromStorage?.() || {}, + detachedSessions: this.detachedSessions, + scheduleLoad: (t, kind, run) => this._tileLoadQueue().schedule(t, kind, run), + scrollback: window.CodemanTileGrid.TILE_SCROLLBACK, + fontSize: this._tileGridFontSize(), + boundedLoad: true, + onExit: (code) => this._onTileExit(sessionId, tile, code), + }); + grid.tiles.set(sessionId, { tile, el }); + grid.ids.push(sessionId); + return true; + }, + + _connectTile(sessionId) { + this._tileGrid?.tiles + .get(sessionId) + ?.tile.connect() + .catch(() => { + /* Best-effort, as the split's Pane B: live output arrives once the socket opens. */ + }); + }, + + /** + * 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. + */ + _onTileExit(sessionId, tile, code) { + if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return; + if (code === 4009) return; + this.removeTile(sessionId, { refocus: true, auto: true }); + }, + + /** Columns x rows for the current tile count, applied to the grid section. */ + _applyTileLayout() { + const grid = this._tileGrid; + const section = this._tileGridSection(); + const rect = section.getBoundingClientRect?.() || { width: 0, height: 0 }; + const { cols, rows } = window.CodemanTileGrid.computeTileLayout({ + count: grid.ids.length, + width: rect.width || window.innerWidth, + height: rect.height || window.innerHeight, + }); + grid.cols = cols; + grid.rows = rows; + section.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; + section.style.gridTemplateRows = `repeat(${rows}, minmax(0, 1fr))`; + }, + + // Refits every tile once the grid area has settled: one xterm resize and one + // PTY resize together per tile (#464), on the trailing edge. + _scheduleTileGridRefit() { + const grid = this._tileGrid; + if (!grid?.open) return; + clearTimeout(grid.refitTimer); + grid.refitTimer = setTimeout(() => { + grid.refitTimer = null; + if (!grid.open) return; + // The 3-tile layout depends on the width (3x1 or 2x2). + this._applyTileLayout(); + for (const { tile } of grid.tiles.values()) tile.fit(); + }, TILE_GRID_REFIT_MS); + }, + + // Narrowing the window past the desktop gate returns to the single view of the + // focused session; the grid is remembered. + _installTileGridWidthGate() { + if (this._tileGridWidthGateInstalled || !window.matchMedia) return; + this._tileGridWidthGateInstalled = true; + const mq = window.matchMedia(`(min-width: ${SPLIT_PANE_MIN_WIDTH}px)`); + mq.addEventListener('change', (e) => { + if (!e.matches && this._tileGrid?.open) this.closeTileGrid({ keepStored: true, reselect: true }); + }); + }, + + _paintTileFocus() { + const grid = this._tileGrid; + for (const [id, { el }] of grid?.tiles || []) el.classList.toggle('focused', id === grid.focusedId); + }, + + /** + * Focuses a tiled session: the tile branch of selectSession. Moving focus is + * an `activeSessionId` change plus `xterm.focus()`: no fetch, no replay. It + * runs the panel refresh a normal switch runs and skips everything bound to + * the main terminal (cleanup, replay, resize, its socket, local echo). Only a + * USER-initiated selection (`auto` not true) acknowledges the idle alert. + * + * @param {string} sessionId + * @param {{auto?: boolean, focus?: boolean}} [options] - `focus: false` leaves + * DOM focus where it is (an app-driven reconcile must not steal it) + */ + _selectTiledSession(sessionId, options = {}) { + const grid = this._tileGrid; + const entry = grid?.open ? grid.tiles.get(sessionId) : null; + if (!entry) return; + const userInitiated = options.auto !== true; + // Aborts any in-flight normal select at its next _isStaleSelect check. + const selectGen = ++this._selectGeneration; + this._hideWebviewLayer?.(); + this.activeSessionId = sessionId; + grid.focusedId = sessionId; + this._activateFileBrowserSession?.(sessionId); + try { + localStorage.setItem('codeman-active-session', sessionId); + } catch { + /* Per-device convenience only. */ + } + // The SSE subscription follows the focused session as in the single view; + // its terminal frames are dropped by the parking guards. + this._updateSseSubscription?.(sessionId); + this.hideWelcome(); + if (userInitiated) this.markIdleAlertSeen(sessionId); + this._paintTileFocus(); + this._updateActiveTabImmediate?.(sessionId); + this.closeSessionSidebarOnHandheld?.(); + this.renderSessionTabs?.(); + const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`); + if (activeTab) { + activeTab.classList.add('tab-glow'); + activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true }); + } + this.updateAttachmentHistoryBadge?.(); + if (this.attachmentHistoryDrawerOpen) this.loadAttachmentHistory?.(sessionId); + if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession(); + this.refreshHostWakeBanner?.(sessionId); + this.currentSessionWorkingDir = this.sessions.get(sessionId)?.workingDir || null; + const idleCb = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb) => setTimeout(cb, 16); + idleCb(() => this._refreshSessionPanels(sessionId, selectGen)); + // The keyboard follows focus: shortcuts, voice and paste act on this tile. + this._noteFocusedTile(entry.tile); + if (options.focus !== false) entry.tile.terminal?.focus(); + }, + + /** Header connection state while tiles own the terminal: every live tile socket open, or not. */ + _tileGridSocketState() { + for (const { tile } of this._tileGrid?.tiles.values() || []) { + // A tile stopped for good (its session exited) has nothing to reconnect. + if (tile._stoppedCode !== null && tile._stoppedCode !== undefined) continue; + if (!tile._wsReady) return 'reconnecting'; + } + return 'connected'; + }, + + /** + * handleInit (page state reloaded, or SSE back after a server restart) with + * the grid open: tiles whose sessions are gone or popped out are removed, the + * rest are KEPT (never rebuilt) and told to reconnect now instead of waiting + * out their backoff, and focus stays on a live tile. + * + * @returns {boolean} whether the grid is still open (handleInit then skips + * restoring the main terminal) + */ + _reconcileTileGrid() { + const grid = this._tileGrid; + if (!grid?.open) return false; + for (const id of grid.ids.slice()) { + if (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false }); + } + 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 }); + for (const { tile } of grid.tiles.values()) tile.reconnectNow(); + return true; + }, +}); diff --git a/test/tile-grid-park-guards.test.ts b/test/tile-grid-park-guards.test.ts new file mode 100644 index 00000000..c51c6a08 --- /dev/null +++ b/test/tile-grid-park-guards.test.ts @@ -0,0 +1,559 @@ +/** + * @fileoverview While the tile grid is open the main terminal is PARKED. + * + * Opening the grid (tile-grid.js `openTileGrid`) runs `_cleanupPreviousSession` + * once, which closes the main terminal's socket, and hides `.terminal-wrap`. + * With that socket closed `_wsReady` is false, so every SSE terminal handler + * would start writing the FOCUSED tile's output into the hidden xterm (they all + * key on `activeSessionId`), refetch captures for it, or reopen its socket onto + * a session a tile already shows. One predicate, `_tilesOwnTerminal()`, turns + * each of them into a no-op. This suite enumerates them, and checks each one + * BOTH ways (grid open: stands aside; grid closed: acts), so none can pass + * vacuously: + * + * - `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` + * (returns false, never undefined), `_scheduleDroppedOutputRecovery`; + * - the `terminal.writeln` in `_onSessionCompletion` / `_onSessionError`; + * - `retryConnection` and `handleInit` (which reconnect the main socket): + * they re-arm the TILES instead, and handleInit keeps live tiles; + * - the backstops `sendResize`, `_maybeRefetchFullHistory`; + * - the WebGL long-task observer, which watches the WHOLE page: tile renders + * must not count toward the main terminal's sticky WebGL disable; + * - the header connection state, derived from the tile sockets. + * + * Plus the park and unpark themselves: one cleanup on enter; on exit every + * tile destroyed, `_lastResizeDims` reset, the main terminal's cached content + * for EVERY tiled id invalidated, and the focused session replayed fresh. + * + * Real code: constants.js + app.js + terminal-ui.js + tile-grid.js in one `vm` + * context, with a small fake DOM and a fake TerminalTile. Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */ +class FakeEl { + id = ''; + className = ''; + dataset: Record = {}; + style: Record = {}; + children: FakeEl[] = []; + parentElement: FakeEl | null = null; + attrs: Record = {}; + listeners: Record void>> = {}; + classList = { + add: (...names: string[]) => names.forEach((n) => this._setClass(n, true)), + remove: (...names: string[]) => names.forEach((n) => this._setClass(n, false)), + toggle: (n: string, on?: boolean) => this._setClass(n, on ?? !this.classList.contains(n)), + contains: (n: string) => this.className.split(/\s+/).includes(n), + }; + _setClass(name: string, on: boolean) { + const set = new Set(this.className.split(/\s+/).filter(Boolean)); + if (on) set.add(name); + else set.delete(name); + this.className = [...set].join(' '); + return on; + } + appendChild(child: FakeEl) { + child.remove(); + child.parentElement = this; + this.children.push(child); + return child; + } + insertBefore(child: FakeEl, ref: FakeEl | null) { + child.remove(); + child.parentElement = this; + const i = ref ? this.children.indexOf(ref) : -1; + if (i === -1) this.children.push(child); + else this.children.splice(i, 0, child); + return child; + } + get nextSibling() { + const siblings = this.parentElement?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + remove() { + if (!this.parentElement) return; + const siblings = this.parentElement.children; + siblings.splice(siblings.indexOf(this), 1); + this.parentElement = null; + } + setAttribute(k: string, v: string) { + this.attrs[k] = v; + } + addEventListener(type: string, fn: (ev: unknown) => void) { + (this.listeners[type] ||= []).push(fn); + } + removeEventListener() {} + getBoundingClientRect() { + return { width: 2400, height: 1200 }; + } + querySelector() { + return null; + } +} + +const main = new FakeEl(); +main.className = 'main'; +const wrap = new FakeEl(); +wrap.className = 'terminal-wrap'; +main.appendChild(wrap); +const section = new FakeEl(); +section.id = 'tileGrid'; +section.className = 'tile-grid'; +main.appendChild(section); + +const documentStub = { + addEventListener: vi.fn(), + documentElement: { dataset: {} }, + createElement: () => new FakeEl(), + getElementById: (id: string) => (id === 'tileGrid' ? section : null), + querySelector: (sel: string) => (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null), + querySelectorAll: () => [], +}; + +/** A TerminalTile stand-in: records what the grid asks of it. */ +class FakeTile { + static all: FakeTile[] = []; + _wsReady = false; + _stoppedCode: number | null = null; + _destroyed = false; + fontSize: number | null; + terminal = { focus: vi.fn(), options: { fontSize: 0 } as Record }; + connect = vi.fn(async () => {}); + reconnectNow = vi.fn(); + fit = vi.fn(); + destroy = vi.fn(() => { + this._destroyed = true; + }); + constructor( + public sessionId: string, + public mountEl: FakeEl, + public opts: Record + ) { + this.fontSize = (opts.fontSize as number) ?? null; + FakeTile.all.push(this); + } +} + +let observerCallback: ((list: { getEntries(): unknown[] }) => void) | null = null; +let clock = 100_000; +const localStore = new Map(); + +const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); +const windowStub: Record = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + CodemanBase: { base: '' }, + innerWidth: 2400, + innerHeight: 1200, +}; +const context = vm.createContext({ + console: { ...console, log: vi.fn(), debug: vi.fn(), warn: vi.fn() }, + performance: { now: () => clock }, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), + requestAnimationFrame: vi.fn(), + requestIdleCallback: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + PerformanceObserver: class { + constructor(cb: (list: { getEntries(): unknown[] }) => void) { + observerCallback = cb; + } + observe() {} + disconnect() {} + }, + fetch: vi.fn(), + navigator: { onLine: true }, + location: { protocol: 'http:', host: 'codeman.test', pathname: '/', search: '', hash: '' }, + document: documentStub, + localStorage: { + getItem: (k: string) => localStore.get(k) ?? null, + setItem: (k: string, v: string) => localStore.set(k, String(v)), + removeItem: (k: string) => localStore.delete(k), + }, + window: windowStub, + VoiceInput: { cleanup: vi.fn() }, + MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' }, +}); +vm.runInContext( + `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('tile-grid.js')}\n` + + 'globalThis.__CodemanApp = CodemanApp;', + context +); +windowStub.TerminalTile = FakeTile; +windowStub.TileLoadQueue = class { + schedule(_t: unknown, _k: string, run: () => Promise) { + return run(); + } + drop() {} +}; +const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp; + +type App = Record; + +const IDS = ['s-a', 's-b', 's-c']; + +function makeApp(): App { + const app = Object.create(CodemanApp.prototype) as App; + app.sessions = new Map( + [...IDS, 's-other'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1, workingDir: '/w' }]) + ); + app.sessionOrder = ['s-other', ...IDS]; + app.detachedSessions = new Set(); + app.isSoloWindow = false; + app.activeSessionId = 's-a'; + app._selectGeneration = 0; + app._initGeneration = 1; + app._xtermSnapshots = new Map(); + app.terminalBufferCache = new Map(); + app._pendingDeliveries = new Map(); + app._closingSessions = new Set(); + app.pendingHooks = new Map(); + app.isOnline = true; + app._connectionStatus = 'connected'; + app._wsState = 'disconnected'; + app._lastResizeDims = { cols: 100, rows: 30 }; + app.terminal = { writeln: vi.fn(), clear: vi.fn(), focus: vi.fn(), options: { fontSize: 14 } }; + // Everything around the grid that is not under test here. + for (const name of [ + '_cleanupPreviousSession', + 'hideWelcome', + 'showWelcome', + 'markIdleAlertSeen', + 'renderSessionTabs', + '_updateActiveTabImmediate', + '_refreshSessionPanels', + '_updateSseSubscription', + '_updateConnectionIndicator', + '_activateFileBrowserSession', + '_hideWebviewLayer', + 'closeSessionSidebarOnHandheld', + 'updateAttachmentHistoryBadge', + 'refreshHostWakeBanner', + 'selectSession', + 'batchTerminalWrite', + '_connectWs', + '_geometryForResizeRequest', + 'updateCost', + '_notifySession', + 'connectSSE', + '_updateConnectionLossUi', + ]) { + app[name] = vi.fn(); + } + app._fetchTerminalCapture = vi.fn(async () => ({ json: { data: {} }, headersAt: 0 })); + app.loadAppSettingsFromStorage = () => ({}); + return app; +} + +beforeEach(() => { + FakeTile.all = []; + localStore.clear(); + windowStub.innerWidth = 2400; + section.children = []; + main.className = 'main'; +}); + +describe('parking the main terminal', () => { + it('opening parks it ONCE and puts a tile per session in its place, focused tile active', () => { + const app = makeApp(); + expect(app.openTileGrid(IDS, { focusedId: 's-b' })).toBe(true); + + expect(app._cleanupPreviousSession).toHaveBeenCalledTimes(1); + expect(main.classList.contains('tiles-active')).toBe(true); + expect(FakeTile.all.map((t) => t.sessionId)).toEqual(IDS); + expect(FakeTile.all.every((t) => t.connect.mock.calls.length === 1)).toBe(true); + expect(app.activeSessionId).toBe('s-b'); + expect(app._tilesOwnTerminal()).toBe(true); + // Grid tiles: the one load queue, the smaller scrollback, a bounded load. + expect(FakeTile.all[0].opts).toMatchObject({ scrollback: 10000, boundedLoad: true }); + expect(typeof FakeTile.all[0].opts.scheduleLoad).toBe('function'); + }); + + it('the focused tile connects first, so its capture leads the queue', () => { + const app = makeApp(); + const order: string[] = []; + app.openTileGrid(IDS, { focusedId: 's-c' }); + for (const t of FakeTile.all) order.push(`${t.sessionId}:${t.connect.mock.invocationCallOrder[0]}`); + const first = FakeTile.all.reduce((a, b) => + a.connect.mock.invocationCallOrder[0] < b.connect.mock.invocationCallOrder[0] ? a : b + ); + expect(first.sessionId).toBe('s-c'); + }); + + it('refuses below the desktop width, in a solo window, and with no live session', () => { + const narrow = makeApp(); + windowStub.innerWidth = 1179; + expect(narrow.openTileGrid(IDS)).toBe(false); + windowStub.innerWidth = 2400; + + const solo = makeApp(); + solo.isSoloWindow = true; + expect(solo.openTileGrid(IDS)).toBe(false); + + const none = makeApp(); + expect(none.openTileGrid(['gone'])).toBe(false); + expect(none._cleanupPreviousSession).not.toHaveBeenCalled(); + }); + + it('never tiles a detached session', () => { + const app = makeApp(); + app.detachedSessions.add('s-b'); + app.openTileGrid(IDS); + expect(FakeTile.all.map((t) => t.sessionId)).toEqual(['s-a', 's-c']); + }); + + it('closing destroys every tile and replays the focused session fresh in the single view', () => { + const app = makeApp(); + app.openTileGrid(IDS, { focusedId: 's-b' }); + app.closeTileGrid(); + + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true); + expect(main.classList.contains('tiles-active')).toBe(false); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app._lastResizeDims).toBeNull(); + expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true, auto: true }); + }); + + it("closing invalidates the main terminal's cached content for EVERY tiled id, and only those", () => { + const app = makeApp(); + for (const id of [...IDS, 's-other']) { + app._xtermSnapshots.set(id, 'old'); + app.terminalBufferCache.set(id, 'old'); + localStore.set(`codeman-xs-${id}`, 'old'); + } + app.openTileGrid(IDS); + app.closeTileGrid(); + + for (const id of IDS) { + expect(app._xtermSnapshots.has(id)).toBe(false); + expect(app.terminalBufferCache.has(id)).toBe(false); + expect(localStore.has(`codeman-xs-${id}`)).toBe(false); + } + expect(app._xtermSnapshots.get('s-other')).toBe('old'); + expect(localStore.get('codeman-xs-s-other')).toBe('old'); + }); + + it('closing with reselect: false leaves the next selection to the caller', () => { + const app = makeApp(); + app.openTileGrid(IDS); + app.closeTileGrid({ reselect: false }); + expect(app.selectSession).not.toHaveBeenCalled(); + }); +}); + +/** An app with the grid open on IDS (focus s-a), or closed, for the paired guard checks. */ +function appWithGrid(open: boolean) { + const app = makeApp(); + if (open) app.openTileGrid(IDS); + return app; +} + +describe('guards: each stands aside while tiles own the terminal, and acts otherwise', () => { + it.each([true, false])('_onSessionTerminal (grid open: %s)', (open) => { + const app = appWithGrid(open); + app.pendingWrites = []; + app._onSessionTerminal({ id: 's-a', data: 'output' }); + expect(app.batchTerminalWrite).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])('_onSessionNeedsRefresh (grid open: %s)', async (open) => { + const app = appWithGrid(open); + const result = await app._onSessionNeedsRefresh({ id: 's-a' }); + if (open) expect(result).toBe(false); + expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])('_onSessionClearTerminal (grid open: %s)', async (open) => { + const app = appWithGrid(open); + app._resetTerminalForReplay = vi.fn(); + app.sendResize = vi.fn(); + await app._onSessionClearTerminal({ id: 's-a' }); + expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])('_scheduleDroppedOutputRecovery (grid open: %s)', (open) => { + const app = appWithGrid(open); + app._scheduleDroppedOutputRecovery('s-a', 0, 1024); + expect(!!app._clientDropRecoveryTimer).toBe(!open); + clearTimeout(app._clientDropRecoveryTimer); + }); + + it.each([true, false])( + '_onSessionCompletion writes into the main terminal only when it is not parked (open: %s)', + (open) => { + const app = appWithGrid(open); + app.totalCost = 0; + app._onSessionCompletion({ id: 's-a', cost: 0.5 }); + expect(app.terminal.writeln).toHaveBeenCalledTimes(open ? 0 : 2); + expect(app.totalCost).toBe(0.5); + } + ); + + it.each([true, false])( + '_onSessionError writes into the main terminal only when it is not parked (open: %s)', + (open) => { + const app = appWithGrid(open); + app._onSessionError({ id: 's-a', error: 'boom' }); + expect(app.terminal.writeln).toHaveBeenCalledTimes(open ? 0 : 1); + // The notification is not the terminal's: it fires either way. + expect(app._notifySession).toHaveBeenCalledTimes(1); + } + ); + + it.each([true, false])('retryConnection re-arms the tiles, never the parked main socket (open: %s)', (open) => { + const app = appWithGrid(open); + app.retryConnection(); + clearTimeout(app._offlineRetryTimer); + expect(app._connectWs).toHaveBeenCalledTimes(open ? 0 : 1); + if (open) expect(FakeTile.all.every((t) => t.reconnectNow.mock.calls.length === 1)).toBe(true); + }); + + it.each([true, false])('sendResize backstop (grid open: %s)', async (open) => { + const app = appWithGrid(open); + expect(await app.sendResize('s-a')).toBe(false); + expect(app._geometryForResizeRequest).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])('_maybeRefetchFullHistory backstop (grid open: %s)', async (open) => { + const app = appWithGrid(open); + app._fullHistoryRepullAt = new Map(); + app._fullHistoryRepullUseless = new Set(); + app._fetchTerminalCapture = vi.fn(async () => { + throw new Error('stop here'); + }); + await app._maybeRefetchFullHistory().catch(() => {}); + expect(app._fetchTerminalCapture).toHaveBeenCalledTimes(open ? 0 : 1); + }); + + it.each([true, false])( + 'the WebGL long-task observer counts nothing while tiles own the terminal (open: %s)', + (open) => { + const app = appWithGrid(open); + app._webglAddon = { dispose: vi.fn() }; + app._webglLongTaskObserver = null; + app._disableWebGLSticky = vi.fn(); + app._scheduleTerminalRepaint = vi.fn(); + app._installWebGLLongTaskGuard(); + clock += 60_000; // past the install grace period + const longTasks = [0, 1, 2].map((i) => ({ duration: 400, startTime: clock - 100 * i })); + observerCallback?.({ getEntries: () => longTasks }); + expect(app._disableWebGLSticky).toHaveBeenCalledTimes(open ? 0 : 1); + } + ); +}); + +describe('handleInit with the grid open (SSE back after a server restart)', () => { + function init(app: App, liveIds: string[]) { + app._initGeneration = 1; // gen > 1 on this call: a reconnect, not a page load + app._resetAllAppState = vi.fn(() => app.sessions.clear()); + for (const name of [ + '_clearTimer', + '_updateCjkInputState', + 'syncSessionOrder', + '_loadTabLayout', + 'cleanupAllFloatingWindows', + 'startSystemStatsPolling', + 'stopSystemStatsPolling', + ]) { + app[name] = vi.fn(); + } + app.$ = () => null; + app._onSessionNeedsRefresh = vi.fn(async () => false); + app.handleInit({ + sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })), + scheduledRuns: [], + }); + } + + it('keeps live tiles (never rebuilt), reconnects them now, and never touches the main socket', () => { + const app = appWithGrid(true); + const before = [...FakeTile.all]; + init(app, [...IDS, 's-other']); + + expect(FakeTile.all).toEqual(before); + expect(before.every((t) => t.destroy.mock.calls.length === 0)).toBe(true); + expect(before.every((t) => t.reconnectNow.mock.calls.length === 1)).toBe(true); + expect(app._connectWs).not.toHaveBeenCalled(); + expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled(); + expect(app.selectSession).not.toHaveBeenCalled(); + expect(app.activeSessionId).toBe('s-a'); + }); + + it('removes the tile of a session that did not come back and focuses a live one', () => { + const app = appWithGrid(true); + init(app, ['s-b', 's-c', 's-other']); + + const tileA = FakeTile.all.find((t) => t.sessionId === 's-a'); + expect(tileA?.destroy).toHaveBeenCalledTimes(1); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-b'); + expect(app._connectWs).not.toHaveBeenCalled(); + }); + + it('leaves an active web tab alone when the focused tile is still live (no re-select on every SSE blip)', () => { + const app = appWithGrid(true); + app.activeWebviewId = 'w1'; + app._hideWebviewLayer.mockClear(); + init(app, [...IDS, 's-other']); + expect(app._hideWebviewLayer).not.toHaveBeenCalled(); + expect(app.activeSessionId).toBe('s-a'); + }); + + it('without the grid, the same reconnect resyncs the main terminal (the guard is what differs)', () => { + const app = appWithGrid(false); + init(app, [...IDS, 's-other']); + expect(app._connectWs).toHaveBeenCalledWith('s-a'); + }); +}); + +describe('the header connection state follows the tile sockets', () => { + it('reads connected only when every live tile socket is open; a stopped tile does not count', () => { + const app = appWithGrid(true); + // The main socket is parked on purpose; it must not decide the header. + app._wsState = 'disconnected'; + for (const t of FakeTile.all) t._wsReady = true; + expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot connected'); + + FakeTile.all[1]._wsReady = false; + expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot reconnecting'); + + FakeTile.all[1]._stoppedCode = 4009; + expect(app._computeConnectionDescriptor().dotClass).toBe('connection-dot connected'); + }); +}); + +describe('panes and fonts while the grid is open', () => { + it('_focusedPane() is the focused tile even when DOM focus left every terminal', () => { + const app = appWithGrid(true); + app._noteFocusedTile(null); + const pane = app._focusedPane(); + expect(pane.isPrimary).toBe(false); + expect(pane.sessionId).toBe('s-a'); + }); + + it('_forEachTile reaches every grid tile; { grid: false } skips them', () => { + const app = appWithGrid(true); + const seen: string[] = []; + app._forEachTile((t: FakeTile) => seen.push(t.sessionId)); + expect(seen).toEqual(IDS); + const none: string[] = []; + app._forEachTile((t: FakeTile) => none.push(t.sessionId), { grid: false }); + expect(none).toEqual([]); + }); + + it('Ctrl +/- sizes the tiles (their own per-device font), refitting each, not the main terminal', () => { + const app = appWithGrid(true); + app.setFontSize = vi.fn(); + app.increaseFontSize(); + expect(app.setFontSize).not.toHaveBeenCalled(); + expect(localStore.get('codeman-tile-font-size')).toBe('15'); + expect(FakeTile.all.every((t) => t.terminal.options.fontSize === 15 && t.fit.mock.calls.length > 0)).toBe(true); + }); +});