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
+3 -1
View File
@@ -262,7 +262,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices, and each carries **created / last-active** stamps. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **overview order** (see below), and each carries a **created** stamp plus the **state duration** the order is computed from (`created 3d ago · working 12m`, word and anchor from `_mobileOverviewSince()` so both home screens say the same thing). A rail sorted by a number it does not show reads as arbitrarily shuffled, and a working row's plain last-active stamp always says "just now". ⚠️ The number badge is the **Alt+1..9 index**, i.e. the position in the TAB STRIP, so on a sorted rail it deliberately does NOT run 1,2,3 downward: it names a shortcut, not a row position, and renumbering it to look tidy would make every badge lie. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure + unit-tested in `test/session-overview-order.test.ts`): BOTH home screens (phone overview and desktop rail) order rows through this ONE comparator, because they list the same sessions and must answer "which of these wants me next?" the same way. Rank is `needs` → `error` → `waiting` → `working` → `idle` → `done`, and ⚠️ **the tiebreak flips direction halfway down**: states a session is still IN sort **oldest-first** (blocked longest / running longest = most urgent), states it has STOPPED in sort **newest-first** (the session that just went quiet is the one you came back for). ⚠️ The running group keys off **`lastSubmitAt`** (the pane's last Enter), never `lastActivityAt`: a working Claude pane repaints about once a second, so its last-activity stamp is always "now" and would rank every running turn as freshly started. A working pane with no submit stamp falls back to last activity, which lands it at the SHORT end of the group rather than falsely leading it. ⚠️ A **0 stamp means "unknown", not "the epoch"**, and it sorts last within its state either way, or a brand-new session would head every oldest-first group. Final tiebreak is the user's tab order (`orderIndex`), so the list is deterministic and cannot shuffle between renders. The tab strip itself is NOT sorted by this; it stays user-ordered and drag-reorderable.
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
+94
View File
@@ -437,6 +437,94 @@ function computeSseStale(input) {
return now - lastMessageAt >= timeoutMs;
}
// Home-screen session order: one comparator for both overviews.
//
// The phone overview (mobile-overview.js) and the desktop tab rail
// (home-sessions.js) list the same sessions, so they answer the same question
// and must answer it the same way: "which of these wants me next?".
//
// 1. Anything blocked on a human first (red question, then error, then a
// yellow idle prompt), longest-blocked at the top: a session that has been
// sitting on a permission dialog for 20 minutes is starving, one that
// raised it 5 seconds ago is not.
// 2. Then whatever is running, LONGEST-RUNNING first, since that is the turn most
// likely to be finished, or stuck, by the time you look.
// 3. Then everything quiet, MOST RECENTLY quiet first: when nothing is
// running, the session that just finished is the one you came back for,
// and the one you abandoned yesterday sinks.
//
// So the tiebreak flips direction halfway down the list, and that is the point:
// for a state something is still doing, longer = more urgent; for a state
// something has stopped in, more recent = more relevant.
//
// Pure: no DOM, no clock (every input is an epoch-ms stamp already on the
// session payload), no `this`. Unit-tested in test/session-overview-order.test.ts.
const SESSION_ACTIVITY_RANK = {
needs: 0,
error: 1,
waiting: 2,
working: 3,
idle: 4,
done: 5,
};
/** States still in progress, where the OLDEST stamp sorts first. */
const SESSION_ACTIVITY_OLDEST_FIRST = ['needs', 'error', 'waiting', 'working'];
/**
* When the row entered the state it is in.
*
* For everything quiet that is `lastActivityAt`, the last byte the pane printed:
* a Claude pane sitting at its composer prints nothing, so the end of the last
* turn is exactly when it went quiet.
*
* A WORKING pane is the opposite: it repaints about once a second, so its
* last-activity stamp is always "now" and would rank every running turn as
* freshly started. Its real start is the pane's last Enter (`lastSubmitAt`),
* persisted server-side and therefore stable across a Codeman restart. A
* working pane that has never submitted (spawned with its prompt on the command
* line, or an external CLI) falls back to last activity, which puts it at the
* short end of the running group rather than falsely at the head of it.
*/
function sessionActivityAnchor(row) {
const activeAt = Number(row && row.lastActivityAt) || 0;
if (row && row.state === 'working') return Number(row.lastSubmitAt) || activeAt;
return activeAt;
}
/**
* Sort comparator for one overview row against another.
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} a
* @param {{state: string, lastActivityAt?: number, lastSubmitAt?: number, orderIndex?: number}} b
*/
function compareSessionActivity(a, b) {
const rankA = SESSION_ACTIVITY_RANK[a.state];
const rankB = SESSION_ACTIVITY_RANK[b.state];
const rank = (rankA === undefined ? 99 : rankA) - (rankB === undefined ? 99 : rankB);
if (rank !== 0) return rank;
const atA = sessionActivityAnchor(a);
const atB = sessionActivityAnchor(b);
if (atA !== atB) {
// A row with no stamp at all gets no opinion: it sorts last either way
// rather than claiming to be the oldest (0) thing on the screen.
if (!atA) return 1;
if (!atB) return -1;
return SESSION_ACTIVITY_OLDEST_FIRST.includes(a.state) ? atA - atB : atB - atA;
}
// Equal stamps (or two unstamped rows): fall back to the user's tab order so
// the list is deterministic and cannot shuffle between renders.
const orderA = Number.isFinite(a.orderIndex) ? a.orderIndex : Number.MAX_SAFE_INTEGER;
const orderB = Number.isFinite(b.orderIndex) ? b.orderIndex : Number.MAX_SAFE_INTEGER;
return orderA - orderB;
}
/** Copy of `rows`, in overview order. Never sorts in place, so callers keep their array. */
function sortSessionsByActivity(rows) {
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -464,6 +552,12 @@ if (typeof window !== 'undefined') {
compute: computeSseStale,
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
};
window.CodemanSessionOrder = {
RANK: SESSION_ACTIVITY_RANK,
anchor: sessionActivityAnchor,
compare: compareSessionActivity,
sort: sortSessionsByActivity,
};
}
// Scheduler API — prioritize terminal writes over background UI updates.
+72 -35
View File
@@ -5,8 +5,13 @@
* The welcome screen centers ~560px of content in a window that is usually
* 1400px+, so the two gutters are dead space. The left one now carries the same
* list a phone gets on its home screen (mobile-overview.js), turned vertical:
* one row per live tab, in TAB ORDER (not sorted by state) so it reads as the
* tab strip rotated, and so Alt+1..9 still matches what you see.
* one row per live tab.
*
* Rows are ordered by `CodemanSessionOrder` (constants.js), the same comparator
* the phone overview uses: blocked on you first, then running longest-first,
* then quiet most-recently-quiet first. The number badge stays the tab-strip
* index (Alt+1..9), so it is deliberately NOT sequential down a sorted rail:
* it names a shortcut, not a row position.
*
* DESKTOP ONLY, and only in a wide enough window: the rail is absolutely
* positioned so the centered welcome content never moves, which means it can
@@ -16,11 +21,13 @@
* both scale with the viewport (see the `.home-sessions` block in styles.css) —
* a fixed 256px card looks abandoned on a 2560px display.
*
* Each row carries when the session was FIRST CREATED and when it was LAST
* ACTIVE, both relative. Those two stamps go stale on their own (a sitting
* session emits no event), so a slow clock refreshes them IN PLACE from the
* epoch-ms values parked on the elements, rather than re-rendering: a re-render
* would restart every row's blink animation and its working ring.
* Each row carries when the session was FIRST CREATED and how long it has been
* in the state it is in ("created 3d ago · working 12m"), and that second stamp is
* the value the order above is computed from, so the rail explains itself
* rather than looking arbitrarily shuffled. Both stamps go stale on their own
* (a sitting session emits no event), so a slow clock refreshes them IN PLACE
* from the epoch-ms values parked on the elements, rather than re-rendering: a
* re-render would restart every row's blink animation and its working ring.
*
* The working state is deliberately identical to the phone's: a pulsing green
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
@@ -34,6 +41,7 @@
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
* @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter)
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
@@ -158,11 +166,18 @@ Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
/**
* One row per live session, in the user's tab order. State classification is
* `_mobileOverviewState()` (mobile-overview.js) so both home screens agree on
* what counts as needing you; the ORDER differs on purpose — the phone sorts
* by urgency because it shows one screenful at a time, this column mirrors the
* tab strip so the number badges line up with Alt+1..9.
* One row per live session, in overview order: whatever is blocked on you
* first, then whatever is running (longest turn first), then the quiet ones
* most-recently-quiet first. The comparator is `CodemanSessionOrder`
* (constants.js), shared with the phone overview, and state classification is
* `_mobileOverviewState()` (mobile-overview.js), so the two home screens can
* neither disagree about what "working" means nor about what sorts first.
*
* `orderIndex` stays the position in the TAB STRIP, because that is what the
* number badge means (Alt+1..9). Once the rows are sorted those badges no
* longer run 1,2,3 down the rail: the badge answers "which key selects this",
* not "how far down the list is it".
*
* @returns {Array<object>} row descriptors, ready to render
*/
buildHomeSessionRows() {
@@ -173,14 +188,14 @@ Object.assign(CodemanApp.prototype, {
// invisible here while its tab already exists.
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
return ids.map((id, index) => {
const rows = ids.map((id, orderIndex) => {
const session = this.sessions.get(id);
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
const mode = session.mode || 'claude';
return {
id,
index,
orderIndex,
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
mode,
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
@@ -192,8 +207,16 @@ Object.assign(CodemanApp.prototype, {
// render time so the clock below can redo it without a re-render.
createdAt: Number(session.createdAt) || 0,
lastActivityAt: Number(session.lastActivityAt) || 0,
// The running group is ordered by the pane's last Enter, since a
// working pane's last-activity stamp is always "now".
lastSubmitAt: Number(session.lastSubmitAt) || 0,
// "how long has it been like this", resolved by the phone overview's
// helper so both home screens label the same stamp with the same word.
since: this._mobileOverviewSince(state, session),
};
});
return window.CodemanSessionOrder.sort(rows);
},
// ═══════════════════════════════════════════════════════════════
@@ -234,32 +257,40 @@ Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
/**
* The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw
* The "created 2h ago · working 12m" footer line. Both stamps keep their raw
* epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can
* rewrite the text without rebuilding the row.
*
* The second stamp is the row's state duration, NOT a plain last-active
* stamp: it is the number the rail is sorted by, and a working row that reads
* "active just now" (every working pane repaints about once a second) hides
* exactly the value that decided its position. `_mobileOverviewSince()` owns
* both the word and the anchor, so the phone says the same thing.
*/
_buildHomeSessionsMeta(row) {
const meta = document.createElement('span');
meta.className = 'home-sessions-row-meta';
// Relative times are generated text, and "created"/"active" here are the
// Relative times are generated text, and "created"/"idle" here are the
// same generic words that mean something else on other surfaces.
meta.setAttribute('data-i18n-skip', '');
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created'));
meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'ago', 'home-sessions-meta-created'));
const sep = document.createElement('span');
sep.className = 'home-sessions-meta-sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
meta.appendChild(sep);
if (row.since) {
const sep = document.createElement('span');
sep.className = 'home-sessions-meta-sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
meta.appendChild(sep);
meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active'));
meta.appendChild(this._buildHomeSessionsStamp(row.since.key, row.since.at, 'for', 'home-sessions-meta-since'));
}
return meta;
},
/** One labelled stamp: a dim key, the relative value, full date in the title. */
_buildHomeSessionsStamp(key, timestamp, className) {
/** One labelled stamp: a dim key, the value, full date in the title. */
_buildHomeSessionsStamp(key, timestamp, format, className) {
const wrap = document.createElement('span');
wrap.className = `home-sessions-meta-item ${className}`;
@@ -270,18 +301,21 @@ Object.assign(CodemanApp.prototype, {
const value = document.createElement('span');
value.dataset.hsTs = String(timestamp || 0);
value.textContent = this._homeSessionsAgo(timestamp);
value.dataset.hsFmt = format;
value.textContent = this._homeSessionsStampText(timestamp, format);
wrap.appendChild(value);
if (timestamp)
wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`;
if (timestamp) wrap.title = `${key === 'created' ? 'First created' : key}: ${new Date(timestamp).toLocaleString()}`;
return wrap;
},
/** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */
_homeSessionsAgo(timestamp) {
if (!timestamp) return '—';
return this.formatRelativeTime(timestamp) || '—';
/**
* 'ago' points at a moment ("3d ago"), 'for' measures a span to now ("12m").
* Both come from the phone overview's formatter, so a duration is written the
* same way on both home screens.
*/
_homeSessionsStampText(timestamp, format) {
return this._mobileOverviewStampText(timestamp, format);
},
/**
@@ -311,7 +345,7 @@ Object.assign(CodemanApp.prototype, {
if (!el) return;
for (const node of el.querySelectorAll('[data-hs-ts]')) {
const ts = Number(node.dataset.hsTs) || 0;
const text = this._homeSessionsAgo(ts);
const text = this._homeSessionsStampText(ts, node.dataset.hsFmt);
if (node.textContent !== text) node.textContent = text;
}
},
@@ -348,11 +382,14 @@ Object.assign(CodemanApp.prototype, {
item.dataset.hsSession = row.id;
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
if (row.index < 9) {
// The badge is the Alt+N key for this tab, so it keeps the tab-strip index
// even though the rows are sorted by activity: it will not read 1,2,3 down
// the rail, and must not, or the shortcut it names would be wrong.
if (row.orderIndex < 9) {
const number = document.createElement('span');
number.className = 'home-sessions-number';
number.setAttribute('data-i18n-skip', '');
number.textContent = String(row.index + 1);
number.textContent = String(row.orderIndex + 1);
item.appendChild(number);
}
+13 -16
View File
@@ -8,6 +8,10 @@
* errored sessions), then SPACES (cases, expandable to their sessions), then
* WORKING and IDLE / DONE.
*
* Rows inside a section are ordered by `CodemanSessionOrder` (constants.js),
* the SAME comparator the desktop rail uses: blocked longest-first, then
* running longest-first, then quiet most-recently-quiet first.
*
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
* popped-out solo window, per-device setting on). Tablet and desktop keep the
* welcome overlay untouched. The container ships with the `hidden` attribute and
@@ -25,6 +29,7 @@
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency constants.js (CodemanSessionOrder, the shared row comparator)
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
* @dependency mobile-handlers.js (MobileDetection)
@@ -35,16 +40,6 @@
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
/** Sort rank per state: the most demanding thing sorts first inside a section. */
const MOBILE_OVERVIEW_STATE_RANK = {
needs: 0,
error: 1,
waiting: 2,
working: 3,
idle: 4,
done: 5,
};
/** How many past conversations show before the "Show all" toggle. */
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
@@ -188,17 +183,19 @@ Object.assign(CodemanApp.prototype, {
// Epoch ms, straight off the session payload; formatting happens at
// render time so the clock can redo it without a re-render.
createdAt: Number(session.createdAt) || 0,
// Raw stamps for the shared order comparator; `since` above is the same
// pair resolved for DISPLAY, and the two must not drift apart.
lastActivityAt: Number(session.lastActivityAt) || 0,
lastSubmitAt: Number(session.lastSubmitAt) || 0,
since: this._mobileOverviewSince(state, session),
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
};
});
const bySeverityThenOrder = (a, b) => {
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
};
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
// Order is `CodemanSessionOrder` (constants.js), shared with the desktop
// rail: blocked first (longest-blocked at the top), then running
// longest-first, then quiet most-recent-first.
const inSection = (states) => window.CodemanSessionOrder.sort(rows.filter((r) => states.includes(r.state)));
// Past = conversations from the unified list that are not currently live.
// The endpoint already folds a transcript into its owning session (via the
+3 -2
View File
@@ -14902,8 +14902,9 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
}
/* The freshest signal on the row: while a session is actually doing something,
its "active" stamp is the one the eye should land on. */
.home-sessions-row--working .home-sessions-meta-active {
how long it has been doing it is what the eye should land on (and it is what
the rail is sorted by). */
.home-sessions-row--working .home-sessions-meta-since {
color: var(--green);
opacity: 0.95;
}
+48 -19
View File
@@ -2,11 +2,11 @@
//
// The desktop home screen's tab column (src/web/public/home-sessions.js) fills
// the welcome overlay's left gutter. Two things about it can silently go wrong
// and are pinned here: the row ORDER (it mirrors the tab strip, unlike the phone
// overview which sorts by urgency, and the number badges are only correct if it
// does), and the WIDTH GATE, which lives in two places at once — the JS constant
// and a CSS media query — because the column is absolutely positioned and would
// overlap the search panel in a narrow window.
// and are pinned here: the row ORDER (shared with the phone overview via
// CodemanSessionOrder, with the number badge still carrying the TAB index so
// Alt+N keeps working), and the WIDTH GATE, which lives in two places at once —
// the JS constant and a CSS media query — because the column is absolutely
// positioned and would overlap the search panel in a narrow window.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
@@ -35,9 +35,10 @@ function fakeElement(): any {
/**
* home-sessions.js reuses `_mobileOverviewState` / `_mobileOverviewCaseFor` /
* `shouldUseMobileOverview` from mobile-overview.js, so both files run in the
* same context — which is also the point: if that reuse ever breaks, these
* tests stop loading rather than quietly testing a divergent copy.
* `shouldUseMobileOverview` from mobile-overview.js and the row comparator from
* constants.js, so all three files run in the same context, which is also the
* point: if that reuse ever breaks, these tests stop loading rather than
* quietly testing a divergent copy.
*/
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) {
const CodemanApp = function CodemanApp(this: any) {};
@@ -52,7 +53,7 @@ function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1
},
MobileDetection: { getDeviceType: () => (innerWidth < 430 ? 'mobile' : 'desktop') },
});
for (const file of ['mobile-overview.js', 'home-sessions.js']) {
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
@@ -76,9 +77,9 @@ function sessionMap(list: Array<Record<string, any>>) {
}
describe('home sessions column: model', () => {
it('lists rows in TAB order, not by urgency, so the number badges match Alt+1..9', () => {
// The phone overview would hoist 'needy' to the top; this surface must not,
// because its badges are the Alt+N indices.
it('hoists a session blocked on you, and keeps its badge on the TAB index', () => {
// The badge names the Alt+N shortcut, so a sorted rail shows 2,1,3 rather
// than renumbering itself 1,2,3 and lying about which key selects what.
const app = loadHomeSessionsApp({
sessions: sessionMap([{ id: 'first' }, { id: 'needy' }, { id: 'third' }]),
sessionOrder: ['first', 'needy', 'third'],
@@ -87,10 +88,34 @@ describe('home sessions column: model', () => {
});
const rows = app.buildHomeSessionRows();
expect(rows.map((r: any) => r.id)).toEqual(['first', 'needy', 'third']);
expect(rows.map((r: any) => r.index)).toEqual([0, 1, 2]);
expect(rows[1].state).toBe('needs');
expect(rows[1].pill).toBe('needs you');
expect(rows.map((r: any) => r.id)).toEqual(['needy', 'first', 'third']);
expect(rows.map((r: any) => r.orderIndex)).toEqual([1, 0, 2]);
expect(rows[0].state).toBe('needs');
expect(rows[0].pill).toBe('needs you');
});
it('orders running sessions longest-turn-first and quiet ones most-recent-first', () => {
// The same rule the phone overview follows, and the reason the rail exists:
// what is running longest is what is most likely to be done or stuck, and
// once nothing is running the session that just stopped is the one you came
// back for.
const app = loadHomeSessionsApp({
sessions: sessionMap([
{ id: 'young-turn', status: 'busy', lastSubmitAt: 9_000, lastActivityAt: 10_000 },
{ id: 'old-turn', status: 'busy', lastSubmitAt: 1_000, lastActivityAt: 10_000 },
{ id: 'stale-idle', status: 'idle', lastActivityAt: 2_000 },
{ id: 'fresh-idle', status: 'idle', lastActivityAt: 8_000 },
]),
sessionOrder: ['young-turn', 'old-turn', 'stale-idle', 'fresh-idle'],
cases: CASES,
});
expect(app.buildHomeSessionRows().map((r: any) => r.id)).toEqual([
'old-turn',
'young-turn',
'fresh-idle',
'stale-idle',
]);
});
it('shows a session that is not in the order list yet', () => {
@@ -117,11 +142,13 @@ describe('home sessions column: model', () => {
cases: CASES,
});
// Unstamped rows fall back to the tab order inside a state, so this reads
// as the state ranking alone: an errored session is blocked on you.
expect(app.buildHomeSessionRows().map((r: any) => [r.state, r.pill])).toEqual([
['error', 'error'],
['working', 'working'],
['idle', 'idle'],
['done', 'done'],
['error', 'error'],
]);
});
@@ -236,8 +263,10 @@ describe('home sessions column: wiring', () => {
expect(aside).toBeGreaterThan(overlayStart);
expect(aside).toBeLessThan(content);
// Load order: the module reuses prototype methods installed by
// mobile-overview.js. Compare the <script> tags, not any mention: both
// files are named in explanatory comments earlier in the document.
// mobile-overview.js and the comparator installed by constants.js. Compare
// the <script> tags, not any mention: both files are named in explanatory
// comments earlier in the document.
expect(html.indexOf('src="home-sessions.js"')).toBeGreaterThan(html.indexOf('src="mobile-overview.js"'));
expect(html.indexOf('src="mobile-overview.js"')).toBeGreaterThan(html.indexOf('src="constants.js"'));
});
});
+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({
+175
View File
@@ -0,0 +1,175 @@
// 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);
});
});