Files
Codeman/test/session-overview-order.test.ts
Codeman maintainer 19aabe34d2 feat: sort both home-screen session lists by activity, not tab order
The phone overview and the desktop tab rail list the same sessions, so
they now share one order (CodemanSessionOrder in constants.js, pure and
unit-tested): blocked on you first (longest-blocked at the top), then
running longest-turn-first, then quiet most-recently-quiet first.

The tiebreak flips direction halfway down on purpose: for a state a
session is still in, longer is more urgent; for a state it has stopped
in, more recent is more relevant. The running group keys off the pane's
last Enter (lastSubmitAt), never lastActivityAt, because a working pane
repaints about once a second and would rank every turn as freshly
started. A 0 stamp means "unknown" and sorts last within its state.

The desktop rail was previously in raw tab order. Its number badge stays
the Alt+1..9 index, so on a sorted rail it deliberately no longer runs
1,2,3 downward: it names a shortcut, not a row position. Its second
stamp changes from "active 3m ago" to the state duration the order is
computed from ("created 1d ago . working 40m"), since both working rows
otherwise read "active just now" and the order looked arbitrary.

The tab strip itself is untouched: still user-ordered and drag-sortable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:11:00 +02:00

176 lines
7.0 KiB
TypeScript

// Port: none (pure comparator — no browser, no server).
//
// `CodemanSessionOrder` (src/web/public/constants.js) is the single row order
// behind both home screens: the phone overview and the desktop tab rail. It is
// the one place the two surfaces can disagree about which session you should
// look at next, which is why it is pure and pinned here rather than living
// inside either renderer.
//
// The rule it encodes, and the thing worth protecting: the tiebreak FLIPS
// direction halfway down the list. For a state a session is still in, older is
// more urgent (blocked longest, running longest). For a state it has stopped
// in, newer is more relevant (just finished beats abandoned yesterday).
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
type Row = {
id: string;
state: string;
lastActivityAt?: number;
lastSubmitAt?: number;
orderIndex?: number;
};
function loadOrderHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (
context.window as {
CodemanSessionOrder: {
RANK: Record<string, number>;
anchor: (row: Row) => number;
compare: (a: Row, b: Row) => number;
sort: (rows: Row[]) => Row[];
};
}
).CodemanSessionOrder;
}
const order = loadOrderHelper();
const ids = (rows: Row[]) => order.sort(rows).map((r) => r.id);
describe('session overview order: state ranking', () => {
it('puts everything blocked on a human above everything else', () => {
// Red question, then a hard error, then the yellow "waiting for input"
// prompt, then work, then whatever has stopped.
const rows: Row[] = [
{ id: 'done', state: 'done' },
{ id: 'idle', state: 'idle' },
{ id: 'working', state: 'working' },
{ id: 'waiting', state: 'waiting' },
{ id: 'error', state: 'error' },
{ id: 'needs', state: 'needs' },
];
expect(ids(rows)).toEqual(['needs', 'error', 'waiting', 'working', 'idle', 'done']);
});
it('sorts an unknown state last instead of dropping it or crashing', () => {
// A state added to one renderer and not to the rank map must still render,
// just at the bottom — a missing row is a worse failure than a misplaced one.
const rows: Row[] = [
{ id: 'mystery', state: 'quantum' },
{ id: 'done', state: 'done' },
];
expect(ids(rows)).toEqual(['done', 'mystery']);
});
});
describe('session overview order: in-progress states sort oldest first', () => {
it('ranks the longest-running turn above a turn that just started', () => {
const rows: Row[] = [
{ id: 'young', state: 'working', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
{ id: 'old', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
];
expect(ids(rows)).toEqual(['old', 'young']);
});
it('measures a running turn from the last Enter, not the last repaint', () => {
// A working pane repaints about once a second, so last-activity is always
// "now" and would rank every running turn identically.
expect(order.anchor({ id: 'w', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 999_000 })).toBe(1_000);
expect(order.anchor({ id: 'i', state: 'idle', lastSubmitAt: 1_000, lastActivityAt: 999_000 })).toBe(999_000);
});
it('falls back to last activity for a working pane that never submitted', () => {
// Spawned with its prompt on the command line, or an external CLI whose
// Enter never went through Codeman. Its fallback stamp is ~now, so it sits
// at the SHORT end of the running group rather than falsely leading it.
const rows: Row[] = [
{ id: 'no-submit', state: 'working', lastActivityAt: 10_000 },
{ id: 'submitted', state: 'working', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
];
expect(ids(rows)).toEqual(['submitted', 'no-submit']);
});
it('ranks the longest-blocked session above one that just asked', () => {
const rows: Row[] = [
{ id: 'just-asked', state: 'needs', lastActivityAt: 9_000 },
{ id: 'starving', state: 'needs', lastActivityAt: 1_000 },
];
expect(ids(rows)).toEqual(['starving', 'just-asked']);
});
});
describe('session overview order: stopped states sort newest first', () => {
it('puts the session that just went quiet above one idle since yesterday', () => {
const rows: Row[] = [
{ id: 'yesterday', state: 'idle', lastActivityAt: 1_000 },
{ id: 'just-now', state: 'idle', lastActivityAt: 9_000 },
{ id: 'this-morning', state: 'idle', lastActivityAt: 5_000 },
];
expect(ids(rows)).toEqual(['just-now', 'this-morning', 'yesterday']);
});
it('applies the same recency rule to finished sessions', () => {
const rows: Row[] = [
{ id: 'old-exit', state: 'done', lastActivityAt: 1_000 },
{ id: 'fresh-exit', state: 'done', lastActivityAt: 9_000 },
];
expect(ids(rows)).toEqual(['fresh-exit', 'old-exit']);
});
});
describe('session overview order: tiebreaks', () => {
it('falls back to the tab order when two rows share a stamp', () => {
const rows: Row[] = [
{ id: 'third', state: 'idle', lastActivityAt: 5_000, orderIndex: 2 },
{ id: 'first', state: 'idle', lastActivityAt: 5_000, orderIndex: 0 },
];
expect(ids(rows)).toEqual(['first', 'third']);
});
it('sorts an unstamped row last within its state, never first', () => {
// 0 is "we have no stamp", not "the epoch": treating it as a timestamp
// would park a brand-new session at the head of the oldest-first groups.
expect(
ids([
{ id: 'none', state: 'idle', orderIndex: 0 },
{ id: 'stamped', state: 'idle', lastActivityAt: 1_000, orderIndex: 1 },
])
).toEqual(['stamped', 'none']);
expect(
ids([
{ id: 'none', state: 'working', orderIndex: 0 },
{ id: 'stamped', state: 'working', lastSubmitAt: 1_000, orderIndex: 1 },
])
).toEqual(['stamped', 'none']);
});
it('is deterministic: two unstamped rows keep tab order in both directions', () => {
const a: Row = { id: 'a', state: 'idle', orderIndex: 0 };
const b: Row = { id: 'b', state: 'idle', orderIndex: 1 };
expect(order.compare(a, b)).toBeLessThan(0);
expect(order.compare(b, a)).toBeGreaterThan(0);
expect(order.compare(a, a)).toBe(0);
});
it('copies rather than sorting the caller array in place', () => {
// Both renderers hand it a filtered slice of a shared row array; mutating
// that would reorder the other surface's list as a side effect.
const rows: Row[] = [
{ id: 'b', state: 'idle', lastActivityAt: 1_000 },
{ id: 'a', state: 'idle', lastActivityAt: 9_000 },
];
order.sort(rows);
expect(rows.map((r) => r.id)).toEqual(['b', 'a']);
});
it('survives junk input rather than throwing inside a render', () => {
expect(order.sort(undefined as unknown as Row[])).toEqual([]);
expect(order.anchor({} as Row)).toBe(0);
});
});