mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 02:09:42 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user