feat(tiles): keep the arranged layout per browser across toggles and reloads

The Tiles toggle used to fill a remembered grid to the count picked in the
menu (refilling a hole the user made, or trimming tiles), removing the last
tile or "Open group as tiles" forgot it, and Ctrl/Cmd+click repacked it.
The owner asked for the opposite: "when I turn tiles off and on, always
keep what the last setting was".

Now the layout the user arranged (which session sits in which cell, holes
included, the tile count, the divider sizes, the focused tile and a zoom
they chose) is written to localStorage on every change and comes back
exactly from the toggle, Ctrl+Shift+G and a reload, however the grid was
closed (toggle, a non-tiled tab, leaveTiles or a #session= link, Home, the
width gate, the last tile, a group, closing or killing sessions). Nothing
of it reaches the server.

- The stored value stays v: 1 and gains `count`: how many tiles the user's
  own last change left. A session that goes away by itself (deleted,
  popped out, socket refused) does not lower it, so the next activation
  fills that cell from the ranking; a hole the user made stays a hole.
- sanitizeTileGridState reports `freed` cells (sessions gone since) and
  derives `count` for values written before it; restoreTileGridCells (pure)
  puts a stored grid back, freed cells first, never trimmed to the window
  (a too-small window shows the focused tile until it fits).
- Only when none of the stored sessions survive does activation rank from
  scratch. A count picked in the menu still re-forms the grid to it.
- Nothing is persisted while a stored grid is being put back, and a
  #session= link only flips `open`, keeping gone ids for the fill.
- The hover card no longer promises "a click opens N" when a stored grid
  will open instead.

This supersedes decision 10's answer that the count wins over a
remembered grid's size; the affected tests say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-10 05:58:17 +02:00
parent 950450b137
commit 16980d644d
11 changed files with 948 additions and 161 deletions
+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', () => {
+576
View File
@@ -0,0 +1,576 @@
/**
* @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 and a page reload bring 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. 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 bring 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('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('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],
+1
View File
@@ -45,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(
+34 -24
View File
@@ -1,15 +1,19 @@
/**
* @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, 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
@@ -58,13 +62,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 +99,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 +108,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 +139,13 @@ 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('brings back the fractions (same layout only) and a zoom the user chose', () => {
@@ -179,26 +185,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 }]]);