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