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); + }); +});