Merge the Tiles open order and kept layout into the 1.41.0 release branch

This commit is contained in:
Codeman maintainer
2026-10-10 06:49:25 +02:00
17 changed files with 1596 additions and 238 deletions
+3 -2
View File
@@ -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
);
+22 -8
View File
@@ -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]);
});
+8 -4
View File
@@ -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', () => {
+617
View File
@@ -0,0 +1,617 @@
/**
* @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 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.
* - 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<string | null>; focusedId: string } | null;
type Sanitized = {
ids: string[];
cells: Array<string | null>;
freed: number[];
count: number;
open: boolean;
focused: string | null;
zoomed: string | null;
colFr: number[] | null;
} | null;
type Helpers = {
sanitizeTileGridState(raw: unknown, live: Iterable<string>, detached?: Set<string>): 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<string, unknown>, 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<string, unknown>).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 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);
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('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' });
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<string, unknown>).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('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');
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');
});
});
+3
View File
@@ -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],
+10 -8
View File
@@ -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.
@@ -44,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(
@@ -59,9 +61,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 +71,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 +173,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() });
+258
View File
@@ -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<string, unknown>): 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<string, unknown>) => 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]);
});
});
+114 -30
View File
@@ -1,15 +1,20 @@
/**
* @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 (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
@@ -25,7 +30,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<Record<string, unknown>>; sessionOrder?: string[]; realOrder?: boolean } = {}
) {
const app = makeGridApp(IDS);
app._initGeneration = 0;
app.activeSessionId = null;
@@ -35,7 +48,7 @@ function pageLoad(liveIds: string[], setup: (app: GridApp) => void = () => {}) {
for (const name of [
'_clearTimer',
'_updateCjkInputState',
'syncSessionOrder',
...(realOrder ? [] : ['syncSessionOrder']),
'_loadTabLayout',
'cleanupAllFloatingWindows',
'startSystemStatsPolling',
@@ -46,10 +59,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;
}
@@ -58,13 +68,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 +105,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 +114,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 +145,81 @@ 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('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', () => {
@@ -179,26 +259,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', () => {
+5 -4
View File
@@ -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']);
});
+3 -2
View File
@@ -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 }]]);