fix: date a working row by its turn, not by the session age

`TuiSessionRow` declared `lastSubmitAt`/`inputTokens`/`outputTokens`,
`stateSince()` ordered the WORKING group by the first of them and
`renderRowLines()` painted the other two, but nothing ever filled any of
them in: the unified list carries none, and the `session:updated` payload
that does was discarded (an event only schedules a refetch).

So a running turn was dated by its SESSION's creation instead. Measured
against the live server before the fix: w65 (created 21h ago, turn started
one minute earlier) outranked w67 (created 15 minutes ago, turn started
five minutes earlier), the reverse of the rule docs/tui.md states, and the
elapsed column read `21h` for a turn a minute old. The token column was
unreachable code for the same reason.

`fetchLiveSessionMetrics()` reads the three fields from `GET /api/sessions`
and `applyLiveMetrics()` folds them onto the rows. That route answers from
the server's cached LIGHT state (no terminal buffers): 10-20ms measured,
against the ~550ms the unified list in the same `Promise.all` already
costs, so it is cheap enough to ride every refresh. It is best-effort like
the approvals and tmux reads beside it, because losing the anchor is
better than losing the list.

A ZERO is treated as unknown rather than merged: `stateSince()` reads
`lastSubmitAt ?? createdAt` and 0 is not nullish, so a merged 0 would date
every never-submitted session to the epoch.

The snapshot path gets the same merge, or `codeman tui --list` would number
the WORKING group differently from the dashboard that `codeman tui <n>`
indexes into.

Verified live: working rows now show 28m/8m (turn age, tokens 280.5k/65.2k)
where they showed 21h/34m and no tokens. The e2e assertion fails on master's
wiring with `[*] 10m` against a session that pressed Enter one minute ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 5fd6c5dd44
commit 954a9ac26a
5 changed files with 205 additions and 9 deletions
+40 -4
View File
@@ -58,6 +58,7 @@ import {
TuiClient,
type TuiApprovalAnswer,
type TuiEventStream,
type TuiLiveSessionMetrics,
type TuiPlanUsage,
type TuiQuickStartOptions,
type TuiTmuxSession,
@@ -455,6 +456,35 @@ export function applyMuxNames(sessions: readonly TuiSessionRow[], tmux: readonly
});
}
/**
* Fold the live-only counters onto the rows the unified list produced: the
* pane's last Enter (which dates a running turn and orders the WORKING group)
* and the token totals the wide layout shows.
*
* A ZERO is treated as "unknown" rather than merged, and that is the whole
* reason this is not a spread: `stateSince()` reads `lastSubmitAt ?? createdAt`,
* and 0 is not nullish, so merging a 0 would date every never-submitted session
* to the epoch and sort it as the oldest turn on the list. The join is on the
* FULL id, since both sides come from the same server.
*/
export function applyLiveMetrics(
sessions: readonly TuiSessionRow[],
metrics: readonly TuiLiveSessionMetrics[]
): TuiSessionRow[] {
if (metrics.length === 0) return sessions.map((session) => ({ ...session }));
const byId = new Map(metrics.map((entry) => [entry.sessionId, entry]));
return sessions.map((session) => {
const live = byId.get(session.sessionId);
if (!live) return { ...session };
return {
...session,
...(live.lastSubmitAt ? { lastSubmitAt: live.lastSubmitAt } : {}),
...(live.inputTokens ? { inputTokens: live.inputTokens } : {}),
...(live.outputTokens ? { outputTokens: live.outputTokens } : {}),
};
});
}
const STATE_TONE: Record<TuiSessionState, Tone> = {
'blocked-permission': 'err',
'blocked-question': 'err',
@@ -762,12 +792,15 @@ class TuiApp {
private async refreshConnected(): Promise<void> {
try {
const [sessions, approvals, tmux] = await Promise.all([
const [sessions, approvals, tmux, metrics] = await Promise.all([
this.client.fetchUnifiedSessions(UNIFIED_LIMIT),
this.client.fetchApprovals().catch(() => []),
this.client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]),
// Best-effort like the other two: without it a running turn is dated by
// its session's creation, which is worse than the list going stale.
this.client.fetchLiveSessionMetrics().catch(() => [] as TuiLiveSessionMetrics[]),
]);
this.model.replaceSessions(applyMuxNames(sessions, tmux));
this.model.replaceSessions(applyLiveMetrics(applyMuxNames(sessions, tmux), metrics));
this.model.setApprovals(approvals);
this.noteApprovals(approvals);
if (this.pendingSelectId && this.model.select(this.pendingSelectId)) this.pendingSelectId = null;
@@ -1684,12 +1717,15 @@ async function snapshot(client: TuiClient): Promise<Snapshot> {
model.replaceSessions(tmuxRowsToSessions(await client.enumerateTmuxSessions()));
return { kind: 'ok', rows: model.rows(), degraded: true };
}
const [sessions, approvals, tmux] = await Promise.all([
const [sessions, approvals, tmux, metrics] = await Promise.all([
client.fetchUnifiedSessions(UNIFIED_LIMIT),
client.fetchApprovals().catch(() => []),
client.enumerateTmuxSessions().catch(() => [] as TuiTmuxSession[]),
client.fetchLiveSessionMetrics().catch(() => [] as TuiLiveSessionMetrics[]),
]);
model.replaceSessions(applyMuxNames(sessions, tmux));
// Same merge as the dashboard, so `--list`'s numbers stay the numbers
// `codeman tui <n>` takes: the WORKING group's order depends on it.
model.replaceSessions(applyLiveMetrics(applyMuxNames(sessions, tmux), metrics));
model.setApprovals(approvals);
return { kind: 'ok', rows: model.rows(), degraded: false };
}