Skip to content

Commit c98755f

Browse files
saucambigdatalinuxclaude
authored
feat: order the session list by attention, not by when you created it (#300)
The sidebar sorted by createdAt — under a comment that read "Sorted list — most recent activity first". The intent was always recency; the code delivered creation order. A session made last week but driven all morning sat at the bottom, which is why the order looked arbitrary. It could not be fixed client-side. The daemon has always tracked `#lastActivityAt` (it orders the resumed session list by it) but never put it on the wire, and `SessionInfo` carried only createdAt. web/src/state/messages.ts has a `lastActivityAt`, but it is derived from messages THIS client received, so it is 0 for every session you have not opened — a trap for the obvious fix. Adds `lastActivityAt` to SessionInfo (optional; clients fall back to createdAt so an older daemon does not sink every session to epoch 0) and bands the list: NEEDS YOU waiting_approval, error WORKING thinking, tool_running IDLE everything else, most recently active first Bands rather than one flat multi-key sort, for three reasons. It is already this project's answer — conductor-frontends-design.md §4 locks a state-grouped list as the fleet view's primary, and a second ordering vocabulary in the same client would be a bug in itself. It absorbs thrash: thinking and tool_running alternate several times a second and both are WORKING, so nothing moves, where a flat sort keyed on status would jitter continuously. And it makes movement legible — a row crossing under a NEEDS YOU header reads as a state change rather than a glitch. Two details that matter more than the sort: - A fleet bands and dates as ONE unit, by the most urgent state and the most recent activity anywhere in it. An orchestrator sits idle while its children work, so banding on the lead alone would file a fleet whose child is blocked on an approval under IDLE — exactly the case NEEDS YOU exists to surface. - Ordering is held while the pointer is over the list and applied on leave. A row that moves between aiming and clicking opens the wrong session. Membership is deliberately NOT held: a destroyed session must disappear, since leaving it clickable trades a misclick for a worse one. Ordering stays client-side. The daemon owns the state and exposes the field; each client decides how to render it, which is what lets the TUI and mobile band differently later. Mirrored in the Rust crate. 13 new tests over the pure ordering functions. Co-authored-by: Yash Datta <datta.yash@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6623c2f commit c98755f

6 files changed

Lines changed: 477 additions & 10 deletions

File tree

packages/protocol/src/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,21 @@ export interface SessionInfo {
202202
status: SessionStatus;
203203
createdBy: string;
204204
createdAt: string;
205+
/**
206+
* ISO timestamp of the last time this session changed state — a turn started,
207+
* a tool ran, it went idle. The daemon has always tracked this (it orders the
208+
* resumed session list by it); this puts it on the wire so clients can order
209+
* by RELEVANCE instead of by creation time.
210+
*
211+
* Bumped on state change, so it covers both "I just sent something" and "it
212+
* just did something", and is deliberately NOT bumped by metadata-only writes
213+
* like `rename()` — renaming a session must not reorder the list.
214+
*
215+
* Optional: absent from a daemon that predates it. Clients should fall back to
216+
* `createdAt` rather than treating a missing value as "never active", which
217+
* would sink every session on an older daemon to the bottom.
218+
*/
219+
lastActivityAt?: string;
205220
attachedClients: number;
206221
/**
207222
* Session role. "conductor" marks the per-tenant conductor session (the

src/daemon/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2521,6 +2521,7 @@ export class Session {
25212521
status: this.#status,
25222522
createdBy: this.createdBy,
25232523
createdAt: this.createdAt,
2524+
lastActivityAt: this.#lastActivityAt,
25242525
attachedClients: this.#clients.size,
25252526
role: this.role,
25262527
providerId: this.#provider.id,

web/src/components/SessionListPane.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import {
1515
roleLabel,
1616
type FilteredFleetGroup,
1717
} from "../lib/fleet";
18+
import {
19+
bandSections,
20+
holdOrder,
21+
snapshotOrder,
22+
type BandedSection,
23+
type OrderSnapshot,
24+
} from "../lib/session-order";
1825
import { sessionAgentLabel, shortSub } from "../lib/identity";
1926
import { fetchPanels, liveProgress, livePanelMember, resetPanels } from "../state/panels";
2027
import { nowTick } from "../state/clock";
@@ -91,6 +98,20 @@ const SessionListPane: Component = () => {
9198
filterFleet(groupFleet(sessionList()), filter()),
9299
);
93100

101+
// Ordering is held while the pointer is over the list: a row that moves
102+
// between aiming and clicking opens the wrong session. The pending reorder
103+
// lands on leave. Membership still updates live — see holdOrder.
104+
const [pointerInside, setPointerInside] = createSignal(false);
105+
let heldOrder: OrderSnapshot | null = null;
106+
const sections = createMemo<BandedSection<FilteredFleetGroup>[]>(() => {
107+
const live = bandSections(groups());
108+
if (!pointerInside()) {
109+
heldOrder = snapshotOrder(live);
110+
return live;
111+
}
112+
return holdOrder(live, heldOrder);
113+
});
114+
94115
// The goal whose panels we poll, as a plain STRING.
95116
//
96117
// A memo over a primitive, deliberately. Reading `focusedSession()?.collaboration`
@@ -156,11 +177,26 @@ const SessionListPane: Component = () => {
156177
>
157178
<SessionFilter value={filter()} onInput={setFilter} />
158179
<Show when={groups().length > 0} fallback={<NoMatch query={filter()} />}>
159-
<ul class="flex flex-col py-1">
160-
<For each={groups()}>
161-
{(g) => <FleetGroupRows group={g} />}
180+
<div
181+
onPointerEnter={() => setPointerInside(true)}
182+
onPointerLeave={() => setPointerInside(false)}
183+
>
184+
<For each={sections()}>
185+
{(section) => (
186+
<ul class="flex flex-col py-1">
187+
<li
188+
class="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-fg-muted"
189+
aria-hidden="true"
190+
>
191+
{section.label}
192+
</li>
193+
<For each={section.groups}>
194+
{(g) => <FleetGroupRows group={g} />}
195+
</For>
196+
</ul>
197+
)}
162198
</For>
163-
</ul>
199+
</div>
164200
</Show>
165201
</Show>
166202
<Show when={focusedSessionId()}>

web/src/lib/session-order.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Session-list ordering.
3+
*
4+
* The behaviour these pin, in order of how badly the failure reads to a user:
5+
*
6+
* 1. A session blocked on YOU is never buried under idle ones — including
7+
* when the blocked party is a fleet's child and the orchestrator is idle.
8+
* 2. The list orders by relevance, not creation time (the original bug).
9+
* 3. It does not reorder under the pointer.
10+
* 4. It degrades sanely against a daemon that never sends lastActivityAt.
11+
*/
12+
13+
import { describe, it, expect } from "vitest";
14+
15+
import {
16+
BAND,
17+
activityKey,
18+
bandOf,
19+
bandOfGroup,
20+
bandSections,
21+
compareByRecency,
22+
holdOrder,
23+
snapshotOrder,
24+
} from "./session-order";
25+
import type { FleetGroup } from "./fleet";
26+
import type { SessionInfo, SessionStatus } from "../protocol/types";
27+
28+
function mk(
29+
id: string,
30+
over: Partial<SessionInfo> & { status?: SessionStatus } = {},
31+
): SessionInfo {
32+
return {
33+
id,
34+
name: id,
35+
workdir: `/repo/${id}`,
36+
status: "idle",
37+
createdBy: "u",
38+
createdAt: "2026-01-01T00:00:00.000Z",
39+
attachedClients: 0,
40+
...over,
41+
} as SessionInfo;
42+
}
43+
44+
const group = (lead: SessionInfo, children: SessionInfo[] = []): FleetGroup => ({
45+
lead,
46+
children,
47+
isFleet: children.length > 0,
48+
});
49+
50+
describe("bands", () => {
51+
it("puts anything blocked on a human in NEEDS_YOU", () => {
52+
expect(bandOf("waiting_approval")).toBe(BAND.NEEDS_YOU);
53+
// A failed session is the other thing that wants a human; filing it under
54+
// idle is how a failure goes unnoticed for an hour.
55+
expect(bandOf("error")).toBe(BAND.NEEDS_YOU);
56+
});
57+
58+
it("treats thinking and tool_running as one band so they cannot thrash", () => {
59+
// These alternate several times a second. If they banded differently the
60+
// row would jitter continuously.
61+
expect(bandOf("thinking")).toBe(BAND.WORKING);
62+
expect(bandOf("tool_running")).toBe(BAND.WORKING);
63+
});
64+
65+
it("idle is idle", () => {
66+
expect(bandOf("idle")).toBe(BAND.IDLE);
67+
});
68+
69+
it("a fleet takes the most urgent band of anything in it", () => {
70+
// The orchestrator sits idle while its children work — banding on the lead
71+
// alone would hide a child that is blocked on an approval.
72+
const fleet = group(mk("goal", { status: "idle" }), [
73+
mk("kid-1", { status: "idle" }),
74+
mk("kid-2", { status: "waiting_approval" }),
75+
]);
76+
expect(bandOfGroup(fleet)).toBe(BAND.NEEDS_YOU);
77+
});
78+
});
79+
80+
describe("recency", () => {
81+
it("orders by lastActivityAt, not createdAt — the original bug", () => {
82+
// Old session, driven all morning; new session, untouched since creation.
83+
const old = mk("old", {
84+
createdAt: "2026-01-01T00:00:00.000Z",
85+
lastActivityAt: "2026-06-01T12:00:00.000Z",
86+
});
87+
const fresh = mk("fresh", {
88+
createdAt: "2026-05-01T00:00:00.000Z",
89+
lastActivityAt: "2026-05-01T00:00:00.000Z",
90+
});
91+
expect([fresh, old].sort(compareByRecency).map((s) => s.id)).toEqual(["old", "fresh"]);
92+
});
93+
94+
it("falls back to createdAt when the daemon never sends lastActivityAt", () => {
95+
// An older daemon must not sink every session to epoch 0 — that would be
96+
// worse than the behaviour being replaced.
97+
const s = mk("legacy", { createdAt: "2026-03-03T00:00:00.000Z" });
98+
expect(activityKey(s)).toBe(Date.parse("2026-03-03T00:00:00.000Z"));
99+
});
100+
101+
it("survives an unparseable timestamp instead of producing NaN order", () => {
102+
expect(activityKey(mk("bad", { lastActivityAt: "not-a-date" }))).toBe(0);
103+
});
104+
105+
it("a fleet is as recent as its busiest child", () => {
106+
const fleet = group(
107+
mk("goal", { lastActivityAt: "2026-01-01T00:00:00.000Z" }),
108+
[mk("kid", { lastActivityAt: "2026-09-09T00:00:00.000Z" })],
109+
);
110+
const solo = group(mk("solo", { lastActivityAt: "2026-05-05T00:00:00.000Z" }));
111+
const [section] = bandSections([solo, fleet]);
112+
expect(section!.groups.map((g) => g.lead.id)).toEqual(["goal", "solo"]);
113+
});
114+
});
115+
116+
describe("bandSections", () => {
117+
it("orders bands NEEDS YOU → WORKING → IDLE and drops empty ones", () => {
118+
const sections = bandSections([
119+
group(mk("i", { status: "idle" })),
120+
group(mk("w", { status: "thinking" })),
121+
group(mk("n", { status: "waiting_approval" })),
122+
]);
123+
expect(sections.map((s) => s.label)).toEqual(["Needs you", "Working", "Idle"]);
124+
expect(sections.map((s) => s.groups[0]!.lead.id)).toEqual(["n", "w", "i"]);
125+
126+
// No empty headers when a band has nothing in it.
127+
expect(bandSections([group(mk("only", { status: "idle" }))]).map((s) => s.label)).toEqual([
128+
"Idle",
129+
]);
130+
});
131+
132+
it("is deterministic for same-millisecond sessions", () => {
133+
// A fan-out creates children within the same millisecond; without a
134+
// tie-break they would swap places on every re-render.
135+
const at = "2026-04-04T00:00:00.000Z";
136+
const a = group(mk("bbb", { lastActivityAt: at }));
137+
const b = group(mk("aaa", { lastActivityAt: at }));
138+
const once = bandSections([a, b]).flatMap((s) => s.groups.map((g) => g.lead.id));
139+
const twice = bandSections([b, a]).flatMap((s) => s.groups.map((g) => g.lead.id));
140+
expect(once).toEqual(twice);
141+
});
142+
});
143+
144+
describe("holdOrder — does not reorder under the pointer", () => {
145+
it("keeps a row in place when its band changes mid-hover", () => {
146+
const idle = group(mk("a", { status: "idle", lastActivityAt: "2026-01-02T00:00:00.000Z" }));
147+
const other = group(mk("b", { status: "idle", lastActivityAt: "2026-01-01T00:00:00.000Z" }));
148+
const before = bandSections([idle, other]);
149+
const snap = snapshotOrder(before);
150+
expect(before.map((s) => s.label)).toEqual(["Idle"]);
151+
152+
// 'b' starts asking for approval — normally it would jump to a new top band.
153+
const after = bandSections([
154+
idle,
155+
group(mk("b", { status: "waiting_approval", lastActivityAt: "2026-01-01T00:00:00.000Z" })),
156+
]);
157+
expect(after.map((s) => s.label)).toEqual(["Needs you", "Idle"]);
158+
159+
// Held: the layout the user is pointing at is unchanged.
160+
const held = holdOrder(after, snap);
161+
expect(held.map((s) => s.label)).toEqual(["Idle"]);
162+
expect(held[0]!.groups.map((g) => g.lead.id)).toEqual(["a", "b"]);
163+
});
164+
165+
it("still shows new sessions and drops destroyed ones while holding", () => {
166+
// Order is frozen; MEMBERSHIP is not. Freezing membership would leave a
167+
// destroyed session clickable — a worse bug than the one being prevented.
168+
const a = group(mk("a", { status: "idle" }));
169+
const b = group(mk("b", { status: "idle" }));
170+
const snap = snapshotOrder(bandSections([a, b]));
171+
172+
const c = group(mk("c", { status: "idle" }));
173+
const held = holdOrder(bandSections([a, c]), snap); // b destroyed, c appeared
174+
const ids = held.flatMap((s) => s.groups.map((g) => g.lead.id));
175+
expect(ids).toContain("a");
176+
expect(ids).toContain("c");
177+
expect(ids).not.toContain("b");
178+
// The newcomer appends rather than displacing the row being aimed at.
179+
expect(ids.indexOf("c")).toBeGreaterThan(ids.indexOf("a"));
180+
});
181+
182+
it("is a no-op without a snapshot", () => {
183+
const live = bandSections([group(mk("a", { status: "waiting_approval" }))]);
184+
expect(holdOrder(live, null)).toEqual(live);
185+
});
186+
});

0 commit comments

Comments
 (0)