From 6bb16fdad6b6130c5605af9a06b6168ff40230e8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 6 Oct 2026 19:26:58 +0200 Subject: [PATCH] feat(tiles): the grid survives a reload (per device, ids only) The grid is stored in localStorage `codeman:tile-grid` as { v: 1, open, ids, focused, zoomed, colFr, rowFr }: ids, focus, a zoom the user chose (an automatic one is worked out again from the window) and the divider fractions, never content. It is written as it changes (layout, focus, zoom, divider drags); closing the grid keeps it remembered as open: false for one-click return, and the last tile leaving forgets it. That stored state is now the only "remembered" grid, so the Tiles toggle, the picker's preselection and Ctrl/Cmd+click all bring back the grid this device last had, across reloads. Never written or read in a solo window. The restore runs INSIDE handleInit, in place of its single-view selectSession(restoreId, { auto: true }), so with a stored open grid the main terminal never loads on that page load (its first select would pull a whole-history capture only to be parked). The stored ids are sanitized against the session list (deleted, detached and duplicate ids dropped, anything that is not a v1 object ignored), and the fractions and zoom go back on. A window too narrow for the grid keeps the single view and the stored grid waits; a #session= link on load wins and leaves the grid remembered but closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 8 + src/web/public/tile-grid.js | 125 +++++++++++++-- test/tile-grid-restore.test.ts | 226 +++++++++++++++++++++++++++ test/tile-grid-select-branch.test.ts | 14 +- 4 files changed, 361 insertions(+), 12 deletions(-) create mode 100644 test/tile-grid-restore.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index 5b1410f5..d7246a4f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4667,6 +4667,9 @@ class CodemanApp { // A `#session=` link wins over restoring the last active tab. if (this._urlSessionId && this.sessions.has(this._urlSessionId)) { + // And over a tile grid stored open: it stays remembered, closed, rather + // than reappearing unexplained on the next reload. + if (!tilesOpen) this._closeStoredTileGrid?.(); this.activeSessionId = null; this._selectUrlSession(); return; @@ -4702,6 +4705,11 @@ class CodemanApp { this._splitPane?.reconnectNow?.(); } else { this.activeSessionId = null; + // A tile grid stored open on this device (tile-grid.js) comes back IN + // PLACE of the single-view restore below, so the main terminal never + // loads (its first select would pull a whole-history capture only to + // be parked a moment later). + if (this._restoreTileGrid?.()) return; // `auto`: the app is restoring a session on load, not a human opening // one, so a pending idle alert on that tab stays armed until it is // actually tapped (see the userInitiated note in selectSession). diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 48d85af4..eb5a0e2a 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -23,6 +23,10 @@ // Per-device tile font size (a tile is a fraction of the screen). const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; +// The grid this device last had, ids only (sanitizeTileGridState, constants.js): +// `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`. `open: false` keeps it +// remembered for one-click return; restored on reload inside handleInit. +const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; // 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; @@ -238,7 +242,9 @@ Object.assign(CodemanApp.prototype, { if (!grid?.open) return; const focusedId = grid.focusedId; const ids = grid.ids.slice(); - this._tileGridRemembered = keepStored ? { ids, focusedId } : null; + // Remembered (closed) for one-click return, or forgotten. + if (keepStored) this._persistTileGrid({ open: false }); + else this._forgetStoredTileGrid(); // A divider drag in progress ends with the grid. this._tileDividerDragTeardown?.(); // Closed BEFORE the tiles go: each destroy() updates the header's connection @@ -355,7 +361,7 @@ Object.assign(CodemanApp.prototype, { const T = window.CodemanTileGrid; const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX)); const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions); - const remembered = (this._tileGridRemembered?.ids || []).filter((id) => candidates.some((c) => c.id === id)); + const remembered = (this._readStoredTileGrid()?.ids || []).filter((id) => candidates.some((c) => c.id === id)); const seed = remembered.length ? remembered : [this.activeSessionId, this._splitPane ? this._splitSessionId : null].filter(Boolean); @@ -590,10 +596,9 @@ Object.assign(CodemanApp.prototype, { return; } if (!this.canOpenTileGrid()) return; - const remembered = this._tileGridRemembered; - const ids = (remembered?.ids || []).filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id)); - if (ids.length > 0) { - this.openTileGrid(ids, { focusedId: remembered.focusedId, auto: true }); + const remembered = this._readStoredTileGrid(); + if (remembered?.ids.length) { + this._openStoredTileGrid(remembered); return; } if (this.activeSessionId) this.openTileGrid([this.activeSessionId], { focusedId: this.activeSessionId }); @@ -844,9 +849,7 @@ Object.assign(CodemanApp.prototype, { this.selectSession(sessionId); return true; } - const remembered = (this._tileGridRemembered?.ids || []).filter( - (id) => this.sessions.has(id) && !this.detachedSessions?.has(id) - ); + const remembered = this._readStoredTileGrid()?.ids || []; const base = remembered.length ? remembered : [this.activeSessionId].filter(Boolean); const ids = [...base.filter((id) => id !== sessionId).slice(0, capacity - 1), sessionId]; this.openTileGrid(ids, { focusedId: sessionId, auto: false }); @@ -1250,6 +1253,7 @@ Object.assign(CodemanApp.prototype, { }); this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows); this._syncTileSlots(zoomed ? 0 : cols * rows - grid.ids.length, cols); + this._persistTileGrid(); }, // The empty cells of a layout that is not full (3 tiles in a 2x2, 5 in a @@ -1387,6 +1391,7 @@ Object.assign(CodemanApp.prototype, { for (const tile of affected) { if (!tile._destroyed) tile.fit(); } + this._persistTileGrid(); }; divider.classList.add('dragging'); document.body.classList.add('tile-grid-resizing', `tile-grid-resizing--${axis}`); @@ -1514,6 +1519,108 @@ Object.assign(CodemanApp.prototype, { // The keyboard follows focus: shortcuts, voice and paste act on this tile. this._noteFocusedTile(entry.tile); if (options.focus !== false) entry.tile.terminal?.focus(); + this._persistTileGrid(); + }, + + // ── Persistence (codeman:tile-grid, per device, ids only) ──────────────── + + /** + * Writes the open grid: ids, focus, a zoom the user chose (an automatic one + * is worked out again from the window) and the divider fractions. Never + * content. `open: false` is the closed-but-remembered state. Never in a solo + * window; a storage failure only costs the convenience. + */ + _persistTileGrid({ open = true } = {}) { + const grid = this._tileGrid; + if (this.isSoloWindow || !grid || grid.ids.length === 0) return; + if (open && !grid.open) return; + const state = { + v: 1, + open, + ids: grid.ids.slice(), + focused: grid.focusedId, + zoomed: grid.autoZoom ? null : grid.zoomedId, + colFr: grid.colFr.slice(), + rowFr: grid.rowFr.slice(), + }; + try { + localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify(state)); + } catch { + /* Per-device convenience only. */ + } + }, + + _forgetStoredTileGrid() { + try { + localStorage.removeItem(TILE_GRID_STORAGE_KEY); + } catch { + /* Nothing stored. */ + } + }, + + /** The stored grid, sanitized against the sessions this page knows now, or null. */ + _readStoredTileGrid() { + if (this.isSoloWindow) return null; + let raw = null; + try { + raw = localStorage.getItem(TILE_GRID_STORAGE_KEY); + } catch { + return null; + } + if (!raw) return null; + return window.CodemanTileGrid.sanitizeTileGridState(raw, this.sessions, this.detachedSessions); + }, + + /** + * Opens a stored grid: its tiles and focus, then the fractions it had (only + * if they still match the layout) and a zoom the user chose. `auto`: the app + * is putting it back, so no idle alert is spent. + */ + _openStoredTileGrid(stored) { + const focus = stored.zoomed || stored.focused; + if (!this.openTileGrid(stored.ids, { focusedId: focus, auto: true })) return false; + const grid = this._tileGrid; + // openTileGrid laid the grid out with equal tracks. The stored ones go back + // on; _applyTileLayout drops them again if they do not match the column or + // row count (the window may have changed the layout since). + if (stored.colFr) grid.colFr = stored.colFr.slice(); + if (stored.rowFr) grid.rowFr = stored.rowFr.slice(); + if (stored.zoomed && grid.tiles.has(stored.zoomed)) { + grid.zoomedId = stored.zoomed; + grid.autoZoom = false; + } + this._applyTileLayout(); + this._scheduleTileGridRefit(); + return true; + }, + + /** + * Page load (handleInit, in place of selecting the session to restore): a + * grid stored OPEN on this device comes back, ids sanitized against the + * session list (deleted, detached and duplicate ids dropped). The main + * terminal then never loads on this page load, so no `full=1` capture is + * paid for a terminal about to be parked. Not in a solo window (nothing is + * read there), nor on a window too narrow for the grid (openTileGrid + * refuses; the stored grid waits for a wide one). + * + * @returns {boolean} whether the grid was restored + */ + _restoreTileGrid() { + if (this._tilesOwnTerminal()) return false; + const stored = this._readStoredTileGrid(); + if (!stored?.open || stored.ids.length === 0) return false; + return this._openStoredTileGrid(stored); + }, + + /** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */ + _closeStoredTileGrid() { + const stored = this._readStoredTileGrid(); + if (!stored?.open) return; + try { + localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...stored, open: false })); + } catch { + /* Per-device convenience only. */ + } }, /** Header connection state while tiles own the terminal: every live tile socket open, or not. */ diff --git a/test/tile-grid-restore.test.ts b/test/tile-grid-restore.test.ts new file mode 100644 index 00000000..6ef46b27 --- /dev/null +++ b/test/tile-grid-restore.test.ts @@ -0,0 +1,226 @@ +/** + * @fileoverview The grid survives a page reload, per device (`codeman:tile-grid`). + * + * - What is stored: ids, focus, a zoom the user chose and the divider + * fractions, never content. Closing the grid (Tiles, a pick outside it) + * keeps it remembered as `open: false` for one-click return; the last tile + * leaving forgets it. Never written or read in a solo window. + * - The restore runs INSIDE handleInit, in place of its single-view + * `selectSession(restoreId, { auto: true })`: with a stored open grid the + * main terminal never loads on that page load (no select, no socket, no + * capture), deleted / detached / duplicate ids are dropped, and the stored + * fractions and zoom come back. A narrow window keeps the single view. + * - A `#session=` link on load wins, and leaves the stored grid remembered + * but closed. + * - Leaving the grid invalidates the main terminal's cached content for every + * tiled id (snapshot, its localStorage copy, buffer cache). + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeTile, localStore, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c', 's-d']; +const KEY = 'codeman:tile-grid'; +const stored = () => JSON.parse(localStore.get(KEY) ?? 'null'); + +/** A fresh page: the app as handleInit leaves it on its FIRST run (gen 1). */ +function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) { + const app = makeGridApp(IDS); + app._initGeneration = 0; + app.activeSessionId = null; + app.selectSession = vi.fn(); + app._fetchTerminalCapture = vi.fn(); + app._resetAllAppState = vi.fn(() => app.sessions.clear()); + for (const name of [ + '_clearTimer', + '_updateCjkInputState', + 'syncSessionOrder', + '_loadTabLayout', + 'cleanupAllFloatingWindows', + 'startSystemStatsPolling', + 'stopSystemStatsPolling', + 'updateCost', + ]) { + app[name] = vi.fn(); + } + app.$ = () => null; + setup(app); + app.handleInit({ + sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })), + scheduledRuns: [], + }); + return app; +} + +beforeEach(() => { + resetGridHarness(); +}); + +describe('what is stored', () => { + it('opening the grid stores ids, focus and fractions, nothing else', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS, { focusedId: 's-b' }); + expect(stored()).toEqual({ + v: 1, + open: true, + ids: IDS, + focused: 's-b', + zoomed: null, + colFr: [1, 1], + rowFr: [1, 1], + }); + }); + + it('focus, a zoom by hand and a divider drag are written as they happen', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.selectSession('s-c'); + expect(stored().focused).toBe('s-c'); + app.zoomTile('s-c'); + expect(stored().zoomed).toBe('s-c'); + app.zoomTile('s-c'); + app._tileGrid.colFr = [2, 1]; + app._persistTileGrid(); + expect(stored().colFr).toEqual([2, 1]); + }); + + it('an automatic zoom (window too small) is not stored: it is worked out again', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app._tileGrid.zoomedId = 's-a'; + app._tileGrid.autoZoom = true; + app._persistTileGrid(); + expect(stored().zoomed).toBeNull(); + }); + + it('closing keeps it remembered (open: false); the last tile leaving forgets it', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + app.openTileGrid(IDS); + app.closeTileGrid({ reselect: false }); + expect(stored()).toMatchObject({ open: false, ids: IDS }); + + app.openTileGrid(['s-a']); + app.removeTile('s-a'); + expect(localStore.has(KEY)).toBe(false); + }); + + it('a solo window never writes', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + localStore.delete(KEY); + app.isSoloWindow = true; + app._persistTileGrid(); + expect(localStore.has(KEY)).toBe(false); + }); +}); + +describe('page load with a stored open grid', () => { + const storeGrid = (state: Record) => + localStore.set(KEY, JSON.stringify({ v: 1, open: true, zoomed: null, ...state })); + + it('restores the grid IN PLACE of the single view: the main terminal never loads', () => { + storeGrid({ ids: IDS, focused: 's-c' }); + const app = pageLoad([...IDS, 's-other']); + + expect(app._tilesOwnTerminal()).toBe(true); + expect(app._tileGrid.ids).toEqual(IDS); + expect(app.activeSessionId).toBe('s-c'); + expect(app.selectSession).not.toHaveBeenCalled(); + expect(app._connectWs).not.toHaveBeenCalled(); + expect(app._fetchTerminalCapture).not.toHaveBeenCalled(); + // Restoring is the app's choice of focus: no idle alert spent. + expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); + }); + + it('drops a session that no longer exists, and stores the cleaned list', () => { + storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 'gone' }); + const app = pageLoad(IDS); + expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + expect(app.activeSessionId).toBe('s-a'); + expect(stored().ids).toEqual(['s-a', 's-b']); + }); + + it('brings back the fractions (same layout only) and a zoom the user chose', () => { + storeGrid({ ids: IDS, focused: 's-a', zoomed: 's-b', colFr: [2, 1], rowFr: [1, 1, 1] }); + const app = pageLoad(IDS); + expect(app._tileGrid.colFr).toEqual([2, 1]); + // Three row fractions do not fit a 2x2: equal rows. + expect(app._tileGrid.rowFr).toEqual([1, 1]); + expect(app._tileGrid.zoomedId).toBe('s-b'); + expect(app.activeSessionId).toBe('s-b'); + }); + + it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => { + localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' })); + const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a')); + expect(app._tilesOwnTerminal()).toBe(false); + expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true }); + + app.activeSessionId = 's-a'; + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-b', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => { + storeGrid({ ids: IDS, focused: 's-a' }); + windowStub.innerWidth = 1100; + const app = pageLoad(IDS); + expect(app._tileGrid?.open ?? false).toBe(false); + expect(app.selectSession).toHaveBeenCalled(); + expect(stored().open).toBe(true); + }); + + it('a solo window never restores it', () => { + storeGrid({ ids: IDS, focused: 's-a' }); + const app = pageLoad(IDS, (a) => { + a.isSoloWindow = true; + a._applySoloMode = vi.fn(); + }); + expect(app._tileGrid?.open ?? false).toBe(false); + expect(FakeTile.all).toHaveLength(0); + }); + + it.each([ + ['another version', JSON.stringify({ v: 2, open: true, ids: IDS })], + ['not JSON', '{oops'], + ['not an object', '[1,2]'], + ])('ignores a stored value that is %s', (_label, raw) => { + localStore.set(KEY, raw); + const app = pageLoad(IDS); + expect(app._tileGrid?.open ?? false).toBe(false); + expect(app.selectSession).toHaveBeenCalled(); + }); + + it('a #session= link on load wins, and the grid stays remembered, closed', () => { + storeGrid({ ids: IDS, focused: 's-a' }); + const app = pageLoad(IDS, (a) => { + a._urlSessionId = 's-d'; + }); + expect(app._tileGrid?.open ?? false).toBe(false); + expect(app.selectSession).toHaveBeenCalledWith('s-d', { auto: true, leaveTiles: true }); + expect(stored()).toMatchObject({ open: false, ids: IDS }); + }); +}); + +describe('leaving the grid', () => { + it("invalidates the main terminal's cached content for every tiled id", () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + 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(['s-a', 's-b']); + app.closeTileGrid(); + for (const id of ['s-a', 's-b']) { + 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-c')).toBe('old'); + }); +}); diff --git a/test/tile-grid-select-branch.test.ts b/test/tile-grid-select-branch.test.ts index 611785f7..3599f965 100644 --- a/test/tile-grid-select-branch.test.ts +++ b/test/tile-grid-select-branch.test.ts @@ -18,7 +18,15 @@ * vm harness in test/mocks/tile-grid-vm.ts). Port: N/A. */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FakeTile, idleCallbacks, makeGridApp, resetGridHarness, section, type GridApp } from './mocks/tile-grid-vm.js'; +import { + FakeTile, + idleCallbacks, + localStore, + makeGridApp, + resetGridHarness, + section, + type GridApp, +} from './mocks/tile-grid-vm.js'; const IDS = ['s-a', 's-b', 's-c']; @@ -99,7 +107,7 @@ describe('selectSession with the grid open', () => { expect(app._tilesOwnTerminal()).toBe(false); expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 1)).toBe(true); - expect(app._tileGridRemembered).toEqual({ ids: IDS, focusedId: 's-a' }); + expect(JSON.parse(localStore.get('codeman:tile-grid')!)).toMatchObject({ open: false, ids: IDS, focused: 's-a' }); // The parked terminal's stale content must not be saved as s-a's snapshot. expect(activeAtCleanup).toBeNull(); expect(app.activeSessionId).toBe('s-other'); @@ -169,7 +177,7 @@ describe('moving focus between tiles', () => { const app = openGrid(); app.goHome(); expect(app._tilesOwnTerminal()).toBe(false); - expect(app._tileGridRemembered?.ids).toEqual(IDS); + expect(JSON.parse(localStore.get('codeman:tile-grid')!)).toMatchObject({ open: false, ids: IDS }); expect(app.activeSessionId).toBeNull(); expect(app.showWelcome).toHaveBeenCalled(); });