From 950450b137551e2261ca725f07441ebe8eb4199f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 05:39:51 +0200 Subject: [PATCH 1/8] feat(tiles): open and fill tiles by work, then input, then recency The Tiles button with no stored grid opened the first tabs, so the oldest sessions got tiles while the busy ones were left out. It now ranks the open sessions: working first (the most recently started turn first, keyed off lastSubmitAt only, since a working pane's activity stamp is always "now"), then the ones needing input (the red and yellow tab alerts), then everything else by most recent activity, tab order breaking ties. The ranking is pure (rankTileSessions in constants.js) and reuses the home screens' classification (_mobileOverviewState) and stamps. It feeds the toggle's fresh set, a count picked in the menu, Ctrl/Cmd+click and an open split's fill. The active session is still always included and focused, detached sessions are never tiled, and a page without the classifier falls back to tab order. The vm harness now loads mobile-overview.js so the app tests exercise the real classifier. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 102 +++++++++++-- src/web/public/tile-grid.js | 51 ++++++- test/mocks/tile-grid-vm.ts | 5 +- test/tile-grid-open-set.test.ts | 17 ++- test/tile-grid-ranking.test.ts | 258 ++++++++++++++++++++++++++++++++ 5 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 test/tile-grid-ranking.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index ea38016b..971e3796 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2285,7 +2285,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) { /** * The sessions the Tiles button can open (case c of tileGridOpenSet, and the - * ones a count fills a grid with), in tab order: live ones only, never a session popped out to + * ones a count fills a grid with), in the order given (tab order, or the + * ranking): live ones only, never a session popped out to * its own window (that window owns its PTY size). A session with no PTY * attached IS offered: its tile shows the Attach overlay. * @@ -2299,29 +2300,105 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) { for (const id of sessionOrder) { if (detachedIds?.has?.(id)) continue; const session = sessions.get(id); - if (!session) continue; + if (!session || result.some((r) => r.id === id)) continue; result.push({ id, label: session.name || 'Session' }); } return result; } +// Ranking groups (rankTileSessions): working first, then the sessions waiting +// on the user, then everything else. +const TILE_RANK_GROUP = { working: 0, needs: 1, waiting: 1 }; + +/** + * The stamp a session is ranked by inside its group. A WORKING session keys + * off the pane's last Enter (`lastSubmitAt`) ONLY: a working pane repaints + * about once a second, so its last-activity stamp is always "now", and one + * that never submitted would otherwise claim the head of the group. 0 means + * unknown. Every other state is the home screens' anchor (sessionActivityAnchor: + * the last byte the pane printed, i.e. when it went quiet). + */ +function tileRankStamp(row) { + if (row.state === 'working') return Number(row.lastSubmitAt) || 0; + return sessionActivityAnchor(row); +} + +/** + * Which open sessions the tile grid shows when nobody said which (the Tiles + * button with no stored grid to bring back, and every place the grid fills a + * tile on its own: a count picked in its menu, a freed cell), best first + * (owner request: "prefer to load in tiles that are working and then the most + * recent, so the oldest don't get opened"): + * 1. WORKING, the most recently started turn first; + * 2. then the ones that NEED INPUT, the red and yellow tab alerts (`needs`: a + * permission or question dialog; `waiting`: a finished turn not seen + * yet), most recent first; + * 3. then every other one (idle, done, error), most recently active first. + * Inside a group the newest stamp wins (tileRankStamp) and a session with no + * stamp (0) sorts last; the final tiebreak is the tab order (`orderIndex`), so + * the result never shuffles. The states are the home screens' own + * (`_mobileOverviewState()`, mobile-overview.js), as are the stamps; only the + * order differs: the home screens put the longest-running turn first, the grid + * the most recent. + * + * Pure. Unit-tested in test/tile-grid-ranking.test.ts. + * + * @param {Array<{id: string, state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}>} rows + * @returns {string[]} the ids, best first, each once + */ +function rankTileSessions(rows) { + const group = (row) => TILE_RANK_GROUP[row.state] ?? 2; + const list = (Array.isArray(rows) ? rows : []).filter((row) => row && typeof row.id === 'string' && row.id); + list.sort((a, b) => { + const byGroup = group(a) - group(b); + if (byGroup !== 0) return byGroup; + const atA = tileRankStamp(a); + const atB = tileRankStamp(b); + if (atA !== atB) { + if (!atA) return 1; + if (!atB) return -1; + return atB - atA; + } + const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER; + const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + const ids = []; + for (const row of list) if (!ids.includes(row.id)) ids.push(row.id); + return ids; +} + /** * What the Tiles button and Ctrl+Shift+G open, at once and without asking * (owner decision 8). In order: * a. the grid this tab last had (`stored`, already sanitized: live, not * detached, at most the cap), if any of its sessions survive; * b. else an open split's two sessions, Pane A focused; - * c. else the open sessions in tab order (buildTilePickerSessions: no - * detached ones), up to `limit`, the active session always among them and focused - * (when it sits past the limit, the first `limit - 1` others come with it). + * c. else the open sessions in `ranked` order (rankTileSessions: working, + * then needing input, then the most recent; tab order when no ranking is + * given), detached ones never, up to `limit`, the active session always + * among them and focused (when it ranks past the limit, the first + * `limit - 1` others come with it). * Null when there is nothing to open. * * @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null, - * split?: string[]|null, sessions: Map, sessionOrder: string[], + * split?: string[]|null, ranked?: string[]|null, sessions: Map, sessionOrder: string[], * detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p - * @returns {{source: 'stored'|'split'|'tabs', ids: string[], focusedId: string|null}|null} + * @returns {{source: 'stored'|'split'|'ranked', ids: string[], focusedId: string|null}|null} */ -function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, detachedIds, activeId = null, limit }) { +function tileGridOpenSet({ + stored = null, + split = null, + ranked = null, + sessions, + sessionOrder, + detachedIds, + activeId = null, + limit, +}) { + const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map( + (c) => c.id + ); if (stored?.ids?.length) { const focus = stored.zoomed || stored.focused; return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] }; @@ -2330,13 +2407,12 @@ function tileGridOpenSet({ stored = null, split = null, sessions, sessionOrder, const pair = (split || []).filter(usable); if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] }; const max = Math.max(1, Math.min(Math.floor(Number(limit) || 0), TILE_GRID_MAX)); - const all = buildTilePickerSessions(sessions, sessionOrder, detachedIds).map((c) => c.id); if (all.length === 0) return null; let ids = all.slice(0, max); if (all.includes(activeId) && !ids.includes(activeId)) { ids = [...all.filter((id) => id !== activeId).slice(0, max - 1), activeId]; } - return { source: 'tabs', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] }; + return { source: 'ranked', ids, focusedId: ids.includes(activeId) ? activeId : ids[0] }; } /** @@ -2356,8 +2432,9 @@ function sanitizeTileCount(raw) { * `base` (what the grid would open, or what an open grid shows, in its order) * trimmed or filled to `n` tiles: trimmed from the end, the session to focus * (`keepId`) always kept (it takes the last place when it sat past `n`, as in - * tileGridOpenSet's case c); filled from `all` (the open sessions in tab order) - * with the ones not in it yet. Fewer sessions than `n` give fewer tiles. + * tileGridOpenSet's case c); filled from `all` (the open sessions, best first: + * the app passes the ranking, rankTileSessions) with the ones not in it yet. + * Fewer sessions than `n` give fewer tiles. * * @param {string[]} base * @param {string[]} all @@ -2866,6 +2943,7 @@ if (typeof window !== 'undefined') { fitTileCells, cycleTile, tileGridOpenSet, + rankTileSessions, sanitizeTileCount, tileGridSetForCount, tileCellCols, diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index dcb5e6da..02c4d5f4 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -1012,8 +1012,8 @@ Object.assign(CodemanApp.prototype, { } const T = window.CodemanTileGrid; const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity); - const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id); - this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId)); + // The tiles that join are the ranking's best (_tileGridRanking). + this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId)); }, /** @@ -1152,10 +1152,12 @@ Object.assign(CodemanApp.prototype, { * remembered count of tiles (the right-click menu's last pick, default 6, * at most what the window fits; owner decision 10), chosen by * `tileGridOpenSet` (constants.js): the grid this tab last had, else an open - * split's two sessions, else the open sessions in tab order, the active one - * focused; then trimmed or filled to the count (the focused one kept). A - * remembered grid comes back with its tiles in their cells, the ones the - * count adds filling its empty cells first (_openStoredTileGrid). + * split's two sessions, else the open sessions as the ranking orders them + * (_tileGridRanking: working, then needing input, then the most recent), the + * active one always among them and focused; then trimmed or filled (from the + * ranking) to the count, the focused one kept. A remembered grid comes back + * with its tiles in their cells, the ones the count adds filling its empty + * cells first (_openStoredTileGrid). */ toggleTileGrid() { this.closeTileCountMenu(); @@ -1182,9 +1184,11 @@ Object.assign(CodemanApp.prototype, { _tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) { const T = window.CodemanTileGrid; const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity)); + const ranked = this._tileGridRanking(); const set = T.tileGridOpenSet({ stored, split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null, + ranked, sessions: this.sessions, sessionOrder: this.sessionOrder, detachedIds: this.detachedSessions, @@ -1192,8 +1196,39 @@ Object.assign(CodemanApp.prototype, { limit: n, }); if (!set) return null; - const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id); - return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) }; + return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) }; + }, + + /** + * The open sessions the grid takes when nobody said which (the Tiles button + * with nothing stored, and every tile it fills on its own), best first: + * working (the most recently started turn first), then the ones needing + * input (the red and yellow tab alerts), then the rest by most recent + * activity, tab order breaking ties (rankTileSessions, constants.js). The + * states and stamps are the home screens' own (`_mobileOverviewState()`, + * mobile-overview.js). Detached sessions are never in it. Guarded like + * the sorted rail: without the classifier (a stale cached + * mobile-overview.js) it is plain tab order. + * + * @returns {string[]} + */ + _tileGridRanking() { + const T = window.CodemanTileGrid; + const open = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions); + if (typeof this._mobileOverviewState !== 'function' || typeof T.rankTileSessions !== 'function') { + return open.map((c) => c.id); + } + const rows = open.map(({ id }, orderIndex) => { + const session = this.sessions.get(id); + return { + id, + state: this._mobileOverviewState(session, this.pendingHooks?.get(id)), + lastActivityAt: Number(session.lastActivityAt) || 0, + lastSubmitAt: Number(session.lastSubmitAt) || 0, + orderIndex, + }; + }); + return T.rankTileSessions(rows); }, /** Alt+Shift+Arrows: a human selection of the tile in that direction. */ diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index d796b4a1..33999d0b 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -1,7 +1,8 @@ /** * @fileoverview A `vm` harness for the tile grid (tile-grid.js) with the real * app around it: constants.js + app.js + terminal-ui.js + terminal-split.js + - * tile-grid.js in one context, a small fake DOM (just what the grid touches) and a fake + * tile-grid.js + mobile-overview.js (the session state classifier the grid's + * ranking reuses) in one context, a small fake DOM (just what the grid touches) and a fake * TerminalTile that records what the grid asks of it. * * `makeGridApp()` returns an app instance with everything around the grid that @@ -295,7 +296,7 @@ const context = vm.createContext({ }); vm.runInContext( `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` + - `${read('tile-grid.js')}\n` + + `${read('tile-grid.js')}\n${read('mobile-overview.js')}\n` + 'globalThis.__CodemanApp = CodemanApp;', context ); diff --git a/test/tile-grid-open-set.test.ts b/test/tile-grid-open-set.test.ts index 35c2527c..8bb4b886 100644 --- a/test/tile-grid-open-set.test.ts +++ b/test/tile-grid-open-set.test.ts @@ -3,10 +3,11 @@ * * - `tileGridOpenSet` (constants.js, pure): what opens, in order: (a) the grid * this tab last had, if any of its sessions survive; (b) else an open - * split's two sessions; (c) else the open sessions in tab order up to the - * limit (never past the cap of 6), the active one always among them and - * focused. Detached sessions and ones that no longer exist are left out, as - * in the picker. + * split's two sessions; (c) else the open sessions in ranked order (tab + * order when no ranking is given, as here; the ranking itself is pinned in + * test/tile-grid-ranking.test.ts) up to the limit (never past the cap of + * 6), the active one always among them and focused. Detached sessions and + * ones that no longer exist are left out, as in the picker. * - The button's click and Ctrl+Shift+G are the same function * (`toggleTileGrid`); right-click (contextmenu) opens the count menu (owner * decision 10). With the grid open a pick re-forms it, the focused tile kept. @@ -59,9 +60,9 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => { }); }); - it('c: else the open sessions in tab order up to the limit, the active one focused', () => { + it('c: else the open sessions (no ranking given: tab order) up to the limit, the active one focused', () => { expect(T.tileGridOpenSet({ ...base, activeId: 't3' })).toEqual({ - source: 'tabs', + source: 'ranked', ids: order.slice(0, 6), focusedId: 't3', }); @@ -69,7 +70,7 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => { it('c: an active session past the limit still comes, with the first ones before it', () => { expect(T.tileGridOpenSet({ ...base, activeId: 't9' })).toEqual({ - source: 'tabs', + source: 'ranked', ids: ['t1', 't2', 't3', 't4', 't5', 't9'], focusedId: 't9', }); @@ -171,7 +172,7 @@ describe('opening at once, in the app', () => { app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); expect(app._tilesOwnTerminal()).toBe(true); pick(4); - // Its two first, then the open sessions in tab order. + // Its two first, then the ranking (every session quiet and unstamped here: tab order). expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']); expect(app.activeSessionId).toBe('s-b'); app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); diff --git a/test/tile-grid-ranking.test.ts b/test/tile-grid-ranking.test.ts new file mode 100644 index 00000000..e3b471f5 --- /dev/null +++ b/test/tile-grid-ranking.test.ts @@ -0,0 +1,258 @@ +/** + * @fileoverview Which sessions the tile grid takes when nobody said which + * (owner request: "prefer to load in tiles that are working and then the most + * recent, so the oldest don't get opened"). + * + * - `rankTileSessions` (constants.js, pure): WORKING first, the most recently + * started turn first (keyed off `lastSubmitAt` only: a working pane's + * last-activity stamp is always "now"); then the ones that NEED INPUT (the + * red and yellow tab alerts: `needs` and `waiting`), most recent first; then + * every other one, most recently active first. A missing stamp (0) sorts + * last in its group, tab order breaks ties, so the order never shuffles. + * - `tileGridOpenSet` case c opens the ranking's best up to the limit, the + * active session always among them and focused, detached ones never. + * - In the app (`_tileGridRanking`, the home screens' `_mobileOverviewState` + * and stamps): the Tiles button with nothing stored, a count picked in its + * menu, Ctrl/Cmd+click and an open split's fill all take the ranking; a stale + * page without the classifier falls back to tab order. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeEl, bySelector, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js'; + +type Row = { id: string; state: string; lastActivityAt?: number; lastSubmitAt?: number; orderIndex?: number }; +type OpenSet = { source: string; ids: string[]; focusedId: string | null } | null; +type Helpers = { + rankTileSessions(rows: unknown): string[]; + tileGridOpenSet(p: Record): OpenSet; + TILE_GRID_MAX: number; +}; + +const T = windowStub.CodemanTileGrid as Helpers; +const rank = (rows: Row[]) => T.rankTileSessions(rows.map((r, orderIndex) => ({ orderIndex, ...r }))); + +describe('rankTileSessions', () => { + it('working first, then needing input (red and yellow), then everything else', () => { + expect( + rank([ + { id: 'idle', state: 'idle', lastActivityAt: 900 }, + { id: 'done', state: 'done', lastActivityAt: 800 }, + { id: 'waiting', state: 'waiting', lastActivityAt: 100 }, + { id: 'error', state: 'error', lastActivityAt: 950 }, + { id: 'working', state: 'working', lastSubmitAt: 10 }, + { id: 'needs', state: 'needs', lastActivityAt: 50 }, + ]) + ).toEqual(['working', 'waiting', 'needs', 'error', 'idle', 'done']); + }); + + it('working: the most recently started turn first', () => { + expect( + rank([ + { id: 'old', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 9_000 }, + { id: 'new', state: 'working', lastSubmitAt: 5_000, lastActivityAt: 9_000 }, + { id: 'mid', state: 'working', lastSubmitAt: 3_000, lastActivityAt: 9_000 }, + ]) + ).toEqual(['new', 'mid', 'old']); + }); + + it('working keys off lastSubmitAt only: a pane that never submitted does not take the head on its repaint stamp', () => { + expect( + rank([ + { id: 'never-submitted', state: 'working', lastSubmitAt: 0, lastActivityAt: 99_999 }, + { id: 'submitted-long-ago', state: 'working', lastSubmitAt: 1, lastActivityAt: 99_998 }, + ]) + ).toEqual(['submitted-long-ago', 'never-submitted']); + }); + + it('needing input is one group, most recent first, whichever alert it raised', () => { + expect( + rank([ + { id: 'red-old', state: 'needs', lastActivityAt: 100 }, + { id: 'yellow-new', state: 'waiting', lastActivityAt: 300 }, + { id: 'red-new', state: 'needs', lastActivityAt: 200 }, + ]) + ).toEqual(['yellow-new', 'red-new', 'red-old']); + }); + + it('the rest: most recently active first, the oldest last', () => { + expect( + rank([ + { id: 'yesterday', state: 'idle', lastActivityAt: 1_000 }, + { id: 'just-now', state: 'done', lastActivityAt: 9_000 }, + { id: 'an-hour-ago', state: 'error', lastActivityAt: 5_000 }, + ]) + ).toEqual(['just-now', 'an-hour-ago', 'yesterday']); + }); + + it('no stamp sorts last in its group; equal stamps keep tab order, so it never shuffles', () => { + const rows: Row[] = [ + { id: 'a', state: 'idle', orderIndex: 0 }, + { id: 'b', state: 'idle', lastActivityAt: 10, orderIndex: 1 }, + { id: 'c', state: 'idle', orderIndex: 2 }, + { id: 'd', state: 'idle', lastActivityAt: 10, orderIndex: 3 }, + { id: 'w', state: 'working', orderIndex: 4 }, + { id: 'v', state: 'working', lastSubmitAt: 5, orderIndex: 5 }, + ]; + const expected = ['v', 'w', 'b', 'd', 'a', 'c']; + expect(T.rankTileSessions(rows)).toEqual(expected); + expect(T.rankTileSessions([...rows].reverse())).toEqual(expected); + }); + + it('an unknown state counts as "everything else"', () => { + expect( + rank([ + { id: 'odd', state: 'mystery', lastActivityAt: 9 }, + { id: 'idle', state: 'idle', lastActivityAt: 5 }, + { id: 'busy', state: 'working', lastSubmitAt: 1 }, + ]) + ).toEqual(['busy', 'odd', 'idle']); + }); + + it('drops malformed rows and repeats, and never sorts its input in place', () => { + const rows = [ + { id: 'b', state: 'idle', lastActivityAt: 1, orderIndex: 1 }, + null, + { id: '', state: 'working' }, + { id: 'a', state: 'working', lastSubmitAt: 1, orderIndex: 0 }, + { id: 'b', state: 'idle', lastActivityAt: 1, orderIndex: 2 }, + ]; + const copy = JSON.stringify(rows); + expect(T.rankTileSessions(rows)).toEqual(['a', 'b']); + expect(JSON.stringify(rows)).toBe(copy); + expect(T.rankTileSessions(null)).toEqual([]); + }); +}); + +describe('tileGridOpenSet with a ranking (case c)', () => { + const order = ['t1', 't2', 't3', 't4', 't5', 't6', 't7', 't8']; + const sessions = new Map(order.map((id) => [id, { id }])); + const ranked = ['t8', 't6', 't1', 't3', 't7', 't2', 't5', 't4']; + const base = { sessions, sessionOrder: order, ranked, limit: 4 }; + + it('opens the best of the ranking, not the first tabs, the active one focused', () => { + expect(T.tileGridOpenSet({ ...base, activeId: 't6' })).toEqual({ + source: 'ranked', + ids: ['t8', 't6', 't1', 't3'], + focusedId: 't6', + }); + }); + + it('an active session the ranking puts past the limit still comes, in the last place', () => { + expect(T.tileGridOpenSet({ ...base, activeId: 't4' })?.ids).toEqual(['t8', 't6', 't1', 't4']); + }); + + it('never a detached session or one that no longer exists', () => { + const out = T.tileGridOpenSet({ + ...base, + ranked: ['gone', ...ranked], + detachedIds: new Set(['t6']), + activeId: null, + }); + expect(out?.ids).toEqual(['t8', 't1', 't3', 't7']); + expect(out?.focusedId).toBe('t8'); + }); + + it('without a ranking it is tab order, as before', () => { + expect(T.tileGridOpenSet({ sessions, sessionOrder: order, limit: 3, activeId: null })?.ids).toEqual([ + 't1', + 't2', + 't3', + ]); + }); +}); + +describe('in the app', () => { + // Eight sessions, tab order s-1 .. s-8 (plus the harness's s-other, first and quiet). + const IDS = ['s-1', 's-2', 's-3', 's-4', 's-5', 's-6', 's-7', 's-8']; + + /** Statuses, hooks and stamps: two working, one red, one yellow, four quiet. */ + function rankedApp(): GridApp { + const app = makeGridApp(IDS); + const set = (id: string, fields: Record) => Object.assign(app.sessions.get(id), fields); + set('s-other', { status: 'idle', lastActivityAt: 1_000 }); + set('s-1', { status: 'idle', lastActivityAt: 2_000 }); // the oldest quiet one + set('s-2', { status: 'busy', lastSubmitAt: 5_000, lastActivityAt: 99_000 }); // working, older turn + set('s-3', { status: 'idle', lastActivityAt: 8_000 }); + set('s-4', { status: 'idle', lastActivityAt: 7_000 }); // red: a permission dialog + set('s-5', { status: 'stopped', lastActivityAt: 3_000 }); + set('s-6', { status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 99_000 }); // working, newest turn + set('s-7', { status: 'idle', lastActivityAt: 6_000 }); // yellow: a finished turn not seen + set('s-8', { status: 'idle', lastActivityAt: 9_500 }); + app.pendingHooks.set('s-4', new Set(['permission_prompt'])); + app.pendingHooks.set('s-7', new Set(['idle_prompt'])); + app.activeSessionId = null; + return app; + } + + beforeEach(() => { + resetGridHarness(); + bySelector.set('.terminal-wrap', new FakeEl()); + }); + + it('ranks with the home screens’ classification and stamps', () => { + expect(rankedApp()._tileGridRanking()).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-3', 's-5', 's-1', 's-other']); + }); + + it('the Tiles button with nothing stored opens working, then needing input, then the most recent; the oldest stay out', () => { + const app = rankedApp(); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-3']); + expect(app._tileGrid.ids).not.toContain('s-1'); + expect(app._tileGrid.ids).not.toContain('s-other'); + // No active session: the best ranked takes focus. + expect(app._tileGrid.focusedId).toBe('s-6'); + }); + + it('the active session always comes and takes focus, even ranked last', () => { + const app = rankedApp(); + app.activeSessionId = 's-1'; + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-7', 's-8', 's-1']); + expect(app._tileGrid.focusedId).toBe('s-1'); + }); + + it('the remembered count takes that many from the top; a detached session is never tiled', () => { + const app = rankedApp(); + app.detachedSessions.add('s-2'); + app._rememberTileGridCount(4); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-6', 's-4', 's-7', 's-8']); + }); + + it('a count picked in the menu fills an open grid from the ranking', () => { + const app = rankedApp(); + app.openTileGrid(['s-1', 's-5'], { focusedId: 's-5' }); + app._pickTileCount(4); + expect(app._tileGrid.cells).toEqual(['s-1', 's-5', 's-6', 's-2']); + expect(app._tileGrid.focusedId).toBe('s-5'); + }); + + it('Ctrl/Cmd+click with nothing stored: the clicked one plus the best of the ranking, the count in total', () => { + const app = rankedApp(); + app._rememberTileGridCount(4); + app.addSessionToTiles('s-5'); + expect(app._tileGrid.ids).toEqual(['s-6', 's-2', 's-4', 's-5']); + expect(app._tileGrid.focusedId).toBe('s-5'); + }); + + it('an open split comes first (case b), the rest from the ranking', () => { + const app = rankedApp(); + app.activeSessionId = 's-1'; + app._splitPane = {}; + app._splitSessionId = 's-5'; + app.closeSplitPane = vi.fn(() => { + app._splitPane = null; + }); + app._rememberTileGridCount(4); + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(['s-1', 's-5', 's-6', 's-2']); + expect(app._tileGrid.focusedId).toBe('s-1'); + }); + + it('a page without the classifier (a stale cached mobile-overview.js) falls back to tab order', () => { + const app = rankedApp(); + app._mobileOverviewState = undefined; + expect(app._tileGridRanking()).toEqual(['s-other', ...IDS]); + }); +}); From 16980d644d94d20c141390b7cad1d1e23c5b5f13 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 05:58:17 +0200 Subject: [PATCH 2/8] feat(tiles): keep the arranged layout per browser across toggles and reloads The Tiles toggle used to fill a remembered grid to the count picked in the menu (refilling a hole the user made, or trimming tiles), removing the last tile or "Open group as tiles" forgot it, and Ctrl/Cmd+click repacked it. The owner asked for the opposite: "when I turn tiles off and on, always keep what the last setting was". Now the layout the user arranged (which session sits in which cell, holes included, the tile count, the divider sizes, the focused tile and a zoom they chose) is written to localStorage on every change and comes back exactly from the toggle, Ctrl+Shift+G and a reload, however the grid was closed (toggle, a non-tiled tab, leaveTiles or a #session= link, Home, the width gate, the last tile, a group, closing or killing sessions). Nothing of it reaches the server. - The stored value stays v: 1 and gains `count`: how many tiles the user's own last change left. A session that goes away by itself (deleted, popped out, socket refused) does not lower it, so the next activation fills that cell from the ranking; a hole the user made stays a hole. - sanitizeTileGridState reports `freed` cells (sessions gone since) and derives `count` for values written before it; restoreTileGridCells (pure) puts a stored grid back, freed cells first, never trimmed to the window (a too-small window shows the focused tile until it fits). - Only when none of the stored sessions survive does activation rank from scratch. A count picked in the menu still re-forms the grid to it. - Nothing is persisted while a stored grid is being put back, and a #session= link only flips `open`, keeping gone ids for the fill. - The hover card no longer promises "a click opens N" when a stored grid will open instead. This supersedes decision 10's answer that the count wins over a remembered grid's size; the affected tests say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 6 +- src/web/public/constants.js | 104 +++- src/web/public/tile-grid.js | 305 ++++++++---- test/tile-grid-cells.test.ts | 30 +- test/tile-grid-count-menu.test.ts | 12 +- test/tile-grid-layout-memory.test.ts | 576 +++++++++++++++++++++++ test/tile-grid-layout.test.ts | 3 + test/tile-grid-open-set.test.ts | 1 + test/tile-grid-restore.test.ts | 58 ++- test/tile-grid-shortcuts.test.ts | 9 +- test/tile-grid-split-coexistence.test.ts | 5 +- 11 files changed, 948 insertions(+), 161 deletions(-) create mode 100644 test/tile-grid-layout-memory.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index 6e896112..075664b7 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -10065,8 +10065,10 @@ class CodemanApp { try { await this._apiDelete('/api/sessions'); - // Every tiled session is gone: nothing left to remember or reselect. - this.closeTileGrid?.({ keepStored: false, reselect: false }); + // Every tiled session is gone: nothing to reselect. The stored grid is + // kept like every other close; it now names only gone sessions, so the + // next Tiles click ranks the open sessions from scratch. + this.closeTileGrid?.({ keepStored: true, reselect: false }); this.sessions.clear(); this.terminalBuffers.clear(); this.terminalBufferCache.clear(); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 971e3796..8a1d500f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2202,11 +2202,11 @@ function tileGridCapacity({ width, height }) { } /** - * The stored grid (`codeman:tile-grid`, ids only) made safe to apply: unknown, - * deleted, detached and duplicate ids are dropped, the list is capped at - * TILE_GRID_MAX, `focused` / `zoomed` must name a kept id, and track fractions - * must be 1 to 3 finite positive numbers. Anything that is not a v1 object - * (or its JSON) gives null. + * The stored grid (`codeman:tile-grid`: session ids and the layout, never + * content) made safe to apply: unknown, deleted, detached and duplicate ids + * are dropped, the list is capped at TILE_GRID_MAX, `focused` / `zoomed` must + * name a kept id, and track fractions must be 1 to 3 finite positive numbers. + * Anything that is not a v1 object (or its JSON) gives null. * * The stored `ids` are the grid's CELLS in reading order, `null` for an empty * one (a hole can be any cell). The old packed list (no nulls) reads as cells @@ -2214,11 +2214,20 @@ function tileGridCapacity({ width, height }) { * every list consumer wants) and `cells` keeps the holes: a dropped id (gone, * detached, a duplicate, past the cap) becomes `null` there, never a shift. * + * `freed` names the cells whose session no longer exists (or was popped out + * to its own window) since the grid was stored: the ranking fills those first + * when the grid comes back (restoreTileGridCells). A hole the user left empty + * is not freed. `count` is how many tiles the grid had after the user's own + * last change (a session that went away by itself does not lower it), so the + * grid comes back to that many when there are sessions to fill it; a value + * stored before it existed, or a malformed one, reads as the number of + * sessions the stored cells name. + * * @param {unknown} raw - the parsed value, or the stored JSON string * @param {{has(id: string): boolean}|Iterable} liveSessions - ids that exist now * @param {{has(id: string): boolean}} [detachedIds] - sessions popped out to their own window - * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], focused: string|null, - * zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null} + * @returns {{v: 1, open: boolean, ids: string[], cells: (string|null)[], freed: number[], count: number, + * focused: string|null, zoomed: string|null, colFr: number[]|null, rowFr: number[]|null}|null} */ function sanitizeTileGridState(raw, liveSessions, detachedIds) { let value = raw; @@ -2229,11 +2238,16 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) { const live = liveSessions && typeof liveSessions.has === 'function' ? liveSessions : new Set(liveSessions || []); const ids = []; const cells = []; + const freed = []; + const named = []; for (const id of (Array.isArray(value.ids) ? value.ids : []).slice(0, TILE_LAYOUT_MAX)) { - const keep = - typeof id === 'string' && id && !ids.includes(id) && live.has(id) && !detachedIds?.has?.(id) && - ids.length < TILE_GRID_MAX; + const isId = typeof id === 'string' && id !== ''; + const present = isId && live.has(id) && !detachedIds?.has?.(id); + const keep = present && !ids.includes(id) && ids.length < TILE_GRID_MAX; if (keep) ids.push(id); + // Its session went away since: the cell is freed for the ranking to fill. + if (isId && !present && !named.includes(id)) freed.push(cells.length); + if (isId && !named.includes(id)) named.push(id); // A malformed entry (not a string, not null) is a hole too. cells.push(keep ? id : null); } @@ -2241,11 +2255,17 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) { if (!Array.isArray(fr) || fr.length < 1 || fr.length > 3) return null; return fr.every((x) => typeof x === 'number' && Number.isFinite(x) && x > 0) ? fr.slice() : null; }; + const count = + Number.isInteger(value.count) && value.count >= 1 && value.count <= TILE_GRID_MAX + ? value.count + : Math.min(named.length, TILE_GRID_MAX); return { v: 1, open: value.open === true && ids.length > 0, ids, cells, + freed, + count, focused: ids.includes(value.focused) ? value.focused : (ids[0] ?? null), zoomed: ids.includes(value.zoomed) ? value.zoomed : null, colFr: fractions(value.colFr), @@ -2253,6 +2273,53 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) { }; } +/** + * A stored grid as it comes back (the Tiles button, a page reload): its cells + * exactly as stored, holes the user left included, and its focus (or the tile + * it had zoomed). Only when it holds fewer tiles than its `count` (sessions + * that went away since, or by themselves while it was open) does it fill, from + * `ranked` (best first, never a session already in it): the freed cells first, + * then the other empty cells, in reading order; more than the cells hold join + * after them (the shape grows when the grid lays them out, reformTileCells). + * A cell stays empty only when no other session is left to place. Never + * trimmed to the window: a grid larger than the window fits shows its focused + * tile alone until the window fits it again, and the arrangement stays. + * + * @param {{cells?: (string|null)[], ids?: string[], freed?: number[], count?: number, + * focused?: string|null, zoomed?: string|null}|null} stored - sanitized (sanitizeTileGridState) + * @param {string[]} ranked - the sessions that may fill a cell, best first (rankTileSessions) + * @returns {{ids: string[], cells: (string|null)[], focusedId: string}|null} null when none of its sessions survive + */ +function restoreTileGridCells(stored, ranked) { + const source = Array.isArray(stored?.cells) ? stored.cells : Array.isArray(stored?.ids) ? stored.ids : []; + const cells = []; + for (const id of source) cells.push(typeof id === 'string' && id && !cells.includes(id) ? id : null); + const tiles = cells.filter(Boolean); + if (tiles.length === 0) return null; + const focusedId = + [stored.zoomed, stored.focused].find((id) => typeof id === 'string' && tiles.includes(id)) ?? tiles[0]; + const target = Math.min(Math.max(tiles.length, Math.floor(Number(stored.count)) || 0), TILE_GRID_MAX); + const fillers = []; + for (const id of ranked || []) { + if (typeof id === 'string' && id && !cells.includes(id) && !fillers.includes(id)) fillers.push(id); + } + const freed = new Set(Array.isArray(stored.freed) ? stored.freed : []); + const empty = []; + cells.forEach((id, k) => { + if (id === null) empty.push(k); + }); + // Freed cells first, each group in reading order. + empty.sort((a, b) => Number(freed.has(b)) - Number(freed.has(a)) || a - b); + let count = tiles.length; + for (const k of empty) { + if (count >= target || fillers.length === 0) break; + cells[k] = fillers.shift(); + count++; + } + const extra = fillers.slice(0, Math.max(0, target - count)); + return { ids: [...cells.filter(Boolean), ...extra], cells, focusedId }; +} + /** * New track fractions after a divider drag (grid-template `fr` values): the two * tracks either side of divider `index` trade `deltaPx` of size, each kept at @@ -2372,7 +2439,9 @@ function rankTileSessions(rows) { * What the Tiles button and Ctrl+Shift+G open, at once and without asking * (owner decision 8). In order: * a. the grid this tab last had (`stored`, already sanitized: live, not - * detached, at most the cap), if any of its sessions survive; + * detached, at most the cap), if any of its sessions survive, exactly + * as it was (restoreTileGridCells: its cells and holes, a cell its + * session freed filled from the ranking); * b. else an open split's two sessions, Pane A focused; * c. else the open sessions in `ranked` order (rankTileSessions: working, * then needing input, then the most recent; tab order when no ranking is @@ -2381,10 +2450,12 @@ function rankTileSessions(rows) { * `limit - 1` others come with it). * Null when there is nothing to open. * - * @param {{stored?: {ids: string[], focused: string|null, zoomed: string|null}|null, + * @param {{stored?: {ids: string[], cells?: (string|null)[], freed?: number[], count?: number, + * focused: string|null, zoomed: string|null}|null, * split?: string[]|null, ranked?: string[]|null, sessions: Map, sessionOrder: string[], * detachedIds?: {has(id: string): boolean}, activeId?: string|null, limit: number}} p - * @returns {{source: 'stored'|'split'|'ranked', ids: string[], focusedId: string|null}|null} + * @returns {{source: 'stored'|'split'|'ranked', ids: string[], cells?: (string|null)[], + * focusedId: string|null}|null} */ function tileGridOpenSet({ stored = null, @@ -2399,10 +2470,8 @@ function tileGridOpenSet({ const all = buildTilePickerSessions(sessions, Array.isArray(ranked) ? ranked : sessionOrder, detachedIds).map( (c) => c.id ); - if (stored?.ids?.length) { - const focus = stored.zoomed || stored.focused; - return { source: 'stored', ids: stored.ids.slice(), focusedId: stored.ids.includes(focus) ? focus : stored.ids[0] }; - } + const restored = stored ? restoreTileGridCells(stored, all) : null; + if (restored) return { source: 'stored', ...restored }; const usable = (id) => typeof id === 'string' && sessions.has(id) && !detachedIds?.has?.(id); const pair = (split || []).filter(usable); if (split && pair.length) return { source: 'split', ids: [...new Set(pair)], focusedId: pair[0] }; @@ -2943,6 +3012,7 @@ if (typeof window !== 'undefined') { fitTileCells, cycleTile, tileGridOpenSet, + restoreTileGridCells, rankTileSessions, sanitizeTileCount, tileGridSetForCount, diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 02c4d5f4..090b3061 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -23,14 +23,18 @@ // 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 }`, `ids` being the cells -// in reading order with `null` for an empty one. `open: false` keeps it -// remembered for one-click return; restored on reload inside handleInit. +// The grid this browser last had, as the user left it (sanitizeTileGridState, +// constants.js): `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, +// `ids` being the cells in reading order with `null` for an empty one, `count` +// how many tiles the user's own last change left (a session that went away by +// itself does not lower it). Session ids and layout only, never content. +// Written on every change while the grid is open; `open: false` keeps it for +// the Tiles toggle, which brings it back exactly; restored on reload inside +// handleInit. Never sent to the server. const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; -// How many tiles a click on Tiles opens: the count last picked in its -// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so -// the stored grid above stays ids only. +// The count last picked in the Tiles button's right-click menu (2, 4 or 6; +// owner decision 10), per device: what a click opens when there is no stored +// grid to bring back, and what a pick re-forms the grid to. const TILE_GRID_COUNT_KEY = 'codeman:tile-count'; // The leaving tiles' fade (styles.css .tile--leaving) plus slack: the still // copy of a closing grid goes even if animationend never comes (a hidden tab, @@ -147,6 +151,11 @@ class TileGridModel { // session id, or null for an empty cell. THE source of truth for where // each tile is (owner: an empty cell can be any cell); `ids` derives from it. this.cells = []; + // How many tiles the user's own last change left (open, add, remove, a + // count picked): a session that goes away by itself (deleted, popped out, + // refused) does not lower it, so the next time the grid opens the ranking + // fills that place. Stored with the grid (_persistTileGrid). + this.count = 0; // id -> { tile: TerminalTile, el: HTMLElement } this.tiles = new Map(); this.focusedId = null; @@ -396,6 +405,9 @@ Object.assign(CodemanApp.prototype, { wanted.forEach((id, k) => this._mountTile(id, { enterIndex: k })); // Packed from the first cell (_applyTileLayout pads the shape with empty cells). grid.cells = wanted.filter((id) => grid.tiles.has(id)); + // What the user opened is the grid they want (_openStoredTileGrid keeps a + // stored count instead). + grid.count = grid.ids.length; this._applyTileLayout(); // The tiles' frames paint first; their terminals are built one per frame // after it, the focused tile's first, so its capture is the one the queue @@ -419,11 +431,13 @@ Object.assign(CodemanApp.prototype, { * 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. `animate` - * (the Tiles toggle only, owner answer 4) leaves a still copy of the tiles - * over the stage until the single view has its content (_ghostTileGrid). + * `keepStored` (every caller) keeps the grid as it was, holes, count, sizes, + * focus and zoom, for the Tiles toggle and the next reload to bring back; + * false would forget it. `reselect` shows the focused session in the single + * view through a forced reload; pass false when the caller selects + * something itself. `animate` (the Tiles toggle only, owner answer 4) + * leaves a still copy of the tiles over the stage until the single view has + * its content (_ghostTileGrid). * * 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 @@ -464,6 +478,7 @@ Object.assign(CodemanApp.prototype, { grid.colFr = []; grid.rowFr = []; grid.cells = []; + grid.count = 0; grid.cols = 0; grid.rows = 0; grid.focusedId = null; @@ -717,8 +732,11 @@ Object.assign(CodemanApp.prototype, { /** * The card's text, from the remembered count, the grid's state and what the - * window fits. Compared with the last English text set, never the DOM (in - * zh-CN the DOM holds the translation; see _renderTileOverlay). + * window fits. With the grid closed and a stored grid to bring back, the + * click opens that grid as it was (never trimmed to the window), so the + * window line only says what fits. Compared with the last English text set, + * never the DOM (in zh-CN the DOM holds the translation; see + * _renderTileOverlay). */ _renderTileHint() { const hint = this._tileHint; @@ -734,10 +752,12 @@ Object.assign(CodemanApp.prototype, { set(hint.title, 'titleText', `Tiles \u00B7 ${count}`); set(hint.click, 'clickText', open ? 'Click: close the grid' : 'Click: open the grid'); const plural = capacity === 1 ? '' : 's'; + const restoring = open ? null : this._storedTileGridSet(); + const opens = restoring ? restoring.ids.length : count; const fitsText = - count <= capacity + opens <= capacity ? '' - : open + : open || restoring ? `This window fits ${capacity} tile${plural}` : `This window fits ${capacity} tile${plural}: a click opens ${capacity}`; set(hint.fits.text, 'fitsText', fitsText); @@ -998,22 +1018,25 @@ Object.assign(CodemanApp.prototype, { /** * A count picked in the menu: remembered for the click, then the grid opens - * with that many tiles (what the click opens), or the open grid is re-formed - * to it (_reformTileGrid). Never more than the window fits; fewer open - * sessions than the count give fewer tiles. + * with that many tiles (a stored grid re-formed to it, its tiles first in + * their cells), or the open grid is re-formed to it (_reformTileGrid). + * Never more than the window fits; fewer open sessions than the count give + * fewer tiles. Either way the grid's count is what the pick left. */ _pickTileCount(count) { this.closeTileCountMenu({ refocus: false }); this._rememberTileGridCount(count); const grid = this._tileGrid; if (!grid?.open) { - this.toggleTileGrid(); + this._activateTileGrid({ count }); return; } const T = window.CodemanTileGrid; const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity); // The tiles that join are the ranking's best (_tileGridRanking). this._reformTileGrid(T.tileGridSetForCount(grid.ids, this._tileGridRanking(), n, grid.focusedId)); + grid.count = grid.ids.length; + this._persistTileGrid(); }, /** @@ -1148,16 +1171,18 @@ Object.assign(CodemanApp.prototype, { /** * The Tiles button's click and Ctrl+Shift+G, one function so the two never * drift (owner decision 8): opens the grid at once, no menu in the way, or - * closes it to the single view of the focused session. What opens is the - * remembered count of tiles (the right-click menu's last pick, default 6, - * at most what the window fits; owner decision 10), chosen by - * `tileGridOpenSet` (constants.js): the grid this tab last had, else an open - * split's two sessions, else the open sessions as the ranking orders them - * (_tileGridRanking: working, then needing input, then the most recent), the - * active one always among them and focused; then trimmed or filled (from the - * ranking) to the count, the focused one kept. A remembered grid comes back - * with its tiles in their cells, the ones the count adds filling its empty - * cells first (_openStoredTileGrid). + * closes it to the single view of the focused session. The grid this + * browser last had comes back EXACTLY as the user left it (owner request: + * "when I turn tiles off and on, always keep what the last setting was"): + * its tiles in their cells, holes included, its count, divider sizes, focus + * and zoom; a cell whose session no longer exists is filled from the + * ranking (_openStoredTileGrid). With none of its sessions left (or nothing + * stored), `tileGridOpenSet` (constants.js) takes an open split's two + * sessions, else the open sessions as the ranking orders them + * (_tileGridRanking: working, then needing input, then the most recent), + * the active one always among them and focused, filled to the remembered + * count (the right-click menu's last pick, default 6, at most what the + * window fits; owner decision 10). */ toggleTileGrid() { this.closeTileCountMenu(); @@ -1165,25 +1190,38 @@ Object.assign(CodemanApp.prototype, { this.closeTileGrid({ keepStored: true, reselect: true, animate: true }); return; } - if (!this.canOpenTileGrid()) return; - const stored = this._readStoredTileGrid(); - const set = this._tileGridOpenSet(stored); - if (!set) { - this.showToast?.('No sessions to show as tiles', 'info'); - return; - } - if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids); - else this.openTileGrid(set.ids, { focusedId: set.focusedId }); + this._activateTileGrid(); }, /** - * What the toggle would open now (see toggleTileGrid): `count` tiles (the - * remembered count), or as many as the window fits; `stored` saves a - * second read. + * Opens the grid as the toggle does (toggleTileGrid), or, with `count` (a + * pick in the count menu while the grid is closed), with that many tiles: a + * stored grid re-formed to it, its tiles first in their cells, the ones the + * count adds filling its empty cells first, the rest from the ranking. + * + * @returns {boolean} whether the grid opened */ - _tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) { + _activateTileGrid({ count = null } = {}) { + if (!this.canOpenTileGrid()) return false; + const stored = this._readStoredTileGrid(); + const set = this._tileGridOpenSet(stored, count); + if (!set) { + this.showToast?.('No sessions to show as tiles', 'info'); + return false; + } + if (set.source === 'stored') return this._openStoredTileGrid(stored, set, { keepCount: count === null }); + return this.openTileGrid(set.ids, { focusedId: set.focusedId }); + }, + + /** + * What the toggle would open now (see toggleTileGrid): a stored grid as it + * was, or `count` tiles (the remembered count by default), at most what the + * window fits. With an explicit `count`, a stored grid is trimmed or filled + * to it too. `stored` saves a second read. + */ + _tileGridOpenSet(stored = this._readStoredTileGrid(), count = null) { const T = window.CodemanTileGrid; - const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity)); + const n = Math.max(1, Math.min(count ?? this._tileGridCount(), this._tileGridLimit().capacity)); const ranked = this._tileGridRanking(); const set = T.tileGridOpenSet({ stored, @@ -1196,6 +1234,9 @@ Object.assign(CodemanApp.prototype, { limit: n, }); if (!set) return null; + // A stored grid comes back as it was: the count does not apply to it + // unless one was asked for. + if (set.source === 'stored' && count === null) return set; return { ...set, ids: T.tileGridSetForCount(set.ids, ranked, n, set.focusedId) }; }, @@ -1299,6 +1340,7 @@ Object.assign(CodemanApp.prototype, { if (grid.ids.length >= window.CodemanTileGrid.TILE_GRID_MAX) return false; if (!this._mountTile(sessionId, { enterIndex })) return false; this._placeTile(sessionId, cell); + grid.count = grid.ids.length; // A tile added while one is zoomed by hand is meant to be seen. if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null; this._applyTileLayout(); @@ -1343,17 +1385,20 @@ Object.assign(CodemanApp.prototype, { * moves focus to the neighbouring tile (next in grid order, else previous), * as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps * DOM focus where it is (an app-driven removal: a socket the server closed), - * so keystrokes never land in the neighbour's PTY unasked. The last tile - * leaving closes the grid: with `refocus` the single view then shows that - * session (or, popped out, the next one: _selectAfterTileGrid), without it - * the caller decides what comes next. + * so keystrokes never land in the neighbour's PTY unasked. `gone`: the + * session went away by itself (deleted, popped out, its socket refused), not + * by the user's hand, so the grid's count stays and the next time it opens + * the ranking fills that place. The last tile leaving closes the grid, kept + * as it was: with `refocus` the single view then shows that session (or, + * popped out, the next one: _selectAfterTileGrid), without it the caller + * decides what comes next. */ - removeTile(sessionId, { refocus = true, focus = true } = {}) { + removeTile(sessionId, { refocus = true, focus = true, gone = false } = {}) { 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 }); + this.closeTileGrid({ keepStored: true, reselect: refocus }); return true; } const wasFocused = grid.focusedId === sessionId; @@ -1369,6 +1414,7 @@ Object.assign(CodemanApp.prototype, { entry.el.remove(); grid.tiles.delete(sessionId); grid.cells[grid.cells.indexOf(sessionId)] = null; + if (!gone) grid.count = grid.ids.length; // Before the layout, which may zoom the focused tile on a small window. if (wasFocused) grid.focusedId = null; this._applyTileLayout(); @@ -1675,10 +1721,12 @@ Object.assign(CodemanApp.prototype, { * Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a * human selection: the user clicked its tab). With the grid closed it opens * what the Tiles toggle would, with this session among the tiles and - * focused: the remembered count in total (owner answer to decision 10's - * questions), never one more. Returns false - * when the grid cannot open here (narrow or solo window), so the click is an - * ordinary one. + * focused, never past the remembered count (owner answer to decision 10's + * questions: N, not N+1): it joins while the grid holds fewer than the + * count (a stored grid's first empty cell), else it takes the last tile's + * place. A stored grid keeps its cells, sizes and holes around it. + * Returns false when the grid cannot open here (narrow or solo window), so + * the click is an ordinary one. */ addSessionToTiles(sessionId) { if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) { @@ -1699,7 +1747,26 @@ Object.assign(CodemanApp.prototype, { return true; } const n = Math.max(1, Math.min(this._tileGridCount(), capacity)); - const base = this._tileGridOpenSet()?.ids || []; + const stored = this._readStoredTileGrid(); + const plan = this._tileGridOpenSet(stored); + if (plan?.source === 'stored') { + let { ids, cells } = plan; + if (!ids.includes(sessionId)) { + if (ids.length < n) { + ids = [...ids, sessionId]; + } else { + const last = ids.at(-1); + ids = ids.map((id) => (id === last ? sessionId : id)); + cells = cells.map((id) => (id === last ? sessionId : id)); + } + } + return this._openStoredTileGrid( + stored, + { ids, cells, focusedId: sessionId }, + { focusedId: sessionId, auto: false, keepCount: false } + ); + } + const base = plan?.ids || []; const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId]; // Exactly these: an open split is already in `base` (tileGridOpenSet seeds // it), and merging it again went past the count (N+1) and the window. @@ -1742,7 +1809,8 @@ Object.assign(CodemanApp.prototype, { _replaceTileGrid(ids) { const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0]; if (this._tilesOwnTerminal()) { - this.closeTileGrid({ keepStored: false, reselect: false }); + // Kept until the group's grid, opened next, takes its place. + this.closeTileGrid({ keepStored: true, reselect: false }); this.activeSessionId = null; } // Exactly the group: an open split closes without joining it (merged, its @@ -2227,7 +2295,7 @@ Object.assign(CodemanApp.prototype, { this._renderTileOverlay(sessionId); return; } - this.removeTile(sessionId, { focus: false }); + this.removeTile(sessionId, { focus: false, gone: true }); }, /** @@ -2581,17 +2649,20 @@ Object.assign(CodemanApp.prototype, { this._persistTileGrid(); }, - // ── Persistence (codeman:tile-grid, per device, ids only) ──────────────── + // ── Persistence (codeman:tile-grid, per browser: ids and layout, never content) ── /** - * 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. + * Writes the open grid as it is, on every change (a move, a divider drag, a + * tile added or removed, a count picked, a focus, a zoom): its cells, its + * count, focus, a zoom the user chose (an automatic one is worked out again + * from the window) and the divider fractions. Never content, never to the + * server. `open: false` is the closed-but-remembered state. Never in a solo + * window, nor while a stored grid is being put back (_openStoredTileGrid + * writes once it is); a storage failure only costs the convenience. */ _persistTileGrid({ open = true } = {}) { const grid = this._tileGrid; - if (this.isSoloWindow || !grid || grid.ids.length === 0) return; + if (this.isSoloWindow || this._tilePersistHold || !grid || grid.ids.length === 0) return; if (open && !grid.open) return; const state = { v: 1, @@ -2600,6 +2671,9 @@ Object.assign(CodemanApp.prototype, { // (sanitizeTileGridState; a build before cells drops the nulls and reads // the tiles packed, as it always did). ids: grid.cells.slice(), + // Never fewer than the tiles shown (a build before `count` derives it + // from `ids`, and ignores it). + count: Math.min(Math.max(grid.count || 0, grid.ids.length), window.CodemanTileGrid.TILE_GRID_MAX), focused: grid.focusedId, zoomed: grid.autoZoom ? null : grid.zoomedId, colFr: grid.colFr.slice(), @@ -2634,47 +2708,74 @@ Object.assign(CodemanApp.prototype, { }, /** - * 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. `ids` is the set to open, - * the stored one by default (a reload); the Tiles button passes it trimmed - * or filled to the remembered count, a shape change the cell model's rule - * handles (reformTileCells). + * The stored grid as it would come back now (restoreTileGridCells): its + * cells as stored, a cell whose session no longer exists filled from the + * ranking while the grid holds fewer than its count. Null when nothing is + * stored or none of its sessions survive. */ - _openStoredTileGrid(stored, ids = stored.ids) { - const focus = stored.zoomed || stored.focused; - // The stored set (an open split closes without joining it, decision 8, - // case a), trimmed or filled to the count by the caller. - if (!this.openTileGrid(ids, { focusedId: focus, auto: true, mergeSplit: false })) return false; - const grid = this._tileGrid; + _storedTileGridSet(stored = this._readStoredTileGrid()) { + if (!stored) return null; + return window.CodemanTileGrid.restoreTileGridCells(stored, this._tileGridRanking()); + }, + + /** + * Opens a stored grid as the user left it: `set` (the stored cells with any + * freed cell filled, _storedTileGridSet, by default; the Tiles button may + * pass it trimmed or filled to a picked count, or with a Ctrl/Cmd+clicked + * session in it) goes back into its cells, holes included, a shape change + * following the cell model's rule (reformTileCells: the tiles keep their row + * and column when all fit, the ones that join fill the empty cells in + * reading order); then the fractions it had (only if they still match the + * layout) and a zoom the user chose on the focused tile. A grid larger than + * the window fits is never trimmed: the focused tile shows alone until the + * window fits it (_applyTileLayout), and the arrangement stays. `auto`: the + * app is putting it back, so no idle alert is spent. `keepCount`: the + * stored count stays the grid's (a session that went away may be filled the + * next time); false makes it what opens (a pick, a Ctrl/Cmd+click). Nothing + * is written until the grid is back, so a stored grid is never overwritten + * by a half-built one. + */ + _openStoredTileGrid( + stored, + set = this._storedTileGridSet(stored), + { focusedId = null, auto = true, keepCount = true } = {} + ) { + if (!set?.ids?.length) return false; const T = window.CodemanTileGrid; - // openTileGrid packed the tiles from the first cell. The stored tiles go - // back to their cells (a session gone since leaves its cell empty): as - // they were when this is the stored set in the shape it was stored with, - // otherwise by the cell model's rule, the tiles the count adds filling the - // empty cells in reading order, holes first. - const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null)); - const kept = storedCells.filter(Boolean); - const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id)); - const cells = sameSet - ? storedCells - : T.reformTileCells( - storedCells, - T.tileCellCols(storedCells.length), - kept, - grid.ids.filter((id) => !kept.includes(id)), - grid.cols, - grid.rows - ); + const focus = focusedId || set.focusedId; + // An open split closes without joining it (decision 8, case a). + this._tilePersistHold = true; + let opened = false; + try { + opened = this.openTileGrid(set.ids, { focusedId: focus, auto, mergeSplit: false }); + } finally { + this._tilePersistHold = false; + } + if (!opened) return false; + const grid = this._tileGrid; + // openTileGrid packed the tiles from the first cell. They go back to their + // cells (a session gone since leaves its cell empty, unless the ranking + // filled it). + const base = (set.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null)); + const kept = base.filter(Boolean); + const cells = T.reformTileCells( + base, + T.tileCellCols(base.length), + kept, + grid.ids.filter((id) => !kept.includes(id)), + grid.cols, + grid.rows + ); if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) { grid.cells = cells; } + grid.count = keepCount ? Math.min(Math.max(grid.ids.length, stored.count || 0), T.TILE_GRID_MAX) : grid.ids.length; // 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)) { + if (stored.zoomed && stored.zoomed === grid.focusedId && grid.tiles.has(stored.zoomed)) { grid.zoomedId = stored.zoomed; grid.autoZoom = false; } @@ -2707,14 +2808,18 @@ Object.assign(CodemanApp.prototype, { } }, - /** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */ + /** + * A followed `#session=` link took the screen on load: the stored grid stays + * remembered, closed. Only `open` changes: the value is written back as it + * was stored, so a session gone since still frees its cell for the ranking + * the next time the grid opens. + */ _closeStoredTileGrid() { const stored = this._readStoredTileGrid(); if (!stored?.open) return; - // Stored as it was read, holes included (`ids` carries the cells). - const { cells, ...rest } = stored; try { - localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...rest, ids: cells, open: false })); + const raw = JSON.parse(localStorage.getItem(TILE_GRID_STORAGE_KEY)); + localStorage.setItem(TILE_GRID_STORAGE_KEY, JSON.stringify({ ...raw, open: false })); } catch { /* Per-device convenience only. */ } @@ -2743,7 +2848,7 @@ Object.assign(CodemanApp.prototype, { 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 (!this.sessions.has(id) || this.detachedSessions?.has(id)) this.removeTile(id, { refocus: false, gone: true }); } if (!grid.open) return false; // Only a focus that is gone moves: re-selecting the same tile would hide an @@ -2771,7 +2876,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) { if (grid?.has(data.id)) { const wasFocused = grid.focusedId === data.id; const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id); - this.removeTile(data.id, { refocus: false }); + this.removeTile(data.id, { refocus: false, gone: true }); if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) { this._selectTiledSession(neighbor, { auto: true, focus: false }); } diff --git a/test/tile-grid-cells.test.ts b/test/tile-grid-cells.test.ts index bb67a918..1a65fe44 100644 --- a/test/tile-grid-cells.test.ts +++ b/test/tile-grid-cells.test.ts @@ -14,8 +14,9 @@ * and Ctrl+Tab / Alt+[ ] cycle through the tiles only. * - `codeman:tile-grid` stores the cells (ids, `null` for a hole); a reload * brings the holes back when the shape is the same, a session gone by then - * leaves its cell empty, a different shape packs, and the old packed format - * reads unchanged. A followed `#session=` link keeps the holes. + * frees its cell for the ranking to fill (empty only when no other session + * is left), a different shape packs, and the old packed format reads + * unchanged. A followed `#session=` link keeps the holes. * - A divider drag refits only the tiles in its two tracks, holes skipped. * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. @@ -246,11 +247,16 @@ describe('focus never lands on an empty cell', () => { }); describe('persistence: the cells, holes included', () => { - function reload(ids: string[] = SIX): GridApp { + function reload(ids: string[] = SIX, { others = true } = {}): GridApp { // The page goes away; a fresh one on the same device restores the grid. section.children = []; FakeTile.all = []; const app = makeGridApp(ids); + // `others: false`: no session but `ids` is open, so a freed cell has nothing to take. + if (!others) { + app.sessions.delete('s-other'); + app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-other'); + } expect(app._restoreTileGrid()).toBe(true); return app; } @@ -266,10 +272,18 @@ describe('persistence: the cells, holes included', () => { expect(slots()[0].dataset.cell).toBe('2'); }); - it('a session gone by then leaves its cell empty if the shape still fits', () => { + it('a session gone by then frees its cell, which the ranking fills, the hole the user left kept', () => { + const app = openGrid(FIVE); + setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); + app._tileGrid.open = false; + const again = reload(['s-a', 's-b', 's-d', 's-e']); + expect(again._tileGrid.cells).toEqual(['s-a', null, 's-b', 's-other', 's-d', 's-e']); + }); + + it('a freed cell stays empty only when no other session is left, if the shape still fits', () => { const app = openGrid(SIX); app._tileGrid.open = false; - const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f']); + const again = reload(['s-a', 's-b', 's-d', 's-e', 's-f'], { others: false }); expect(again._tileGrid.cells).toEqual(['s-a', 's-b', null, 's-d', 's-e', 's-f']); }); @@ -277,15 +291,15 @@ describe('persistence: the cells, holes included', () => { const app = openGrid(FIVE); setCells(app, ['s-a', null, 's-b', 's-c', 's-d', 's-e']); app._tileGrid.open = false; - // s-d gone: four tiles take a 2x2. - const again = reload(['s-a', 's-b', 's-c', 's-e']); + // s-d gone and nothing to take its cell: four tiles take a 2x2. + const again = reload(['s-a', 's-b', 's-c', 's-e'], { others: false }); expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-e']); }); it('another shape packs even when the stored holes would fit the new one', () => { // Stored as 3x2 [a _ b / c d e]; d and e gone: three tiles take a 2x2. localStore.set(KEY, JSON.stringify({ v: 1, open: true, ids: ['s-a', null, 's-b', 's-c', 's-d', 's-e'] })); - const again = reload(['s-a', 's-b', 's-c']); + const again = reload(['s-a', 's-b', 's-c'], { others: false }); expect(again._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null]); }); diff --git a/test/tile-grid-count-menu.test.ts b/test/tile-grid-count-menu.test.ts index 49fa662b..e3b51df7 100644 --- a/test/tile-grid-count-menu.test.ts +++ b/test/tile-grid-count-menu.test.ts @@ -16,8 +16,11 @@ * menu owns its Escape in the global handler, like the tab-group menu), Tab * and a click elsewhere close it. * - A pick is remembered per device (`codeman:tile-count`; `codeman:tile-grid` - * stays ids only) and opens that many tiles: the session to focus included, - * the rest from tab order. The click and Ctrl+Shift+G then open with it. + * holds ids and layout, never content) and opens that many tiles: the + * session to focus included, the rest from the ranking (all quiet and + * unstamped here: tab order). The click and Ctrl+Shift+G then open with it + * when no grid is stored (a stored one comes back as it was: + * tile-grid-layout-memory.test.ts). * - With the grid open a pick re-forms it: a shape change under the cell * model's rule, the focused tile always kept, tiles dropped from the end, * new ones filling the empty cells first, all mounted and laid out before @@ -310,9 +313,10 @@ describe('picking a count', () => { expect(app._tileGrid.ids).toEqual(['s-other', 's-c']); expect(app._tileGrid.focusedId).toBe('s-c'); expect(localStore.get('codeman:tile-count')).toBe('2'); - // The grid's own key stays ids only. + // The grid's own key holds ids and layout only (its tile count included), never content. const stored = JSON.parse(localStore.get('codeman:tile-grid')!); - expect(Object.keys(stored).sort()).toEqual(['colFr', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']); + expect(Object.keys(stored).sort()).toEqual(['colFr', 'count', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']); + expect(stored.count).toBe(2); }); it('a click on 4 opens four, Space works too', () => { diff --git a/test/tile-grid-layout-memory.test.ts b/test/tile-grid-layout-memory.test.ts new file mode 100644 index 00000000..261f42a9 --- /dev/null +++ b/test/tile-grid-layout-memory.test.ts @@ -0,0 +1,576 @@ +/** + * @fileoverview The grid keeps the layout the user arranged, per browser + * (owner request: "when I moved around and modified it, save it per browser + * the layout, so when I turn tiles off and on, always keep what the last + * setting was"). + * + * - Saved on every change (`codeman:tile-grid` in localStorage, never the + * server): which session sits in which cell, holes included, the tile count, + * the divider sizes, the focused tile and a zoom the user chose. + * - The Tiles toggle and a page reload bring back exactly that, however the + * grid was closed: the toggle, a tab that is not tiled, a followed link + * (`leaveTiles`, or `#session=` on load), Home, the window narrowing past the + * desktop gate, the last tile removed, "Open group as tiles", closing a + * session, killing them all. It is never filled to the remembered count and + * never trimmed to the window (a too-small window shows the focused tile + * until it fits, the arrangement kept). + * - A session that no longer exists frees its cell, and the ranking fills it + * (the hole a user left stays a hole); with none of the stored sessions left + * the grid opens from the ranking, as with nothing stored. + * - The stored format stays `v: 1`: a value written before `count` (or before + * cells) loads, and a malformed one is ignored safely. + * + * Pure helpers (sanitizeTileGridState, restoreTileGridCells) and the app via + * the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + FakeTile, + body, + bySelector, + fetchSpy, + flushFrames, + localStore, + makeGridApp, + resetGridHarness, + section, + windowStub, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +type Restored = { ids: string[]; cells: Array; focusedId: string } | null; +type Sanitized = { + ids: string[]; + cells: Array; + freed: number[]; + count: number; + open: boolean; + focused: string | null; + zoomed: string | null; + colFr: number[] | null; +} | null; +type Helpers = { + sanitizeTileGridState(raw: unknown, live: Iterable, detached?: Set): Sanitized; + restoreTileGridCells(stored: unknown, ranked: string[]): Restored; +}; + +const T = windowStub.CodemanTileGrid as Helpers; +const KEY = 'codeman:tile-grid'; +const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f']; +const stored = () => JSON.parse(localStore.get(KEY) ?? 'null'); + +describe('sanitizeTileGridState: freed cells and the count', () => { + const live = ['a', 'b', 'c', 'd']; + + it('marks the cells whose session went away (deleted or popped out), never a hole the user left', () => { + const out = T.sanitizeTileGridState( + { v: 1, open: true, ids: ['a', null, 'gone', 'b', 'c', 'x'] }, + live, + new Set(['c']) + ); + expect(out?.cells).toEqual(['a', null, null, 'b', null, null]); + expect(out?.freed).toEqual([2, 4, 5]); + }); + + it('a repeat or a malformed entry is a plain hole, not freed', () => { + const out = T.sanitizeTileGridState({ v: 1, ids: ['a', 'a', 7, '', 'gone', 'gone'] }, live); + expect(out?.cells).toEqual(['a', null, null, null, null, null]); + expect(out?.freed).toEqual([4]); + }); + + it('reads a stored count; one written before it existed is the number of sessions the cells name', () => { + expect(T.sanitizeTileGridState({ v: 1, ids: ['a', null, 'b'], count: 4 }, live)?.count).toBe(4); + expect(T.sanitizeTileGridState({ v: 1, ids: ['a', null, 'gone', 'b'] }, live)?.count).toBe(3); + // The old packed format of nine (before the cap) reads as the cap. + const nine = Array.from({ length: 9 }, (_, i) => `n${i}`); + expect(T.sanitizeTileGridState({ v: 1, ids: nine }, nine)?.count).toBe(6); + }); + + it.each([[0], [7], [2.5], ['4'], [null], [-1]])('a malformed count (%j) is ignored', (count) => { + expect(T.sanitizeTileGridState({ v: 1, ids: ['a', 'b'], count }, live)?.count).toBe(2); + }); +}); + +describe('restoreTileGridCells', () => { + const sanitize = (value: Record, live: string[]) => + T.sanitizeTileGridState({ v: 1, open: false, ...value }, live); + + it('gives back the cells exactly, holes included, focused on the zoomed tile, else the focused one', () => { + const s = sanitize({ ids: ['a', null, 'b', 'c', 'd', 'e'], focused: 'c', zoomed: 'd' }, [ + 'a', + 'b', + 'c', + 'd', + 'e', + 'z', + ]); + expect(T.restoreTileGridCells(s, ['z'])).toEqual({ + ids: ['a', 'b', 'c', 'd', 'e'], + cells: ['a', null, 'b', 'c', 'd', 'e'], + focusedId: 'd', + }); + const t = sanitize({ ids: ['a', 'b'], focused: 'b' }, ['a', 'b']); + expect(T.restoreTileGridCells(t, [])?.focusedId).toBe('b'); + }); + + it('fills a freed cell from the ranking, best first, never one already in the grid; the user hole stays', () => { + const s = sanitize({ ids: ['a', null, 'gone', 'b', 'c', 'd'], count: 5 }, ['a', 'b', 'c', 'd', 'x', 'y']); + expect(T.restoreTileGridCells(s, ['b', 'y', 'x'])?.cells).toEqual(['a', null, 'y', 'b', 'c', 'd']); + }); + + it('fills freed cells first, then other empty cells only up to the count', () => { + // A hand-made value: cell 3 freed (its session gone), cell 1 a plain hole, a count of six. + const s = sanitize({ ids: ['a', null, 'b', 'gone', 'c', 'd'], count: 6 }, ['a', 'b', 'c', 'd', 'x', 'y', 'z']); + expect(T.restoreTileGridCells(s, ['x', 'y', 'z'])?.cells).toEqual(['a', 'y', 'b', 'x', 'c', 'd']); + }); + + it('a freed cell stays empty only when no session is left to place', () => { + const s = sanitize({ ids: ['a', 'gone', 'b'] }, ['a', 'b']); + expect(T.restoreTileGridCells(s, [])).toEqual({ ids: ['a', 'b'], cells: ['a', null, 'b'], focusedId: 'a' }); + }); + + it('more to place than the stored cells hold: they join after them (the shape grows when laid out)', () => { + // Four stored cells (2x2), the count says six: two sessions closed while the grid was open packed it. + const s = sanitize({ ids: ['a', 'b', 'c', 'd'], count: 6 }, ['a', 'b', 'c', 'd', 'x', 'y', 'z']); + expect(T.restoreTileGridCells(s, ['x', 'y', 'z'])?.ids).toEqual(['a', 'b', 'c', 'd', 'x', 'y']); + }); + + it('is null when none of its sessions survive', () => { + expect(T.restoreTileGridCells(sanitize({ ids: ['gone', null] }, ['x']), ['x'])).toBeNull(); + expect(T.restoreTileGridCells(null, ['x'])).toBeNull(); + }); +}); + +// ── In the app ────────────────────────────────────────────────────────────── + +/** The real selectSession (tile branch, leaving the grid); the main terminal's select path bails at once. */ +function layoutApp(ids: string[] = SIX): GridApp { + const app = makeGridApp(ids); + app._setTerminalLoadState = vi.fn(); + app._clearTerminalLoadState = vi.fn(); + app._renderHistoryTruncationBanner = vi.fn(); + app._isStaleSelect = vi.fn(() => true); + app._retireUrlSession = vi.fn(); + return app; +} + +const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl; + +/** A column divider dragged from `from` to `to` (px), released. */ +function dragColumn(app: GridApp, key: string, from: number, to: number) { + const d = divider(app, key); + d.dispatch('pointerdown', { + button: 0, + pointerId: 3, + clientX: from, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + d.dispatch('pointermove', { clientX: to }); + flushFrames(); + d.dispatch('pointerup', {}); +} + +/** + * Six tiles opened, then arranged by hand: s-b removed (its cell left empty), + * s-a and s-f swapped, the first column divider dragged, s-c focused and + * zoomed. [ f _ c / d e a ]. + */ +async function arrange(app: GridApp) { + app.openTileGrid(SIX, { focusedId: 's-a' }); + flushFrames(); + app.removeTile('s-b'); + app._swapTiles('s-a', 's-f'); + dragColumn(app, 'col-0', 800, 1000); + await app.selectSession('s-c'); + app.zoomTile('s-c'); +} + +const layoutOf = (app: GridApp) => ({ + cells: app._tileGrid.cells.slice(), + count: app._tileGrid.count, + colFr: app._tileGrid.colFr.slice(), + rowFr: app._tileGrid.rowFr.slice(), + focused: app._tileGrid.focusedId, + zoomed: app._tileGrid.zoomedId, +}); + +const ARRANGED_CELLS = ['s-f', null, 's-c', 's-d', 's-e', 's-a']; + +beforeEach(() => { + resetGridHarness(); + bySelector.set('.terminal-wrap', new FakeEl()); + fetchSpy.mockClear(); +}); +afterEach(() => { + delete (section as unknown as Record).getBoundingClientRect; +}); + +describe('saved on every change', () => { + it('a move, a removal, a divider drag, a focus and a zoom are each written as they happen', async () => { + const app = layoutApp(); + app.openTileGrid(SIX, { focusedId: 's-a' }); + flushFrames(); + expect(stored()).toMatchObject({ open: true, ids: SIX, count: 6, focused: 's-a', colFr: [1, 1, 1] }); + + app.removeTile('s-b'); + expect(stored()).toMatchObject({ ids: ['s-a', null, 's-c', 's-d', 's-e', 's-f'], count: 5 }); + + app._swapTiles('s-a', 's-f'); + expect(stored().ids).toEqual(ARRANGED_CELLS); + + dragColumn(app, 'col-0', 800, 1000); + expect(stored().colFr[0]).toBeGreaterThan(1); + expect(stored().colFr).toEqual(app._tileGrid.colFr); + + await app.selectSession('s-c'); + expect(stored().focused).toBe('s-c'); + + app.zoomTile('s-c'); + expect(stored().zoomed).toBe('s-c'); + }); + + it('adding a tile (a tab dropped on the empty cell) and picking a count are written, the count with them', () => { + const app = layoutApp([...SIX, 's-g']); + app.openTileGrid(SIX.slice(0, 5)); + flushFrames(); + app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e']; + app._applyTileLayout(); + app.dropSessionOnSlot('s-g', 1); + expect(stored()).toMatchObject({ ids: ['s-a', 's-g', 's-b', 's-c', 's-d', 's-e'], count: 6 }); + app._pickTileCount(4); + expect(stored().count).toBe(4); + expect(stored().ids.filter(Boolean)).toHaveLength(4); + }); + + it('is never sent to the server', async () => { + const app = layoutApp(); + await arrange(app); + app.toggleTileGrid(); + app.toggleTileGrid(); + const calls = JSON.stringify(fetchSpy.mock.calls); + expect(calls).not.toContain('tile'); + expect(calls).not.toContain('colFr'); + }); +}); + +describe('the Tiles toggle and a reload bring back exactly what was arranged', () => { + it('off and on: the cells and the hole, the count, the divider sizes, the focus and the zoom', async () => { + const app = layoutApp(); + await arrange(app); + const before = layoutOf(app); + expect(before.cells).toEqual(ARRANGED_CELLS); + + app.toggleTileGrid(); + expect(app._tilesOwnTerminal()).toBe(false); + expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5, focused: 's-c', zoomed: 's-c' }); + app.toggleTileGrid(); + expect(layoutOf(app)).toEqual(before); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('never filled to the remembered count, though more sessions are free', async () => { + const app = layoutApp([...SIX, 's-g', 's-h']); + await arrange(app); + app._rememberTileGridCount(6); + app.toggleTileGrid(); + app.toggleTileGrid(); + expect(app._tileGrid.cells).toEqual(ARRANGED_CELLS); + }); + + it('a reload with the grid open: the same layout on the next page', async () => { + const app = layoutApp(); + await arrange(app); + const before = layoutOf(app); + // The page goes away; a fresh one on the same browser. + section.children = []; + FakeTile.all = []; + const next = layoutApp(); + expect(next._restoreTileGrid()).toBe(true); + expect(layoutOf(next)).toEqual(before); + }); + + it('never trimmed to a smaller window: the focused tile shows alone, and the arrangement is kept for a wider one', async () => { + const app = layoutApp(); + app.openTileGrid(SIX, { focusedId: 's-c' }); + flushFrames(); + app.toggleTileGrid(); + // A window that fits four (3x2 needs 1440px across). + const small = { width: 1300, height: 900, top: 0, left: 0, right: 1300, bottom: 900 }; + section.getBoundingClientRect = () => small; + (bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = () => small; + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(SIX); + expect(app._tileGrid.zoomedId).toBe('s-c'); + expect(app._tileGrid.autoZoom).toBe(true); + expect(stored().ids).toEqual(SIX); + app.toggleTileGrid(); + delete (section as unknown as Record).getBoundingClientRect; + (bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = FakeEl.prototype.getBoundingClientRect; + app.toggleTileGrid(); + expect(app._tileGrid.ids).toEqual(SIX); + expect(app._tileGrid.zoomedId).toBeNull(); + }); +}); + +describe('every way the grid closes keeps it', () => { + const closers: Array<[string, (app: GridApp) => unknown]> = [ + ['the Tiles toggle', (app) => app.toggleTileGrid()], + ['picking a tab that is not tiled', (app) => app.selectSession('s-b').catch(() => {})], + [ + 'a followed link (leaveTiles)', + (app) => app.selectSession('s-other', { auto: true, leaveTiles: true }).catch(() => {}), + ], + ['Home', (app) => app.goHome()], + ['"Open group as tiles" (its grid opens, then that one is kept)', () => {}], + ]; + + it.each(closers.slice(0, 4))('%s', async (_label, close) => { + const app = layoutApp(); + await arrange(app); + const before = layoutOf(app); + await close(app); + expect(app._tilesOwnTerminal()).toBe(false); + expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5 }); + app.toggleTileGrid(); + expect(layoutOf(app)).toEqual(before); + }); + + it('the window narrowing past the desktop gate', async () => { + const gates: Array<(e: { matches: boolean }) => void> = []; + windowStub.matchMedia = (q: string) => ({ + matches: false, + addEventListener: (_t: string, cb: (e: { matches: boolean }) => void) => { + if (q.includes('min-width')) gates.push(cb); + }, + }); + const app = layoutApp(); + await arrange(app); + const before = layoutOf(app); + for (const gate of gates) gate({ matches: false }); + expect(app._tilesOwnTerminal()).toBe(false); + app.toggleTileGrid(); + expect(layoutOf(app)).toEqual(before); + }); + + it('"Open group as tiles" replaces it with the group, and THAT is what comes back', async () => { + const app = layoutApp(); + await arrange(app); + app.tabLayout = { + groups: [ + { + id: 'g', + refs: [ + { kind: 'session', id: 's-b' }, + { kind: 'session', id: 's-d' }, + ], + }, + ], + }; + app.openGroupAsTiles('g'); + expect(app._tileGrid.ids).toEqual(['s-b', 's-d']); + app.toggleTileGrid(); + app.toggleTileGrid(); + expect(app._tileGrid.cells).toEqual(['s-b', 's-d']); + }); + + it('the last tile removed: that tile comes back', () => { + const app = layoutApp(); + app.openTileGrid(['s-c', 's-d'], { focusedId: 's-c' }); + app.removeTile('s-d'); + app.removeTile('s-c'); + expect(app._tilesOwnTerminal()).toBe(false); + expect(stored()).toMatchObject({ open: false, ids: ['s-c'], count: 1 }); + app.toggleTileGrid(); + expect(app._tileGrid.cells).toEqual(['s-c']); + }); + + it('a #session= link on load closes it, its value kept as stored (a session gone since still frees its cell)', async () => { + const app = layoutApp(); + await arrange(app); + const page = layoutApp(); + page.sessions.delete('s-d'); + page._closeStoredTileGrid(); + expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS, count: 5 }); + page.toggleTileGrid(); + // s-d's cell taken by the ranking's best untiled session (all quiet here: tab order, s-other first). + expect(page._tileGrid.cells).toEqual(['s-f', null, 's-c', 's-other', 's-e', 's-a']); + }); + + it('closing the last tiled session: kept, and with none of its sessions left the ranking opens from scratch', () => { + const app = layoutApp(); + app.openTileGrid(['s-c'], { focusedId: 's-c' }); + app._onSessionDeleted({ id: 's-c' }); + app.sessions.delete('s-c'); + app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-c'); + expect(app._tilesOwnTerminal()).toBe(false); + expect(stored()).toMatchObject({ open: false, ids: ['s-c'] }); + Object.assign(app.sessions.get('s-e'), { status: 'busy', lastSubmitAt: 50 }); + app.toggleTileGrid(); + expect(app._tileGrid.ids[0]).toBe('s-e'); + expect(app._tileGrid.ids).toHaveLength(6); + }); + + it('killing every session: kept, naming only gone sessions, so the next click ranks from scratch', async () => { + const app = layoutApp(); + await arrange(app); + app._apiDelete = vi.fn(async () => ({ ok: true })); + app.terminalBuffers = new Map(); + app.terminalLoadStates = new Map(); + // killAllSessions asks with a bare confirm(): the harness context's own global. + const contextGlobal = (app.toggleTileGrid.constructor as FunctionConstructor)('return globalThis')(); + contextGlobal.confirm = () => true; + try { + await app.killAllSessions(); + } finally { + delete contextGlobal.confirm; + } + expect(app._tilesOwnTerminal()).toBe(false); + expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS }); + // New sessions later: none of the stored ones survive. + app.sessions = new Map(['n-1', 'n-2'].map((id) => [id, { id, name: id, mode: 'claude', pid: 1 }])); + app.sessionOrder = ['n-1', 'n-2']; + app.toggleTileGrid(); + expect(app._tileGrid.cells).toEqual(['n-1', 'n-2']); + }); +}); + +describe('a session that went away by itself while the grid was open', () => { + // With the cap at 6 a grid has at most one empty cell (5 tiles in a 3x2, or + // 3 in a narrow 2x2), so the stored count alone says that cell is to be filled. + function six(extra: string[] = []): GridApp { + const app = layoutApp([...SIX, ...extra]); + app.openTileGrid(SIX, { focusedId: 's-a' }); + flushFrames(); + return app; + } + + it('closed from elsewhere: its cell empties now, and the ranking fills it the next time the grid opens', () => { + const app = six(['s-g']); + app._onSessionDeleted({ id: 's-d' }); + app.sessions.delete('s-d'); + app.sessionOrder = app.sessionOrder.filter((id: string) => id !== 's-d'); + // Nothing joins on its own while the grid is open. + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null, 's-e', 's-f']); + // The count stays what the user left: six. + expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', null, 's-e', 's-f'], count: 6 }); + Object.assign(app.sessions.get('s-g'), { status: 'busy', lastSubmitAt: 10 }); + app.toggleTileGrid(); + app.toggleTileGrid(); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-g', 's-e', 's-f']); + }); + + it('popped out (or its socket refused): the same, the place kept for the ranking', () => { + const app = six(); + app.detachedSessions.add('s-e'); + app._reconcileTileGrid(); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-f']); + FakeTile.all.find((t) => t.sessionId === 's-b' && !t._destroyed)!.onExit!(4010); + expect(stored().count).toBe(6); + app.toggleTileGrid(); + app.toggleTileGrid(); + // Two places to fill, the two sessions left that are not popped out (all quiet: tab order). + expect(app._tileGrid.ids).toHaveLength(6); + expect(app._tileGrid.ids).not.toContain('s-e'); + }); + + it('removed by hand instead: the hole stays a hole', () => { + const app = six(['s-g']); + app.removeTile('s-d'); + expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', null, 's-e', 's-f'], count: 5 }); + app.toggleTileGrid(); + app.toggleTileGrid(); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', null, 's-e', 's-f']); + }); + + it('a tile added by hand meanwhile sets the count to what is shown', () => { + const app = six(['s-g']); + app._onSessionDeleted({ id: 's-d' }); + app.sessions.delete('s-d'); + app._onSessionDeleted({ id: 's-e' }); + app.sessions.delete('s-e'); + // Four tiles now (a 2x2), count still six; then a Ctrl/Cmd+click adds one. + app.addSessionToTiles('s-g'); + expect(app._tileGrid.ids).toHaveLength(5); + expect(stored().count).toBe(5); + }); +}); + +describe('the stored format', () => { + it('a value written before the count (packed, no holes) loads, its sessions in their cells', () => { + localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-c', 's-a', 's-e', 's-d'], focused: 's-e' })); + const app = layoutApp(); + app.toggleTileGrid(); + expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-e', 's-d']); + expect(app.activeSessionId).toBe('s-e'); + expect(stored().count).toBe(4); + }); + + it.each([ + ['another version', JSON.stringify({ v: 2, open: false, ids: SIX })], + ['not JSON', '{oops'], + ['an array', '[1,2]'], + ['ids that are not a list', JSON.stringify({ v: 1, open: false, ids: 's-a' })], + ['nothing but junk', JSON.stringify({ v: 1, open: false, ids: [1, {}, null], count: 'x', colFr: 'y' })], + ])('a malformed value (%s) is ignored: the grid opens from the ranking', (_label, raw) => { + localStore.set(KEY, raw); + const app = layoutApp(); + Object.assign(app.sessions.get('s-f'), { status: 'busy', lastSubmitAt: 99 }); + expect(() => app.toggleTileGrid()).not.toThrow(); + expect(app._tileGrid.ids[0]).toBe('s-f'); + expect(app._tileGrid.ids).toHaveLength(6); + }); + + it('a malformed divider size is dropped, the rest of the layout kept', () => { + localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-a', 's-b'], colFr: [1, 'x'], rowFr: [-1] })); + const app = layoutApp(); + app.toggleTileGrid(); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b']); + expect(app._tileGrid.colFr).toEqual([1, 1]); + }); +}); + +describe('Ctrl/Cmd+click with a stored grid', () => { + it('joins the hole while the grid holds fewer than the count, the rest kept where it was', async () => { + const app = layoutApp(); + await arrange(app); + app.toggleTileGrid(); + app._rememberTileGridCount(6); + app.addSessionToTiles('s-b'); + expect(app._tileGrid.cells).toEqual(['s-f', 's-b', 's-c', 's-d', 's-e', 's-a']); + expect(app._tileGrid.focusedId).toBe('s-b'); + expect(app._tileGrid.colFr).toEqual(stored().colFr); + }); + + it('at the count, it takes the last tile’s place: the count in total, never one more', async () => { + const app = layoutApp(); + await arrange(app); + app.toggleTileGrid(); + app._rememberTileGridCount(4); + app.addSessionToTiles('s-b'); + expect(app._tileGrid.cells).toEqual(['s-f', null, 's-c', 's-d', 's-e', 's-b']); + expect(app._tileGrid.focusedId).toBe('s-b'); + expect(stored().count).toBe(5); + }); +}); + +describe('the hover card', () => { + it('with a stored grid larger than the window, says what fits, not "a click opens N"', () => { + const btn = new FakeEl(); + btn.className = 'btn-icon-header btn-tile-grid'; + bySelector.set('.btn-tile-grid', btn); + const small = { width: 1300, height: 900, top: 0, left: 0, right: 1300, bottom: 900 }; + (bySelector.get('.terminal-wrap') as FakeEl).getBoundingClientRect = () => small; + const app = layoutApp(); + app._applyTileGridButtonVisibility(true); + const card = body.children.find((c) => c.id === 'tileGridHint')!; + const texts = () => card.children.map((c) => c.children.at(-1)?.textContent ?? c.textContent); + // Nothing stored: the click opens what fits. + expect(texts()).toContain('This window fits 4 tiles: a click opens 4'); + localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: SIX, focused: 's-a' })); + app._renderTileHint(); + expect(texts()).toContain('This window fits 4 tiles'); + expect(texts().join('\n')).not.toContain('a click opens'); + }); +}); diff --git a/test/tile-grid-layout.test.ts b/test/tile-grid-layout.test.ts index 4540b705..613033f8 100644 --- a/test/tile-grid-layout.test.ts +++ b/test/tile-grid-layout.test.ts @@ -122,6 +122,9 @@ describe('sanitizeTileGridState', () => { open: true, ids: ['a', 'b'], cells: ['a', 'b'], + // Nothing freed; no stored count: the sessions it names. + freed: [], + count: 2, focused: 'b', zoomed: 'a', colFr: [1, 2], diff --git a/test/tile-grid-open-set.test.ts b/test/tile-grid-open-set.test.ts index 8bb4b886..9a654cb8 100644 --- a/test/tile-grid-open-set.test.ts +++ b/test/tile-grid-open-set.test.ts @@ -45,6 +45,7 @@ describe('tileGridOpenSet (what the Tiles button opens)', () => { expect(T.tileGridOpenSet({ ...base, stored, split: ['t1', 't9'], activeId: 't1' })).toEqual({ source: 'stored', ids: ['t4', 't2'], + cells: ['t4', 't2'], focusedId: 't2', }); expect(T.tileGridOpenSet({ ...base, stored: { ids: ['t4', 't2'], focused: 't4', zoomed: 't2' } })?.focusedId).toBe( diff --git a/test/tile-grid-restore.test.ts b/test/tile-grid-restore.test.ts index 1367d597..7712173f 100644 --- a/test/tile-grid-restore.test.ts +++ b/test/tile-grid-restore.test.ts @@ -1,15 +1,19 @@ /** * @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. + * - What is stored: ids (the cells), the tile count, focus, a zoom the user + * chose and the divider fractions, never content. Closing the grid (Tiles, + * a pick outside it, the last tile leaving) keeps it remembered as + * `open: false` for one-click return, and the toggle brings it back exactly + * (owner request, superseding decision 10's "the count wins over a + * remembered grid's size"; the full set of close paths is in + * tile-grid-layout-memory.test.ts). 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. + * capture), a deleted or detached session frees its cell for the ranking + * to fill, 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 @@ -58,13 +62,14 @@ beforeEach(() => { }); describe('what is stored', () => { - it('opening the grid stores ids, focus and fractions, nothing else', () => { + it('opening the grid stores ids, its count, focus and fractions, nothing else', () => { const app = makeGridApp(IDS); app.openTileGrid(IDS, { focusedId: 's-b' }); expect(stored()).toEqual({ v: 1, open: true, ids: IDS, + count: 4, focused: 's-b', zoomed: null, colFr: [1, 1], @@ -94,7 +99,7 @@ describe('what is stored', () => { expect(stored().zoomed).toBeNull(); }); - it('closing keeps it remembered (open: false); the last tile leaving forgets it', () => { + it('closing keeps it remembered (open: false); the last tile leaving keeps it too', () => { const app = makeGridApp(IDS); app.selectSession = vi.fn(); app.openTileGrid(IDS); @@ -103,7 +108,7 @@ describe('what is stored', () => { app.openTileGrid(['s-a']); app.removeTile('s-a'); - expect(localStore.has(KEY)).toBe(false); + expect(stored()).toMatchObject({ open: false, ids: ['s-a'], count: 1 }); }); it('a solo window never writes', () => { @@ -134,12 +139,13 @@ describe('page load with a stored open grid', () => { expect(app.markIdleAlertSeen).not.toHaveBeenCalled(); }); - it('drops a session that no longer exists, and stores the cleaned list', () => { + it('a session that no longer exists frees its cell, which the ranking fills; that is stored', () => { storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 'gone' }); const app = pageLoad(IDS); - expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); + // Every session quiet and unstamped here: the ranking is tab order (s-c first). + expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-b']); expect(app.activeSessionId).toBe('s-a'); - expect(stored().ids).toEqual(['s-a', 's-b']); + expect(stored().ids).toEqual(['s-a', 's-c', 's-b']); }); it('brings back the fractions (same layout only) and a zoom the user chose', () => { @@ -179,26 +185,30 @@ describe('page load with a stored open grid', () => { expect(app.activeSessionId).toBe('s-c'); }); - it('the toggle fills a stored grid to the remembered count: its tiles first, in their cells, then tab order', () => { - // Default 6 (owner answer 1, superseding decision 8's "exactly the stored set"). + it('the toggle brings a stored grid back exactly, never filled to the remembered count', () => { + // Owner request ("always keep what the last setting was"), superseding + // decision 10's answer that the count wins over a remembered grid's size. 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')); app.activeSessionId = 's-a'; app.toggleTileGrid(); - // The two stay in the first row (2x1 to 2x2 keeps them), the rest join - // in tab order: every live session, fewer than the count. - expect(app._tileGrid.cells).toEqual(['s-b', 's-c', 's-a', 's-d']); + expect(app._tileGrid.cells).toEqual(['s-b', 's-c']); expect(app.activeSessionId).toBe('s-c'); }); - it('a stored hole is filled first when the count needs more tiles', () => { - // A 2x2 of three with the hole first; the count asks for four. - localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: [null, 's-b', 's-c', 's-d'], focused: 's-b' })); - localStore.set('codeman:tile-count', '4'); - const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a')); + it('a stored hole stays a hole on the toggle; a count picked in the menu fills it first', () => { + // A 3x2 of five with the second cell empty; the remembered count is six. + const cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e']; + localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: cells, focused: 's-b' })); + const app = pageLoad([...IDS, 's-e', 's-f']); + // (syncSessionOrder is stubbed here: the tab order the ranking reads.) + app.sessionOrder = [...IDS, 's-e', 's-f']; app.toggleTileGrid(); - // s-a joins in the hole (packing would have put it last). - expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']); + expect(app._tileGrid.cells).toEqual(cells); + app.closeTileGrid({ reselect: false }); + app._pickTileCount(6); + // s-f joins in the hole (packing would have put it last). + expect(app._tileGrid.cells).toEqual(['s-a', 's-f', 's-b', 's-c', 's-d', 's-e']); }); it('a reload brings back exactly the stored grid, whatever the count', () => { diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index 05c4e7c1..7abbaed6 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -243,14 +243,15 @@ describe('the capture-phase handler', () => { }); describe('the actions', () => { - it('the toggle brings back the grid this tab last left, focus included, filled to the count', () => { + it('the toggle brings back exactly the grid this tab last left, focus included, never filled to the count', () => { const app = makeGridApp(IDS); app.selectSession = vi.fn(); app.openTileGrid(IDS, { focusedId: 's-c' }); app.closeTileGrid({ reselect: false }); app.toggleTileGrid(); - // Its three first, then the open sessions in tab order (the default count, 6). - expect(app._tileGrid.ids).toEqual([...IDS, 's-other']); + // Its three, as left (owner request: "always keep what the last setting + // was"), though the default count is 6 and s-other is free. + expect(app._tileGrid.ids).toEqual(IDS); expect(app.activeSessionId).toBe('s-c'); }); @@ -261,7 +262,7 @@ describe('the actions', () => { app.sessions.delete('s-b'); app.toggleTileGrid(); expect(app._tileGrid.ids).not.toContain('s-b'); - // Its cell stays where it was, and the count fills it first (s-other, tab order). + // Its cell stays where it was, and the ranking fills it (s-other, the only one left). expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-c']); }); diff --git a/test/tile-grid-split-coexistence.test.ts b/test/tile-grid-split-coexistence.test.ts index 7a7ee1fa..3c5c40cd 100644 --- a/test/tile-grid-split-coexistence.test.ts +++ b/test/tile-grid-split-coexistence.test.ts @@ -89,8 +89,9 @@ describe('opening the grid over an open split', () => { expect(paneB.destroy).toHaveBeenCalledTimes(1); expect(app._splitPane).toBeNull(); - // Filled from tab order (s-other first), not seeded with the split's two. - expect(app._tileGrid.ids).toEqual(['s-c', 's-other']); + // Exactly as stored (owner request: the last arrangement comes back), not + // seeded with the split's two, nor filled to the count. + expect(app._tileGrid.ids).toEqual(['s-c']); expect(app.activeSessionId).toBe('s-c'); // Pane A (s-a) is not a tile: the split's closing resize gave it its full width back. expect(app.sendResize.mock.calls).toEqual([['s-a', { force: true }]]); From a81d344fb5abb972db53054bce2e044191f98e5c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 06:01:34 +0200 Subject: [PATCH 3/8] docs(tiles): the ranking and the kept layout The tile-grid plan records owner decision 11 (the layout is kept per browser and comes back exactly; a fresh or filling grid ranks working, then needing input, then most recent) and marks where it supersedes decision 10's "the count wins over a remembered grid's size". The plan, architecture-invariants#tile-grid and the CLAUDE.md Tile grid paragraph now describe the stored format (`count`, freed cells, still v: 1), every close path keeping it, and the open order; the wiki tells users what the Tiles button brings back and how a new grid is chosen. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 6 +- docs/tile-grid-plan.md | 99 ++++++++++++++++++++++++--------- docs/wiki/Tile-Grid.md | 48 ++++++++++------ 4 files changed, 109 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85c6dbae..61f59975 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,7 +280,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only at 1180px (width alone, so a wide Android tablet clears it), per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9cad3787..2479d1c9 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -819,7 +819,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t ### Tile grid -**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `
` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view. +**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives, reports the cells whose sessions went away as `freed`), `restoreTileGridCells` (a stored grid as it comes back), `rankTileSessions` (the order a fresh or filling grid takes sessions in), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `
` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view. ⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`. @@ -829,7 +829,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t ⚠️ **Chords.** `toggle-tile-grid` (Ctrl+Shift+G), `focus-tile-*` (Alt+Shift+Arrows), `move-tile-*` (Ctrl+Shift+Arrows), `zoom-tile` (Alt+Shift+Enter) and `remove-tile` (unbound) are registry entries kept OUT of `SHORTCUT_ACTIONS`: `tileShortcutFor` decides whether one applies (the toggle while the grid is open, or where one could open AND `showTileGridButton` is on, an owner decision: with the setting off the chord is inert and passes through like any unbound key; the rest only while it is open), the capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and BEFORE the Shift+Enter gate (Alt+Shift+Enter would otherwise send `S-Enter`). Outside the grid the focus chords reach the terminal untouched. The move chords also apply while a tile is zoomed (a no-op, so their keys never reach the CLI). The arrow chords, focus and move, never apply in a text field other than xterm's own textarea (`isTextFieldTarget`), where shifted arrows select. -⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once with the remembered count of tiles (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`), on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive (an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), (b) else an open split's two sessions, (c) else the open sessions in tab order (no detached ones), the active session always included and focused; then `tileGridSetForCount` trims it from the end (the session to focus kept) or fills it from tab order to the count. A stored grid comes back with its tiles in their cells (`reformTileCells`: a count change is a shape change under `fitTileCells`, the added tiles fill the empty cells first): the count supersedes decision 8's "exactly the stored set", and only the page-load restore brings back exactly what was stored. Ctrl/Cmd+click with the grid closed opens the count in total, the clicked session among them and focused. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy. +⚠️ **Opening.** The Tiles button's click and `Ctrl+Shift+G` are one function, `toggleTileGrid` (owner decision 8), so the two cannot drift. It opens the grid at once on `tileGridOpenSet` (constants.js, pure): (a) the stored grid if any of its sessions survive, EXACTLY as the user left it (decision 11: `restoreTileGridCells` keeps its cells and holes, its focus and a zoom the user chose; `_openStoredTileGrid` puts back its divider sizes and count; an open split closes and is not seeded first: `openTileGrid(..., { mergeSplit: false })`), never filled to the remembered count and never trimmed to the window (`_applyTileLayout`'s automatic zoom shows the focused tile until the window fits, the arrangement kept); (b) else an open split's two sessions; (c) else the open sessions as `rankTileSessions` orders them (`_tileGridRanking`, tile-grid.js: no detached ones; WORKING first, the most recently started turn first, keyed off `lastSubmitAt` ONLY because a working pane's `lastActivityAt` is always "now"; then the ones NEEDING INPUT, `needs`/`waiting`, the red and yellow tab alerts, most recent first; then the rest by `sessionActivityAnchor`, newest first; a 0 stamp last, tab order the final tiebreak; the states are `_mobileOverviewState`'s, and a page without it falls back to tab order), the active session always included and focused; then `tileGridSetForCount` trims (b) and (c) from the end (the session to focus kept) or fills them from the ranking to the remembered count (`codeman:tile-count`, 2, 4 or 6, default 6, owner decision 10; at most `_tileGridLimit().capacity`). ⚠️ A stored grid's freed cells (a session gone or popped out since) are filled from the ranking first, then other empty cells, only while it holds fewer tiles than its stored `count`; a hole the user made stays. A count picked in the menu with the grid closed (`_activateTileGrid({ count })`) re-forms a stored grid to it (`reformTileCells`, the added tiles in the empty cells first). Ctrl/Cmd+click with the grid closed opens what the toggle would with the clicked session focused, never past the count: it joins while the grid holds fewer, else takes the last tile's place. Right-click (`oncontextmenu`, which also fires for Shift+F10 and the Menu key, and calls `preventDefault` for the browser menu) is the 2 / 4 / 6 count menu (`openTileCountMenu`); with the grid open a pick re-forms it (`_reformTileGrid`: the focused tile always stays, every joining tile is mounted and laid out before any connects, so each fits once). ⚠️ The menu owns its Escape: the global handler closes it alone and gives the keyboard back to the Tiles button, like the tab-group menu. ⚠️ It closes when the keyboard leaves it for another element (a `focusout` with a `relatedTarget` outside it; a focus going nowhere, a Safari button click, does not count): closing the grid starts a selection that focuses the single view's terminal when its replay lands, and a menu left open behind that sent its arrows, Enter and Escape into the PTY. ⚠️ Opening builds the tiles' terminals one per animation frame (`_connectTilesPaced`, the focused one first, a run token stops a stale run), so `openTileGrid` returns before they exist: a focus asked for meanwhile is handed over in `_connectTile` (`focusOnConnect`), never for `focus: false`, and `_connectTile` connects a tile once (`entry.connected`, reset by `_remountTile`). ⚠️ Motion (the grid's own, on by default): tiles enter staggered (`.tile--entering`, `--tile-enter-index`), a terminal stays transparent until its first capture lands (`.tile--revealing`, cleared by the load queue's idle, 15 s backstop), and the toggle's close leaves an inert cloned still copy (`_ghostTileGrid`, no xterm or socket) that holds until the single view's `selectSession` settles (at most 700 ms) and then fades; every keyframe animates opacity and transform only (FitAddon reads the untransformed box, so still one PTY resize per tile), nothing moves and no copy is made under `prefers-reduced-motion`, and `.main.webview-active` hides the copy. ⚠️ **zh-CN.** Every string the grid puts on screen has its own entry in i18n.js's `ZH_CN` (or a `translateDynamic` pattern for counts, exit codes and the header tooltip's state plus duration, which requires the duration: bare state words stay out of the table, see mobile-overview.js), so nothing reaches the generic leading-verb fallback. `test/tile-grid-i18n.test.ts` drives the real tile code through every state that writes text, harvests each string and requires a full translation (and the same English back in `en`); a new tile string needs its entry or that test fails. Session and group names stay user text (`data-i18n-skip`). ⚠️ A refresh that skips unchanged text must compare with the last ENGLISH value it set (`entry.headerLabel`, `entry.overlayLabel`, `entry.zoomLabel`), never the DOM: in zh-CN the DOM holds the translation, so a DOM compare rewrites English on every `session:updated` for the observer to translate again. @@ -839,7 +839,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. -⚠️ **Persistence and joining.** `codeman:tile-grid` holds `{ v: 1, open, ids, focused, zoomed, colFr, rowFr }`, ids only, written as the grid changes; its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants; a restore puts the holes back only when the cell count matches the current shape, else the tiles stay packed, and `_closeStoredTileGrid` writes the cells back, never the packed list. The old packed format reads as cells with no hole; closing keeps it as `open: false` (the Tiles toggle, the picker and Ctrl/Cmd+click bring it back), the last tile leaving forgets it, a solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. +⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. ### Gesture control: the setting diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 943706b4..155f39f6 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -45,20 +45,28 @@ or settled a question the spec left open. The invariants as built are in `Right-click`) and `Arrows` translated. Every string has its own entry or pattern; refreshes compare with the last English value, not the translated DOM. - **The Tiles button opens the grid at once** (owner decision 8, with the count of - decision 10): a click (and `Ctrl+Shift+G`, the same `toggleTileGrid`) opens the remembered - count of tiles (default 6, at most what the window fits). `tileGridOpenSet` - (constants.js) picks the grid this tab last had, else an open split's two sessions, else - the open sessions in tab order, the active one always included and focused, and - `tileGridSetForCount` trims it (from the end, the session to focus kept) or fills it - (from tab order) to the count. A remembered grid comes back with its tiles first, in - their cells, then sessions in tab order, to the count in total: the count is a shape - change under the cell model's rule (`reformTileCells`: the tiles keep their row and - column when all fit, else they pack in reading order) and the added tiles fill the empty - cells first. This supersedes decision 8's "exactly the stored set" (owner answer). A - remembered grid still wins over an open split: the split closes and its sessions are not - seeded first. A page-load restore brings back exactly the stored grid, whatever the - count. Ctrl/Cmd+click on a tab with the grid closed opens the count in total, that - session among them and focused (owner answer: N, not N+1). + decision 10 and the layout memory of decision 11): a click (and `Ctrl+Shift+G`, the same + `toggleTileGrid`) brings back the grid this browser last had EXACTLY as the user left + it: which session sits in which cell, holes included, its tile count, divider sizes, + focus and a zoom the user chose (`restoreTileGridCells`, constants.js). It is never + filled to the remembered count and never trimmed to the window (a window too small + for it shows the focused tile alone until it fits, the arrangement kept). A session + that no longer exists frees its cell, which the ranking fills. Only with nothing + stored, or none of its sessions left, does `tileGridOpenSet` (constants.js) take an + open split's two sessions, else the open sessions as `rankTileSessions` orders them + (owner request: "prefer to load in tiles that are working and then the most recent, + so the oldest don't get opened"): WORKING first (the most recently started turn + first, keyed off `lastSubmitAt` only), then the ones NEEDING INPUT (the red and yellow + tab alerts), then the rest by most recent activity, tab order breaking ties; the + active one always included and focused, and `tileGridSetForCount` trims it (from the + end, the session to focus kept) or fills it (from the ranking) to the remembered count + (default 6, at most what the window fits). The states and stamps are the home + screens' own (`_mobileOverviewState`, `sessionActivityAnchor`). A remembered grid still + wins over an open split: the split closes and its sessions are not seeded first. A + page-load restore brings back the same grid as the toggle. Ctrl/Cmd+click on a tab + with the grid closed opens what the toggle would with that session among the tiles and + focused, never past the count (owner answer: N, not N+1): it joins the first empty cell + while the grid holds fewer than the count, else it takes the last tile's place. - **A hover card on the Tiles button says it** (owner feedback: "give me the hover info to right click over the tile button to adjust it"). It replaces the button's native title: "Tiles · N" (the remembered count, live), what a click does (open or close the grid), @@ -80,9 +88,10 @@ or settled a question the spec left open. The invariants as built are in back on the Tiles button, Tab, a click elsewhere and the keyboard leaving it for another element close it (the single view a close starts focuses its terminal when its replay lands; a menu left open behind that would send its keys there). A pick is remembered per - device in `codeman:tile-count` (`codeman:tile-grid` stays ids only) and opens that many - tiles; with the grid open it re-forms it (`_reformTileGrid`): the focused tile always - stays, the others leave from the end or join from tab order, filling empty cells first, + device in `codeman:tile-count` and opens that many tiles (a stored grid re-formed to + it, its tiles first in their cells); with the grid open it re-forms it + (`_reformTileGrid`): the focused tile always stays, the others leave from the end or + join from the ranking, filling empty cells first, every joining tile mounted and laid out before any connects (one fit, one PTY resize each), and a zoom the user chose ends. The other ways in (Ctrl/Cmd+click, a dragged tab, "Open group as tiles", Run) still add up to the cap of 6. @@ -392,16 +401,34 @@ the harness/model request; see "As built") ### Persistence -Decided: per device, restored on reload. Stored in localStorage key -`codeman:tile-grid`: +Decided: per device (per browser), restored on reload, and by the Tiles toggle +(decision 11). Stored in localStorage key `codeman:tile-grid`, never on the +server: ```json -{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null, - "colFr": [1, 1, 1], "rowFr": [1, 1] } +{ "v": 1, "open": true, "ids": ["…", null, "…"], "count": 3, "focused": "…", + "zoomed": null, "colFr": [1, 1, 1], "rowFr": [1, 1] } ``` -Ids only, never content. A pure sanitizer drops unknown, deleted, detached and -duplicate ids on load. Never restored in a solo window. +Session ids and the layout, never content. `ids` are the CELLS in reading order, +`null` for an empty one. `count` is how many tiles the user's own last change +left (open, add, remove by hand, a count picked): a session that goes away by +itself (deleted, popped out, its socket refused) does not lower it, so the next +time the grid opens the ranking fills that place, while a hole the user made +stays. It is written on every change (a move, a divider drag at pointer-up, a +tile added or removed, a count picked, a focus, a zoom) and kept, as +`open: false`, however the grid closes: the toggle, a non-tiled tab, +`leaveTiles` or a `#session=` link (which flips `open` only, so a gone id still +frees its cell), Home, the width gate, the last tile, "Open group as tiles", +closing or killing sessions. Nothing is written while a stored grid is being +put back, so a half-built grid never overwrites it. + +A pure sanitizer drops unknown, deleted, detached and duplicate ids on load, +reports the cells their sessions freed (`freed`), and derives `count` for a +value written before it existed (the number of sessions the cells name); the +old packed `ids` (no nulls) read as cells with no hole, and anything that is not +a v1 object is ignored. The format stays `v: 1`, so an older build still reads +a newer value (it ignores `count`). Never read or written in a solo window. The restore runs INSIDE `handleInit`, in place of its initial `selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not @@ -818,7 +845,8 @@ Separate follow-up PRs worth doing (see "Follow-ups"). open, and an open menu owns the Escape (it closes alone and the keyboard goes back to the Tiles button, like the tab-group menu). - **User text** (names) via `textContent` / attributes, never `innerHTML`. -- **No secrets in localStorage**: the stored grid holds ids only. +- **No secrets in localStorage**: the stored grid holds session ids and its + layout only, never content. - **Memory**: everything a tile creates is released in `destroy()`. ## Delivery: two PRs @@ -1060,7 +1088,8 @@ exits green. Use the browser runner for those files and read the file count. is on right-click of the button (its title says so, as do the wiki and the Help modal). Superseded in part by decision 10: right-click is now the count menu, and a remembered grid is filled to the count instead of opening - exactly as stored. + exactly as stored; decision 11 then restored "exactly as stored" and put a + ranking in place of the tab order. 9. **No + in the tile header.** Decided by the owner ("remove the + button from these views"): the header is `● name ……… ⋯ ⤢ ×`. The + menu and its "New session in this case" went with it. Tiles are added from the Tiles button @@ -1073,12 +1102,30 @@ exits green. Use the browser runner for those files and read the file count. right-click menu offers 2, 4 and 6, remembered per device; the session picker is gone, and decision 8's "picker on right-click" is superseded. The owner's answers on the details: the count wins over a remembered grid's - size (its tiles first, in their cells, holes filled first, then tab order); + size (its tiles first, in their cells, holes filled first, then tab order; + superseded by decision 11: a click brings the remembered grid back as it + was, and only a count picked in the menu re-forms it); Ctrl/Cmd+click with the grid closed opens the count in total, that session focused; shrinking keeps the focused tile; only the toggle animates the close; a remembered count larger than the window stays checked but greyed and a click opens what fits; the close keeps its dimmed still until the single view has painted (at most 700 ms); paced connect is in. +11. **The grid keeps the layout the user arranged, and a fresh one ranks by + work.** Decided by the owner ("when I hit the tiles button, it should prefer + to load in tiles that are working and then the most recent working, so the + oldest dont get opened ... when I moved around and modified it, save it per + browser the layout, so when I turn tiles off and on, always keep what the + last setting was, if there was no setting before take the working ones, that + ones needs input and then the most recent ones in order"). The layout + (cells and holes, tile count, divider sizes, focus, a zoom the user chose) + is saved per browser on every change and comes back exactly from the toggle + and a reload, however the grid closed; it is never filled to the remembered + count nor trimmed to the window. A session gone since frees its cell for the + ranking; with none left, the grid opens from the ranking (`rankTileSessions`: + working, then needing input, then most recent), which also fills every place + the grid fills on its own (a count picked in the menu, a freed cell, an open + split's fill). Supersedes decision 10's "the count wins over a remembered + grid's size"; the count menu itself, its counts and its other answers stay. ## Code anchors diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index c88bb50f..af705888 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -16,23 +16,32 @@ It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift ## Opening a grid -- **Tiles button**: one click shows the tiles straight away, as many as you last chose - (six until you choose; fewer if the window is too small or you have fewer sessions open). - You get the grid you last had, its tiles where they were, topped up with your open - sessions in tab order; if there is none, an open split's two first; otherwise your open - sessions in tab order, with the session you are on focused. With the grid open, the same - button closes it. +- **Tiles button**: one click shows the tiles straight away. If you have used the grid in + this browser before, you get it back exactly as you left it: the same sessions in the + same places, an empty place where you left one, the same number of tiles, your column + widths and row heights, the tile you were in, and a zoomed tile still zoomed. A session + closed since frees its place, which is filled the way a new grid is filled (below). + Otherwise, or when none of those sessions is left, you get as many tiles as you last + chose (six until you choose; fewer if the window is too small or you have fewer sessions + open): an open split's two first; otherwise the sessions that are working (the most + recently started first), then the ones waiting on you (red and yellow tabs), then the + rest, the most recently used first, so the oldest are the ones left out. The session you + are on always comes along and is focused. With the grid open, the same button closes it. - **Rest the pointer on the Tiles button** (or tab to it) for a short card that shows the - count it opens and what a click and a right-click do. + count you chose and what a click and a right-click do. - **Right-click the Tiles button** (or press `Shift+F10` on it) to choose how many tiles: - **2**, **4** or **6**, each drawn as its layout. Your choice is remembered on this device - and is what the next click opens. With the grid open, picking a count re-forms it: the - tile you are in always stays, extra tiles leave from the end, new ones join from your tab - order. A count the window is too small for is greyed out, with the reason. + **2**, **4** or **6**, each drawn as its layout. Your choice is remembered in this + browser. Picking a count opens the grid with that many tiles (the grid you left, its + tiles in their places, new ones in the empty places first), and it is what a click opens + when there is no grid to bring back. With the grid open, picking a count re-forms it: the + tile you are in always stays, extra tiles leave from the end, new ones join working ones + first, then the ones waiting on you, then the most recent. A count the window is too + small for is greyed out, with the reason. - **`Ctrl+Shift+G`**: exactly what a click on the Tiles button does. - **`Ctrl`+click (or `Cmd`+click) a tab**: adds that session to the grid and focuses it. With - the grid closed it opens what the Tiles button would show, with that session among them - (still the count you chose in total). On macOS use + the grid closed it opens what the Tiles button would show with that session added: in the + empty place while the grid has fewer tiles than the count you chose, else in place of the + last tile (never more than the count). On macOS use `Cmd`: `Ctrl`+click there opens the tab's rename instead. - **Drag a tab onto a tile** to replace that tile with it (the replaced session keeps running), or onto an empty slot to add it. Dragging a session that is already tiled onto @@ -89,7 +98,8 @@ keeps the focus. A moved tile takes the size of the place it lands in: column widths and row heights stay where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is, -the empty slot included, is saved with the grid and comes back on reload. +the empty slot included, is saved with the grid and comes back on reload and when you turn +the grid off and on. Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a new tile takes the first empty place. When the number of tiles changes the grid's shape (four @@ -122,8 +132,14 @@ session finder) shows that session on its own, the normal single view. The grid remembered: the Tiles button or `Ctrl+Shift+G` brings it straight back. Going Home does the same. Narrowing the window below the desktop width also returns to the single view. -The grid is saved on this device and comes back when you reload the page, with its focus, -zoom and column widths. A session that was closed in the meantime is simply left out. +The grid is saved in this browser every time you change it (moving, resizing, adding or +removing a tile, changing the count, focusing or zooming a tile), and never sent to the +server. However you leave it (the Tiles button, another tab, Home, a link, closing its last +tile or session), the Tiles button and a page reload bring it back as it was. A session +that was closed in the meantime frees its place for another one, picked the way a new grid +picks them; the place stays empty only when no other session is left. If the window has +become too small for all the tiles, the tile you were in fills the grid until the window is +wide enough again, and the rest of the layout is kept. Split shows the same logo, name and model above both of its panes. From 3a40dae1dd19bddc43051dbeadf617670946f77b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 06:06:22 +0200 Subject: [PATCH 4/8] test(tiles): pin the reload fill to the init payload's states and tab order A page reload restores the stored grid inside handleInit; a cell freed by a session gone since is filled from the ranking. handleInit syncs the tab order and loads the session states before the restore runs, so the fill ranks with real data: this test runs the real syncSessionOrder against a server snapshot order and shows a working session taking the freed cell ahead of the first tab. Pending approvals are seeded asynchronously after init, so the needs-input group cannot rank on a reload. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/tile-grid-restore.test.ts | 38 ++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/test/tile-grid-restore.test.ts b/test/tile-grid-restore.test.ts index 7712173f..ad71ef0a 100644 --- a/test/tile-grid-restore.test.ts +++ b/test/tile-grid-restore.test.ts @@ -29,7 +29,15 @@ 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 = () => {}) { +function pageLoad( + liveIds: string[], + setup: (app: GridApp) => void = () => {}, + { + sessions = liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })), + sessionOrder, + realOrder = false, + }: { sessions?: Array>; sessionOrder?: string[]; realOrder?: boolean } = {} +) { const app = makeGridApp(IDS); app._initGeneration = 0; app.activeSessionId = null; @@ -39,7 +47,7 @@ function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) { for (const name of [ '_clearTimer', '_updateCjkInputState', - 'syncSessionOrder', + ...(realOrder ? [] : ['syncSessionOrder']), '_loadTabLayout', 'cleanupAllFloatingWindows', 'startSystemStatsPolling', @@ -50,10 +58,7 @@ function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) { } app.$ = () => null; setup(app); - app.handleInit({ - sessions: liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })), - scheduledRuns: [], - }); + app.handleInit({ sessions, scheduledRuns: [], ...(sessionOrder ? { sessionOrder } : {}) }); return app; } @@ -148,6 +153,27 @@ describe('page load with a stored open grid', () => { expect(stored().ids).toEqual(['s-a', 's-c', 's-b']); }); + it('the reload fill ranks with the init payload: its states, stamps and synced tab order', () => { + // The tab order comes from the server snapshot (synced before the restore + // runs); the states from the session payload. Pending approvals arrive + // later (seedApprovals is async), so only status and stamps rank here. + storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 's-a' }); + const now = 1_000_000; + const app = pageLoad(IDS, () => {}, { + realOrder: true, + sessionOrder: ['s-c', 's-b', 's-a', 's-d'], + sessions: [ + { id: 's-a', name: 's-a', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now }, + { id: 's-b', name: 's-b', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now }, + { id: 's-c', name: 's-c', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 10 }, + { id: 's-d', name: 's-d', mode: 'claude', pid: 1, status: 'busy', lastSubmitAt: now - 500 }, + ], + }); + expect(app.sessionOrder).toEqual(['s-c', 's-b', 's-a', 's-d']); + // s-d is working: it takes the freed cell, though s-c comes first in the tab order. + expect(app._tileGrid.cells).toEqual(['s-a', 's-d', '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); From 7b95856cc5b226a3818042c2a674354039d3b281 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 06:26:39 +0200 Subject: [PATCH 5/8] fix(tiles): popping out a tiled session keeps the grid's count A session popped out while the grid was open (its pop-out button, or another dashboard tab announcing the pop-out over the window channel) left the grid through _markDetached, which removed its tile as if the user had closed it: the stored count dropped to five and its cell stayed a hole the ranking never filled, so after the window re-docked Tiles off and on showed five tiles and an empty cell. Popping out with the grid closed, or a reload reconcile, already kept the count and refilled the cell. _markDetached now removes the tile as `gone` (it left by itself), the same as a deleted session or a refused socket, so the count stays and the next activation fills the cell from the ranking. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture-invariants.md | 2 +- src/web/public/app.js | 7 +++++-- test/tile-grid-layout-memory.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2479d1c9..19989421 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -839,7 +839,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. -⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. +⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. ### Gesture control: the setting diff --git a/src/web/public/app.js b/src/web/public/app.js index 075664b7..dda35644 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1820,8 +1820,11 @@ class CodemanApp { _markDetached(id, on) { if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id); // A popped-out session's window owns its PTY size now, so it leaves the - // tile grid (one place per session in this browser tab). - if (on && this._tileGrid?.has(id)) this.removeTile(id); + // tile grid (one place per session in this browser tab). `gone`: it left by + // itself, not by a tile the user removed, so the grid's count stays and the + // ranking fills that cell the next time the grid opens, as when it pops out + // with the grid closed. + if (on && this._tileGrid?.has(id)) this.removeTile(id, { gone: true }); const container = this.$('sessionTabs'); const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`); if (tab) tab.classList.toggle('detached', on); diff --git a/test/tile-grid-layout-memory.test.ts b/test/tile-grid-layout-memory.test.ts index 261f42a9..3d3ebf6e 100644 --- a/test/tile-grid-layout-memory.test.ts +++ b/test/tile-grid-layout-memory.test.ts @@ -475,6 +475,29 @@ describe('a session that went away by itself while the grid was open', () => { expect(app._tileGrid.ids).not.toContain('s-e'); }); + it('popped out while open (the pop-out button, or another tab announcing it): the count stays, the cell refills after re-dock', () => { + const app = six(); + // What the real pop-out paths touch: detachSession and the window channel + // both mark through _markDetached; _redock clears it again. + app._detachOrphanStrikes = new Map(); + app._detachWatchTimers = new Map(); + app._redockGrace = new Map(); + app.detachedWindows = new Map(); + app._elemCache = {}; + app._onWindowMessage({ type: 'detached', id: 's-e' }); + expect(app.detachedSessions.has('s-e')).toBe(true); + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', null, 's-f']); + // Not the user's hand: the count stays six, the same as a pop-out with the grid closed. + expect(stored()).toMatchObject({ ids: ['s-a', 's-b', 's-c', 's-d', null, 's-f'], count: 6 }); + app._redock('s-e'); + expect(app.detachedSessions.has('s-e')).toBe(false); + app.toggleTileGrid(); + app.toggleTileGrid(); + // The freed cell is filled from the ranking (all quiet here: tab order, s-other first). + expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d', 's-other', 's-f']); + expect(stored().count).toBe(6); + }); + it('removed by hand instead: the hole stays a hole', () => { const app = six(['s-g']); app.removeTile('s-d'); From fb5c1bfca5659425dc9b8bd3f26b75a230078d16 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 06:31:26 +0200 Subject: [PATCH 6/8] docs(tiles): a reload's fill ranks without pending approvals, by design A page reload puts a stored open grid back inside handleInit and fills a cell freed since (its session deleted while the grid was closed) from the ranking. Pending approvals, the source of the needs-input group, reach the page only through seedApprovals, an async GET that lands after the restore, so for that one fill a session waiting on a permission dialog or an unseen finished turn ranks with the quiet ones (working sessions still rank first). Holding the fill back until the seed lands would open fewer tiles, which can be another shape, and then reshape the grid and move the user's tiles a moment after the reload, so a late seed never re-forms a restored grid. The plan, architecture-invariants#tile-grid and the wiki now say so, and a test pins it: the seed arrives after the restore, the ranking then puts the needs-input session first, and the restored cells and the stored layout stay as they came back. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture-invariants.md | 2 +- docs/tile-grid-plan.md | 11 +++++++ docs/wiki/Tile-Grid.md | 8 +++-- test/tile-grid-restore.test.ts | 52 +++++++++++++++++++++++++++++++-- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 19989421..ce51923b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -839,7 +839,7 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t ⚠️ **Attach.** A tile whose session has no PTY (`pid === null`) or whose socket closed because it exited (4009) shows an Attach overlay (absolute, the body keeps its size): `POST /interactive` (or `/shell`) with NO body, one in flight per session, a tripped PTY-exit breaker only through the same confirm as the single view, then the tile is remounted (a stopped socket cannot reconnect). The routes report a refusal in the ENVELOPE of a 200, so the response body is read, not `res.ok`. An agent that exited in a live pane (`paneExit`) cannot be started again in place (both routes refuse while the pane's tmux client runs): its tile shows the exit and points at Close session. -⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. +⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`. ### Gesture control: the setting diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 155f39f6..768d0153 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -439,6 +439,17 @@ the main terminal never loads on that page load. A later `handleInit` (SSE reconnect after a server restart, the `keepTerminal` branch) reconciles ids against the live list without rebuilding tiles that are still alive. +A cell freed since the grid was stored is filled during that restore, from a +ranking that knows each session's status and stamps (the init payload) but not +yet its pending approvals: `seedApprovals` asks the server for them +asynchronously, and the restore has run by the time they land. So on a reload a +session waiting on a permission dialog or an unseen finished turn ranks with the +quiet ones for that one fill (working sessions still rank first). Accepted: a +fill held back for the approvals would open fewer tiles, which can be another +shape, and then reshape the grid and move the user's tiles a second after the +reload; so approvals that land later never re-form a restored grid. The Tiles +toggle, run once the page has loaded, ranks with them. + ### Gating - Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index af705888..cbb5b2ad 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -137,9 +137,11 @@ removing a tile, changing the count, focusing or zooming a tile), and never sent server. However you leave it (the Tiles button, another tab, Home, a link, closing its last tile or session), the Tiles button and a page reload bring it back as it was. A session that was closed in the meantime frees its place for another one, picked the way a new grid -picks them; the place stays empty only when no other session is left. If the window has -become too small for all the tiles, the tile you were in fills the grid until the window is -wide enough again, and the rest of the layout is kept. +picks them; the place stays empty only when no other session is left. Right after a page +reload the page does not know yet which sessions are waiting for your answer, so that pick +goes by which sessions are working and which you used last. If the window has become too +small for all the tiles, the tile you were in fills the grid until the window is wide enough +again, and the rest of the layout is kept. Split shows the same logo, name and model above both of its panes. diff --git a/test/tile-grid-restore.test.ts b/test/tile-grid-restore.test.ts index ad71ef0a..00b29a5a 100644 --- a/test/tile-grid-restore.test.ts +++ b/test/tile-grid-restore.test.ts @@ -12,8 +12,9 @@ * `selectSession(restoreId, { auto: true })`: with a stored open grid the * main terminal never loads on that page load (no select, no socket, no * capture), a deleted or detached session frees its cell for the ranking - * to fill, duplicate ids are dropped, and the stored fractions and zoom - * come back. A narrow window keeps the single view. + * to fill (on status and stamps: pending approvals are seeded after, and + * never re-form the restored grid), 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 @@ -174,6 +175,53 @@ describe('page load with a stored open grid', () => { expect(app._tileGrid.cells).toEqual(['s-a', 's-d', 's-b']); }); + it('pending approvals seeded after the reload never re-form the restored grid (known limit of the reload fill)', async () => { + // seedApprovals is async (GET /api/approvals), so the freed cell is filled + // from status and stamps alone. A needs-input session the seed reveals a + // moment later does not take a tile from the grid already back on screen: + // a late fill would reshape it (fewer tiles opened is another shape) and + // move the user's tiles a second after the reload. + storeGrid({ ids: ['s-a', 'gone', 's-b'], focused: 's-a' }); + const now = 1_000_000; + let release!: () => void; + const app = pageLoad( + IDS, + (a) => { + a.tabAlerts = new Map(); + a.seedApprovals = vi.fn( + () => + new Promise((resolve) => { + release = () => { + a.setPendingHook('s-d', 'permission_prompt'); + resolve(); + }; + }) + ); + }, + { + sessions: [ + { id: 's-a', name: 's-a', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now }, + { id: 's-b', name: 's-b', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now }, + { id: 's-c', name: 's-c', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 10 }, + { id: 's-d', name: 's-d', mode: 'claude', pid: 1, status: 'idle', lastActivityAt: now - 500 }, + ], + } + ); + expect(app.seedApprovals).toHaveBeenCalledTimes(1); + // Before the seed lands: all quiet, the most recent (s-c) takes the freed cell. + expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-b']); + const before = stored(); + release(); + await Promise.resolve(); + await Promise.resolve(); + // The seed made s-d need input: the ranking now puts it first... + expect(app._tileGridRanking()[0]).toBe('s-d'); + app._renderTileChrome(); + // ...but the restored grid and the stored layout stay as they came back. + expect(app._tileGrid.cells).toEqual(['s-a', 's-c', 's-b']); + expect(stored()).toEqual(before); + }); + 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); From 8b2cb4994073d27a9b644941522adafb4c61eac6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 06:33:00 +0200 Subject: [PATCH 7/8] docs(tiles): a reload brings the grid back only when it was open The wiki, decision 11 in the tile-grid plan, its Persistence section, the layout-memory test header and two tile-grid.js comments said the Tiles button and a page reload bring the grid back however it was closed. Only the Tiles button does: a grid closed before the reload stays stored as open: false, the page shows the single view, and the toggle puts the grid back. The behaviour was right; the wording now says that. A test pins it through the real close path: arrange the grid, close it with the toggle, start a fresh page, and the restore leaves the single view while the toggle still brings back the cells, count, divider sizes, focus and zoom. ('a stored closed grid leaves the single view' in tile-grid-restore.test.ts covers the same from a raw stored value through handleInit.) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/tile-grid-plan.md | 12 ++++++---- docs/wiki/Tile-Grid.md | 8 ++++--- src/web/public/tile-grid.js | 8 +++---- test/tile-grid-layout-memory.test.ts | 34 +++++++++++++++++++++------- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 768d0153..d3711c80 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -401,8 +401,8 @@ the harness/model request; see "As built") ### Persistence -Decided: per device (per browser), restored on reload, and by the Tiles toggle -(decision 11). Stored in localStorage key `codeman:tile-grid`, never on the +Decided: per device (per browser), restored on reload when it was open, and by +the Tiles toggle however it was closed (decision 11). Stored in localStorage key `codeman:tile-grid`, never on the server: ```json @@ -1129,9 +1129,11 @@ exits green. Use the browser runner for those files and read the file count. last setting was, if there was no setting before take the working ones, that ones needs input and then the most recent ones in order"). The layout (cells and holes, tile count, divider sizes, focus, a zoom the user chose) - is saved per browser on every change and comes back exactly from the toggle - and a reload, however the grid closed; it is never filled to the remembered - count nor trimmed to the window. A session gone since frees its cell for the + is saved per browser on every change and comes back exactly from the toggle, + however the grid closed, and from a reload when the grid was open (a grid + closed before the reload stays remembered for the toggle; the page shows the + single view); it is never filled to the remembered count nor trimmed to the + window. A session gone since frees its cell for the ranking; with none left, the grid opens from the ranking (`rankTileSessions`: working, then needing input, then most recent), which also fills every place the grid fills on its own (a count picked in the menu, a freed cell, an open diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index cbb5b2ad..cfe56135 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -98,8 +98,8 @@ keeps the focus. A moved tile takes the size of the place it lands in: column widths and row heights stay where you dragged the dividers. Tiles do not move while one is zoomed. Where everything is, -the empty slot included, is saved with the grid and comes back on reload and when you turn -the grid off and on. +the empty slot included, is saved with the grid and comes back when you turn the grid off +and on, and on a page reload while the grid is open. Closing a tile leaves its place empty when the grid keeps its shape (six tiles to five), and a new tile takes the first empty place. When the number of tiles changes the grid's shape (four @@ -135,7 +135,9 @@ same. Narrowing the window below the desktop width also returns to the single vi The grid is saved in this browser every time you change it (moving, resizing, adding or removing a tile, changing the count, focusing or zooming a tile), and never sent to the server. However you leave it (the Tiles button, another tab, Home, a link, closing its last -tile or session), the Tiles button and a page reload bring it back as it was. A session +tile or session), the Tiles button brings it back as it was. A page reload brings it back +when the grid was open; after you left it, a reload shows the single view and the Tiles +button still brings the grid back. A session that was closed in the meantime frees its place for another one, picked the way a new grid picks them; the place stays empty only when no other session is left. Right after a page reload the page does not know yet which sessions are waiting for your answer, so that pick diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 090b3061..f761367c 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -29,8 +29,8 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size'; // how many tiles the user's own last change left (a session that went away by // itself does not lower it). Session ids and layout only, never content. // Written on every change while the grid is open; `open: false` keeps it for -// the Tiles toggle, which brings it back exactly; restored on reload inside -// handleInit. Never sent to the server. +// the Tiles toggle, which brings it back exactly; a grid stored open is +// restored on reload inside handleInit. Never sent to the server. const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; // The count last picked in the Tiles button's right-click menu (2, 4 or 6; // owner decision 10), per device: what a click opens when there is no stored @@ -432,8 +432,8 @@ Object.assign(CodemanApp.prototype, { * queued loads dropped), the main terminal unparked. * * `keepStored` (every caller) keeps the grid as it was, holes, count, sizes, - * focus and zoom, for the Tiles toggle and the next reload to bring back; - * false would forget it. `reselect` shows the focused session in the single + * focus and zoom, closed, for the Tiles toggle to bring back (a reload + * restores only a grid stored open); false would forget it. `reselect` shows the focused session in the single * view through a forced reload; pass false when the caller selects * something itself. `animate` (the Tiles toggle only, owner answer 4) * leaves a still copy of the tiles over the stage until the single view has diff --git a/test/tile-grid-layout-memory.test.ts b/test/tile-grid-layout-memory.test.ts index 3d3ebf6e..d554c6ff 100644 --- a/test/tile-grid-layout-memory.test.ts +++ b/test/tile-grid-layout-memory.test.ts @@ -7,13 +7,14 @@ * - Saved on every change (`codeman:tile-grid` in localStorage, never the * server): which session sits in which cell, holes included, the tile count, * the divider sizes, the focused tile and a zoom the user chose. - * - The Tiles toggle and a page reload bring back exactly that, however the - * grid was closed: the toggle, a tab that is not tiled, a followed link - * (`leaveTiles`, or `#session=` on load), Home, the window narrowing past the - * desktop gate, the last tile removed, "Open group as tiles", closing a - * session, killing them all. It is never filled to the remembered count and - * never trimmed to the window (a too-small window shows the focused tile - * until it fits, the arrangement kept). + * - The Tiles toggle brings back exactly that, however the grid was closed: + * the toggle, a tab that is not tiled, a followed link (`leaveTiles`, or + * `#session=` on load), Home, the window narrowing past the desktop gate, + * the last tile removed, "Open group as tiles", closing a session, killing + * them all. A page reload brings it back only when the grid was open (a + * closed one stays remembered for the toggle). It is never filled to the + * remembered count and never trimmed to the window (a too-small window + * shows the focused tile until it fits, the arrangement kept). * - A session that no longer exists frees its cell, and the ranking fills it * (the hole a user left stays a hole); with none of the stored sessions left * the grid opens from the ranking, as with nothing stored. @@ -255,7 +256,7 @@ describe('saved on every change', () => { }); }); -describe('the Tiles toggle and a reload bring back exactly what was arranged', () => { +describe('the Tiles toggle (and a reload while it is open) brings back exactly what was arranged', () => { it('off and on: the cells and the hole, the count, the divider sizes, the focus and the zoom', async () => { const app = layoutApp(); await arrange(app); @@ -291,6 +292,23 @@ describe('the Tiles toggle and a reload bring back exactly what was arranged', ( expect(layoutOf(next)).toEqual(before); }); + it('a reload after the grid was closed: the single view, and the Tiles toggle brings it back exactly', async () => { + const app = layoutApp(); + await arrange(app); + const before = layoutOf(app); + app.toggleTileGrid(); + section.children = []; + FakeTile.all = []; + const next = layoutApp(); + // The reload leaves it closed (handleInit then selects the single view)... + expect(next._restoreTileGrid()).toBe(false); + expect(next._tilesOwnTerminal()).toBe(false); + expect(stored()).toMatchObject({ open: false, ids: ARRANGED_CELLS }); + // ...and remembered: the toggle puts it back as it was. + next.toggleTileGrid(); + expect(layoutOf(next)).toEqual(before); + }); + it('never trimmed to a smaller window: the focused tile shows alone, and the arrangement is kept for a wider one', async () => { const app = layoutApp(); app.openTileGrid(SIX, { focusedId: 's-c' }); From 0c6cbd0718cbf748ecaa8c3b790595c2e46b6e77 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 06:38:46 +0200 Subject: [PATCH 8/8] docs(tiles): a popped-out session frees its place too The wiki said a session closed in the meantime frees its place for the ranking. A session popped out into its own window does the same (it is never tiled while its window owns its PTY size, and since the previous fix popping it out with the grid open keeps the count, too), so the sentence now names both. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/wiki/Tile-Grid.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/wiki/Tile-Grid.md b/docs/wiki/Tile-Grid.md index cfe56135..0a788c0b 100644 --- a/docs/wiki/Tile-Grid.md +++ b/docs/wiki/Tile-Grid.md @@ -137,13 +137,13 @@ removing a tile, changing the count, focusing or zooming a tile), and never sent server. However you leave it (the Tiles button, another tab, Home, a link, closing its last tile or session), the Tiles button brings it back as it was. A page reload brings it back when the grid was open; after you left it, a reload shows the single view and the Tiles -button still brings the grid back. A session -that was closed in the meantime frees its place for another one, picked the way a new grid -picks them; the place stays empty only when no other session is left. Right after a page -reload the page does not know yet which sessions are waiting for your answer, so that pick -goes by which sessions are working and which you used last. If the window has become too -small for all the tiles, the tile you were in fills the grid until the window is wide enough -again, and the rest of the layout is kept. +button still brings the grid back. A session that was closed or popped out into its own +window in the meantime frees its place for another one, picked the way a new grid picks +them; the place stays empty only when no other session is left. Right after a page reload +the page does not know yet which sessions are waiting for your answer, so that pick goes by +which sessions are working and which you used last. If the window has become too small for +all the tiles, the tile you were in fills the grid until the window is wide enough again, +and the rest of the layout is kept. Split shows the same logo, name and model above both of its panes.