From 7b95856cc5b226a3818042c2a674354039d3b281 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 06:26:39 +0200 Subject: [PATCH] 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');