Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,21 @@ export interface SessionInfo {
status: SessionStatus;
createdBy: string;
createdAt: string;
/**
* ISO timestamp of the last time this session changed state — a turn started,
* a tool ran, it went idle. The daemon has always tracked this (it orders the
* resumed session list by it); this puts it on the wire so clients can order
* by RELEVANCE instead of by creation time.
*
* Bumped on state change, so it covers both "I just sent something" and "it
* just did something", and is deliberately NOT bumped by metadata-only writes
* like `rename()` — renaming a session must not reorder the list.
*
* Optional: absent from a daemon that predates it. Clients should fall back to
* `createdAt` rather than treating a missing value as "never active", which
* would sink every session on an older daemon to the bottom.
*/
lastActivityAt?: string;
attachedClients: number;
/**
* Session role. "conductor" marks the per-tenant conductor session (the
Expand Down
1 change: 1 addition & 0 deletions src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2521,6 +2521,7 @@ export class Session {
status: this.#status,
createdBy: this.createdBy,
createdAt: this.createdAt,
lastActivityAt: this.#lastActivityAt,
attachedClients: this.#clients.size,
role: this.role,
providerId: this.#provider.id,
Expand Down
44 changes: 40 additions & 4 deletions web/src/components/SessionListPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import {
roleLabel,
type FilteredFleetGroup,
} from "../lib/fleet";
import {
bandSections,
holdOrder,
snapshotOrder,
type BandedSection,
type OrderSnapshot,
} from "../lib/session-order";
import { sessionAgentLabel, shortSub } from "../lib/identity";
import { fetchPanels, liveProgress, livePanelMember, resetPanels } from "../state/panels";
import { nowTick } from "../state/clock";
Expand Down Expand Up @@ -91,6 +98,20 @@ const SessionListPane: Component = () => {
filterFleet(groupFleet(sessionList()), filter()),
);

// Ordering is held while the pointer is over the list: a row that moves
// between aiming and clicking opens the wrong session. The pending reorder
// lands on leave. Membership still updates live — see holdOrder.
const [pointerInside, setPointerInside] = createSignal(false);
let heldOrder: OrderSnapshot | null = null;
const sections = createMemo<BandedSection<FilteredFleetGroup>[]>(() => {
const live = bandSections(groups());
if (!pointerInside()) {
heldOrder = snapshotOrder(live);
return live;
}
return holdOrder(live, heldOrder);
});

// The goal whose panels we poll, as a plain STRING.
//
// A memo over a primitive, deliberately. Reading `focusedSession()?.collaboration`
Expand Down Expand Up @@ -156,11 +177,26 @@ const SessionListPane: Component = () => {
>
<SessionFilter value={filter()} onInput={setFilter} />
<Show when={groups().length > 0} fallback={<NoMatch query={filter()} />}>
<ul class="flex flex-col py-1">
<For each={groups()}>
{(g) => <FleetGroupRows group={g} />}
<div
onPointerEnter={() => setPointerInside(true)}
onPointerLeave={() => setPointerInside(false)}
>
<For each={sections()}>
{(section) => (
<ul class="flex flex-col py-1">
<li
class="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-fg-muted"
aria-hidden="true"
>
{section.label}
</li>
<For each={section.groups}>
{(g) => <FleetGroupRows group={g} />}
</For>
</ul>
)}
</For>
</ul>
</div>
</Show>
</Show>
<Show when={focusedSessionId()}>
Expand Down
186 changes: 186 additions & 0 deletions web/src/lib/session-order.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Session-list ordering.
*
* The behaviour these pin, in order of how badly the failure reads to a user:
*
* 1. A session blocked on YOU is never buried under idle ones — including
* when the blocked party is a fleet's child and the orchestrator is idle.
* 2. The list orders by relevance, not creation time (the original bug).
* 3. It does not reorder under the pointer.
* 4. It degrades sanely against a daemon that never sends lastActivityAt.
*/

import { describe, it, expect } from "vitest";

import {
BAND,
activityKey,
bandOf,
bandOfGroup,
bandSections,
compareByRecency,
holdOrder,
snapshotOrder,
} from "./session-order";
import type { FleetGroup } from "./fleet";
import type { SessionInfo, SessionStatus } from "../protocol/types";

function mk(
id: string,
over: Partial<SessionInfo> & { status?: SessionStatus } = {},
): SessionInfo {
return {
id,
name: id,
workdir: `/repo/${id}`,
status: "idle",
createdBy: "u",
createdAt: "2026-01-01T00:00:00.000Z",
attachedClients: 0,
...over,
} as SessionInfo;
}

const group = (lead: SessionInfo, children: SessionInfo[] = []): FleetGroup => ({
lead,
children,
isFleet: children.length > 0,
});

describe("bands", () => {
it("puts anything blocked on a human in NEEDS_YOU", () => {
expect(bandOf("waiting_approval")).toBe(BAND.NEEDS_YOU);
// A failed session is the other thing that wants a human; filing it under
// idle is how a failure goes unnoticed for an hour.
expect(bandOf("error")).toBe(BAND.NEEDS_YOU);
});

it("treats thinking and tool_running as one band so they cannot thrash", () => {
// These alternate several times a second. If they banded differently the
// row would jitter continuously.
expect(bandOf("thinking")).toBe(BAND.WORKING);
expect(bandOf("tool_running")).toBe(BAND.WORKING);
});

it("idle is idle", () => {
expect(bandOf("idle")).toBe(BAND.IDLE);
});

it("a fleet takes the most urgent band of anything in it", () => {
// The orchestrator sits idle while its children work — banding on the lead
// alone would hide a child that is blocked on an approval.
const fleet = group(mk("goal", { status: "idle" }), [
mk("kid-1", { status: "idle" }),
mk("kid-2", { status: "waiting_approval" }),
]);
expect(bandOfGroup(fleet)).toBe(BAND.NEEDS_YOU);
});
});

describe("recency", () => {
it("orders by lastActivityAt, not createdAt — the original bug", () => {
// Old session, driven all morning; new session, untouched since creation.
const old = mk("old", {
createdAt: "2026-01-01T00:00:00.000Z",
lastActivityAt: "2026-06-01T12:00:00.000Z",
});
const fresh = mk("fresh", {
createdAt: "2026-05-01T00:00:00.000Z",
lastActivityAt: "2026-05-01T00:00:00.000Z",
});
expect([fresh, old].sort(compareByRecency).map((s) => s.id)).toEqual(["old", "fresh"]);
});

it("falls back to createdAt when the daemon never sends lastActivityAt", () => {
// An older daemon must not sink every session to epoch 0 — that would be
// worse than the behaviour being replaced.
const s = mk("legacy", { createdAt: "2026-03-03T00:00:00.000Z" });
expect(activityKey(s)).toBe(Date.parse("2026-03-03T00:00:00.000Z"));
});

it("survives an unparseable timestamp instead of producing NaN order", () => {
expect(activityKey(mk("bad", { lastActivityAt: "not-a-date" }))).toBe(0);
});

it("a fleet is as recent as its busiest child", () => {
const fleet = group(
mk("goal", { lastActivityAt: "2026-01-01T00:00:00.000Z" }),
[mk("kid", { lastActivityAt: "2026-09-09T00:00:00.000Z" })],
);
const solo = group(mk("solo", { lastActivityAt: "2026-05-05T00:00:00.000Z" }));
const [section] = bandSections([solo, fleet]);
expect(section!.groups.map((g) => g.lead.id)).toEqual(["goal", "solo"]);
});
});

describe("bandSections", () => {
it("orders bands NEEDS YOU → WORKING → IDLE and drops empty ones", () => {
const sections = bandSections([
group(mk("i", { status: "idle" })),
group(mk("w", { status: "thinking" })),
group(mk("n", { status: "waiting_approval" })),
]);
expect(sections.map((s) => s.label)).toEqual(["Needs you", "Working", "Idle"]);
expect(sections.map((s) => s.groups[0]!.lead.id)).toEqual(["n", "w", "i"]);

// No empty headers when a band has nothing in it.
expect(bandSections([group(mk("only", { status: "idle" }))]).map((s) => s.label)).toEqual([
"Idle",
]);
});

it("is deterministic for same-millisecond sessions", () => {
// A fan-out creates children within the same millisecond; without a
// tie-break they would swap places on every re-render.
const at = "2026-04-04T00:00:00.000Z";
const a = group(mk("bbb", { lastActivityAt: at }));
const b = group(mk("aaa", { lastActivityAt: at }));
const once = bandSections([a, b]).flatMap((s) => s.groups.map((g) => g.lead.id));
const twice = bandSections([b, a]).flatMap((s) => s.groups.map((g) => g.lead.id));
expect(once).toEqual(twice);
});
});

describe("holdOrder — does not reorder under the pointer", () => {
it("keeps a row in place when its band changes mid-hover", () => {
const idle = group(mk("a", { status: "idle", lastActivityAt: "2026-01-02T00:00:00.000Z" }));
const other = group(mk("b", { status: "idle", lastActivityAt: "2026-01-01T00:00:00.000Z" }));
const before = bandSections([idle, other]);
const snap = snapshotOrder(before);
expect(before.map((s) => s.label)).toEqual(["Idle"]);

// 'b' starts asking for approval — normally it would jump to a new top band.
const after = bandSections([
idle,
group(mk("b", { status: "waiting_approval", lastActivityAt: "2026-01-01T00:00:00.000Z" })),
]);
expect(after.map((s) => s.label)).toEqual(["Needs you", "Idle"]);

// Held: the layout the user is pointing at is unchanged.
const held = holdOrder(after, snap);
expect(held.map((s) => s.label)).toEqual(["Idle"]);
expect(held[0]!.groups.map((g) => g.lead.id)).toEqual(["a", "b"]);
});

it("still shows new sessions and drops destroyed ones while holding", () => {
// Order is frozen; MEMBERSHIP is not. Freezing membership would leave a
// destroyed session clickable — a worse bug than the one being prevented.
const a = group(mk("a", { status: "idle" }));
const b = group(mk("b", { status: "idle" }));
const snap = snapshotOrder(bandSections([a, b]));

const c = group(mk("c", { status: "idle" }));
const held = holdOrder(bandSections([a, c]), snap); // b destroyed, c appeared
const ids = held.flatMap((s) => s.groups.map((g) => g.lead.id));
expect(ids).toContain("a");
expect(ids).toContain("c");
expect(ids).not.toContain("b");
// The newcomer appends rather than displacing the row being aimed at.
expect(ids.indexOf("c")).toBeGreaterThan(ids.indexOf("a"));
});

it("is a no-op without a snapshot", () => {
const live = bandSections([group(mk("a", { status: "waiting_approval" }))]);
expect(holdOrder(live, null)).toEqual(live);
});
});
Loading
Loading