feat(tiles): open and fill tiles by work, then input, then recency

The Tiles button with no stored grid opened the first tabs, so the oldest
sessions got tiles while the busy ones were left out. It now ranks the
open sessions: working first (the most recently started turn first, keyed
off lastSubmitAt only, since a working pane's activity stamp is always
"now"), then the ones needing input (the red and yellow tab alerts), then
everything else by most recent activity, tab order breaking ties.

The ranking is pure (rankTileSessions in constants.js) and reuses the home
screens' classification (_mobileOverviewState) and stamps. It feeds the
toggle's fresh set, a count picked in the menu, Ctrl/Cmd+click and an open
split's fill. The active session is still always included and focused,
detached sessions are never tiled, and a page without the classifier falls
back to tab order. The vm harness now loads mobile-overview.js so the app
tests exercise the real classifier.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-10 05:39:51 +02:00
parent 55cd59a346
commit 950450b137
5 changed files with 403 additions and 30 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
);
+9 -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.
@@ -59,9 +60,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 +70,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 +172,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]);
});
});