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