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>
This commit is contained in:
Codeman maintainer
2026-08-16 05:11:00 +02:00
parent 869a507482
commit 19aabe34d2
8 changed files with 451 additions and 77 deletions
+43 -4
View File
@@ -42,9 +42,12 @@ function loadOverviewApp(overrides: Record<string, any> = {}) {
},
MobileDetection: { getDeviceType: () => 'mobile' },
});
vm.runInContext(readFileSync(resolve(PUBLIC, 'mobile-overview.js'), 'utf8'), context, {
filename: 'mobile-overview.js',
});
// constants.js first: it installs the row comparator (window.CodemanSessionOrder)
// that buildMobileOverviewModel() sorts every section with, shared with the
// desktop rail so the two home screens cannot order the same list differently.
for (const file of ['constants.js', 'mobile-overview.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.getSessionName = (session: any) => session.name || session.workingDir?.split('/').pop() || session.id.slice(0, 8);
@@ -123,7 +126,7 @@ describe('mobile overview model', () => {
expect(model.sessionCount).toBe(4);
});
it('keeps the user tab order as the tiebreak inside a section', () => {
it('keeps the user tab order as the tiebreak when nothing is stamped', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [session({ id: 'first' }), session({ id: 'second' }), session({ id: 'third' })],
@@ -134,6 +137,42 @@ describe('mobile overview model', () => {
expect(model.current.map((r: any) => r.id)).toEqual(['third', 'first', 'second']);
});
it('sorts running sessions longest-turn-first and quiet ones most-recent-first', () => {
// A working pane repaints about once a second, so its last-activity stamp
// is always "now": the running group has to key off the pane's last Enter
// instead, or every turn ranks as freshly started.
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [
session({ id: 'quiet-old', status: 'idle', lastActivityAt: 2_000 }),
session({ id: 'turn-young', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 }),
session({ id: 'quiet-new', status: 'idle', lastActivityAt: 8_000 }),
session({ id: 'turn-old', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 }),
],
cases: CASES,
sessionOrder: ['quiet-old', 'turn-young', 'quiet-new', 'turn-old'],
});
expect(model.current.map((r: any) => r.id)).toEqual(['turn-old', 'turn-young', 'quiet-new', 'quiet-old']);
});
it('puts the longest-blocked session at the top of NEEDS YOU', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [
session({ id: 'just-asked', lastActivityAt: 9_000 }),
session({ id: 'starving', lastActivityAt: 1_000 }),
],
cases: CASES,
pendingHooks: new Map([
['just-asked', new Set(['permission_prompt'])],
['starving', new Set(['permission_prompt'])],
]),
});
expect(model.needsYou.map((r: any) => r.id)).toEqual(['starving', 'just-asked']);
});
it('matches a session started in a subdirectory to its case (longest prefix)', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({