mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 09:49:41 +02:00
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) <noreply@anthropic.com>
355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
/**
|
|
* @fileoverview The grid survives a page reload, per device (`codeman:tile-grid`).
|
|
*
|
|
* - 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), a deleted or detached session frees its cell for the ranking
|
|
* 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
|
|
* tiled id (snapshot, its localStorage copy, buffer cache).
|
|
*
|
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { FakeTile, localStore, makeGridApp, resetGridHarness, windowStub, type GridApp } from './mocks/tile-grid-vm.js';
|
|
|
|
const IDS = ['s-a', 's-b', 's-c', 's-d'];
|
|
const KEY = 'codeman:tile-grid';
|
|
const stored = () => JSON.parse(localStore.get(KEY) ?? 'null');
|
|
|
|
/** A fresh page: the app as handleInit leaves it on its FIRST run (gen 1). */
|
|
function pageLoad(
|
|
liveIds: string[],
|
|
setup: (app: GridApp) => void = () => {},
|
|
{
|
|
sessions = liveIds.map((id) => ({ id, name: id, mode: 'claude', pid: 1 })),
|
|
sessionOrder,
|
|
realOrder = false,
|
|
}: { sessions?: Array<Record<string, unknown>>; sessionOrder?: string[]; realOrder?: boolean } = {}
|
|
) {
|
|
const app = makeGridApp(IDS);
|
|
app._initGeneration = 0;
|
|
app.activeSessionId = null;
|
|
app.selectSession = vi.fn();
|
|
app._fetchTerminalCapture = vi.fn();
|
|
app._resetAllAppState = vi.fn(() => app.sessions.clear());
|
|
for (const name of [
|
|
'_clearTimer',
|
|
'_updateCjkInputState',
|
|
...(realOrder ? [] : ['syncSessionOrder']),
|
|
'_loadTabLayout',
|
|
'cleanupAllFloatingWindows',
|
|
'startSystemStatsPolling',
|
|
'stopSystemStatsPolling',
|
|
'updateCost',
|
|
]) {
|
|
app[name] = vi.fn();
|
|
}
|
|
app.$ = () => null;
|
|
setup(app);
|
|
app.handleInit({ sessions, scheduledRuns: [], ...(sessionOrder ? { sessionOrder } : {}) });
|
|
return app;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetGridHarness();
|
|
});
|
|
|
|
describe('what is stored', () => {
|
|
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],
|
|
rowFr: [1, 1],
|
|
});
|
|
});
|
|
|
|
it('focus, a zoom by hand and a divider drag are written as they happen', () => {
|
|
const app = makeGridApp(IDS);
|
|
app.openTileGrid(IDS);
|
|
app.selectSession('s-c');
|
|
expect(stored().focused).toBe('s-c');
|
|
app.zoomTile('s-c');
|
|
expect(stored().zoomed).toBe('s-c');
|
|
app.zoomTile('s-c');
|
|
app._tileGrid.colFr = [2, 1];
|
|
app._persistTileGrid();
|
|
expect(stored().colFr).toEqual([2, 1]);
|
|
});
|
|
|
|
it('an automatic zoom (window too small) is not stored: it is worked out again', () => {
|
|
const app = makeGridApp(IDS);
|
|
app.openTileGrid(IDS);
|
|
app._tileGrid.zoomedId = 's-a';
|
|
app._tileGrid.autoZoom = true;
|
|
app._persistTileGrid();
|
|
expect(stored().zoomed).toBeNull();
|
|
});
|
|
|
|
it('closing keeps it remembered (open: false); the last tile leaving keeps it too', () => {
|
|
const app = makeGridApp(IDS);
|
|
app.selectSession = vi.fn();
|
|
app.openTileGrid(IDS);
|
|
app.closeTileGrid({ reselect: false });
|
|
expect(stored()).toMatchObject({ open: false, ids: IDS });
|
|
|
|
app.openTileGrid(['s-a']);
|
|
app.removeTile('s-a');
|
|
expect(stored()).toMatchObject({ open: false, ids: ['s-a'], count: 1 });
|
|
});
|
|
|
|
it('a solo window never writes', () => {
|
|
const app = makeGridApp(IDS);
|
|
app.openTileGrid(IDS);
|
|
localStore.delete(KEY);
|
|
app.isSoloWindow = true;
|
|
app._persistTileGrid();
|
|
expect(localStore.has(KEY)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('page load with a stored open grid', () => {
|
|
const storeGrid = (state: Record<string, unknown>) =>
|
|
localStore.set(KEY, JSON.stringify({ v: 1, open: true, zoomed: null, ...state }));
|
|
|
|
it('restores the grid IN PLACE of the single view: the main terminal never loads', () => {
|
|
storeGrid({ ids: IDS, focused: 's-c' });
|
|
const app = pageLoad([...IDS, 's-other']);
|
|
|
|
expect(app._tilesOwnTerminal()).toBe(true);
|
|
expect(app._tileGrid.ids).toEqual(IDS);
|
|
expect(app.activeSessionId).toBe('s-c');
|
|
expect(app.selectSession).not.toHaveBeenCalled();
|
|
expect(app._connectWs).not.toHaveBeenCalled();
|
|
expect(app._fetchTerminalCapture).not.toHaveBeenCalled();
|
|
// Restoring is the app's choice of focus: no idle alert spent.
|
|
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('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);
|
|
// 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-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('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<void>((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);
|
|
expect(app._tileGrid.colFr).toEqual([2, 1]);
|
|
// Three row fractions do not fit a 2x2: equal rows.
|
|
expect(app._tileGrid.rowFr).toEqual([1, 1]);
|
|
expect(app._tileGrid.zoomedId).toBe('s-b');
|
|
expect(app.activeSessionId).toBe('s-b');
|
|
});
|
|
|
|
it('a stored 3x3 (before the cap of 6) comes back as its first six, focus kept, a dropped zoom cleared', () => {
|
|
const nine = Array.from({ length: 9 }, (_, i) => `n-${i + 1}`);
|
|
storeGrid({ ids: nine, focused: 'n-5', zoomed: 'n-8', colFr: [2, 1, 1], rowFr: [1, 1, 1] });
|
|
const app = pageLoad(nine);
|
|
expect(app._tileGrid.ids).toEqual(nine.slice(0, 6));
|
|
expect(FakeTile.all.filter((t) => !t._destroyed)).toHaveLength(6);
|
|
expect(app.activeSessionId).toBe('n-5');
|
|
expect(app._tileGrid.zoomedId).toBeNull();
|
|
// 3x2 now: the columns still match, the three stored rows do not.
|
|
expect(app._tileGrid.colFr).toEqual([2, 1, 1]);
|
|
expect(app._tileGrid.rowFr).toEqual([1, 1]);
|
|
expect(stored().ids).toEqual(nine.slice(0, 6));
|
|
});
|
|
|
|
it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => {
|
|
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
|
|
localStore.set('codeman:tile-count', '2');
|
|
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
|
|
expect(app._tilesOwnTerminal()).toBe(false);
|
|
expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true });
|
|
|
|
app.activeSessionId = 's-a';
|
|
app.toggleTileGrid();
|
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
|
expect(app.activeSessionId).toBe('s-c');
|
|
});
|
|
|
|
it('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();
|
|
expect(app._tileGrid.cells).toEqual(['s-b', 's-c']);
|
|
expect(app.activeSessionId).toBe('s-c');
|
|
});
|
|
|
|
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();
|
|
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', () => {
|
|
localStore.set('codeman:tile-count', '6');
|
|
storeGrid({ ids: ['s-b', 's-c'], focused: 's-c' });
|
|
const app = pageLoad(IDS);
|
|
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
|
|
});
|
|
|
|
it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => {
|
|
storeGrid({ ids: IDS, focused: 's-a' });
|
|
windowStub.innerWidth = 1100;
|
|
const app = pageLoad(IDS);
|
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
|
expect(app.selectSession).toHaveBeenCalled();
|
|
expect(stored().open).toBe(true);
|
|
});
|
|
|
|
it('a solo window never restores it', () => {
|
|
storeGrid({ ids: IDS, focused: 's-a' });
|
|
const app = pageLoad(IDS, (a) => {
|
|
a.isSoloWindow = true;
|
|
a._applySoloMode = vi.fn();
|
|
});
|
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
|
expect(FakeTile.all).toHaveLength(0);
|
|
});
|
|
|
|
it.each([
|
|
['another version', JSON.stringify({ v: 2, open: true, ids: IDS })],
|
|
['not JSON', '{oops'],
|
|
['not an object', '[1,2]'],
|
|
])('ignores a stored value that is %s', (_label, raw) => {
|
|
localStore.set(KEY, raw);
|
|
const app = pageLoad(IDS);
|
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
|
expect(app.selectSession).toHaveBeenCalled();
|
|
});
|
|
|
|
it('a #session= link on load wins, and the grid stays remembered, closed', () => {
|
|
storeGrid({ ids: IDS, focused: 's-a' });
|
|
const app = pageLoad(IDS, (a) => {
|
|
a._urlSessionId = 's-d';
|
|
});
|
|
expect(app._tileGrid?.open ?? false).toBe(false);
|
|
expect(app.selectSession).toHaveBeenCalledWith('s-d', { auto: true, leaveTiles: true });
|
|
expect(stored()).toMatchObject({ open: false, ids: IDS });
|
|
});
|
|
});
|
|
|
|
describe('leaving the grid', () => {
|
|
it("invalidates the main terminal's cached content for every tiled id", () => {
|
|
const app = makeGridApp(IDS);
|
|
app.selectSession = vi.fn();
|
|
for (const id of [...IDS, 's-other']) {
|
|
app._xtermSnapshots.set(id, 'old');
|
|
app.terminalBufferCache.set(id, 'old');
|
|
localStore.set(`codeman-xs-${id}`, 'old');
|
|
}
|
|
app.openTileGrid(['s-a', 's-b']);
|
|
app.closeTileGrid();
|
|
for (const id of ['s-a', 's-b']) {
|
|
expect(app._xtermSnapshots.has(id)).toBe(false);
|
|
expect(app.terminalBufferCache.has(id)).toBe(false);
|
|
expect(localStore.has(`codeman-xs-${id}`)).toBe(false);
|
|
}
|
|
expect(app._xtermSnapshots.get('s-c')).toBe('old');
|
|
});
|
|
});
|