diff --git a/web/src/components/CenterPane.tsx b/web/src/components/CenterPane.tsx
index d46df41..9178389 100644
--- a/web/src/components/CenterPane.tsx
+++ b/web/src/components/CenterPane.tsx
@@ -24,11 +24,13 @@ import UiRequestBar from "./transcript/UiRequestBar";
import PromptBox from "./prompt/PromptBox";
import SessionControls from "./SessionControls";
import Transcript from "./transcript/Transcript";
+import FleetRail from "./FleetRail";
import WorkerIndicator from "./transcript/WorkerIndicator";
import { openNewSessionModal } from "./NewSessionModal";
import { openImportModal } from "./SessionImportModal";
const CenterPane: Component = () => {
+ const isConductor = () => focusedSession()?.role === "conductor";
return (
{
}
>
-
-
-
-
-
+ {/* The conductor is a lens, not a wall (§3): it gets the SAME chat
+ cockpit every agent gets, with the fleet board docked beside it —
+ never a second, divergent copy of a session's chat. */}
+ }>
+
+
+
+
+
+
+
);
};
+/**
+ * Chat cockpit — identical for every session, conductor included. Extracted so
+ * the conductor branch docks a rail beside it rather than reimplementing it.
+ */
+const SessionBody: Component = () => (
+ <>
+
+
+
+
+
+ >
+);
+
const SessionHeader: Component = () => (
{(s) => (
diff --git a/web/src/components/FleetRail.tsx b/web/src/components/FleetRail.tsx
new file mode 100644
index 0000000..aa5e8af
--- /dev/null
+++ b/web/src/components/FleetRail.tsx
@@ -0,0 +1,213 @@
+/**
+ * The conductor's fleet rail — the state-grouped board, docked beside its chat.
+ *
+ * conductor-frontends-design §4 makes this the primary fleet view: the
+ * operator's job is triage, and lanes answer "who needs me / what is running /
+ * what is ready to read" faster than a graph. All the classification lives in
+ * `lib/fleet-lanes.ts`; this file is the renderer.
+ *
+ * §3.B is the other rule shaping it: every node is a REAL session, so clicking
+ * one focuses that session's ordinary cockpit. "Take over" is not a feature
+ * here — it is the existing attach flow, reached from a fleet node.
+ */
+
+import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js";
+
+import { formatCostUsd } from "../lib/format";
+import {
+ groupIntoLanes,
+ needsYouCount,
+ type FleetLaneGroup,
+ type FleetNode,
+ type FleetNodeState,
+} from "../lib/fleet-lanes";
+import { fleetBoard, subscribeFleet, taskSession, unsubscribeFleet } from "../state/fleet";
+import { focusSession } from "../state/sessions";
+
+/**
+ * Semantic colours for the status vocabulary (§6). Deliberately separate from
+ * the product accent, which is chrome: this is a signal system, and `awaiting`
+ * must read as "you" rather than as "conductor".
+ *
+ * `working` is the only state that animates — motion is the scarcest signal on
+ * screen, so spending it anywhere else would devalue it. `disconnected` is
+ * quiet grey, never red: a dropped runner is a transport event, and colouring
+ * it as an error trains the operator to ignore errors.
+ */
+const STATE_STYLE: Record = {
+ awaiting: {
+ cls: "border-warn/60 bg-warn/15 text-warn",
+ title: "Waiting on you — an approval or a question is blocking this agent",
+ },
+ blocked: {
+ cls: "border-danger/60 bg-danger/15 text-danger",
+ title: "Blocked — hit the anti-spin failure limit and will not retry",
+ },
+ failed: {
+ cls: "border-danger/50 bg-danger/10 text-danger",
+ title: "Failed — the task errored",
+ },
+ working: {
+ cls: "border-warn/50 bg-warn/10 text-warn animate-pulse",
+ title: "Working — running a turn or a tool",
+ },
+ queued: {
+ cls: "border-border bg-bg text-fg-muted",
+ title: "Queued — accepted, waiting for a worker slot",
+ },
+ disconnected: {
+ cls: "border-border bg-bg text-fg-faint",
+ title: "Disconnected — the runner dropped. Not a failure; the dispatcher reconciles it",
+ },
+ done: {
+ cls: "border-success/40 bg-success/10 text-success",
+ title: "Done — the digest came back",
+ },
+ idle: { cls: "border-border bg-bg text-fg-faint", title: "Idle — settled and quiet" },
+};
+
+const FleetRail: Component = () => {
+ onMount(() => void subscribeFleet());
+ onCleanup(() => unsubscribeFleet());
+
+ const board = fleetBoard;
+ const lanes = createMemo(() => {
+ const b = board();
+ return groupIntoLanes(b.tasks, (t) => taskSession(b, t));
+ });
+ const attention = createMemo(() => needsYouCount(lanes()));
+
+ return (
+
+ );
+};
+
+const Lane: Component<{ group: FleetLaneGroup }> = (props) => (
+
+
+
+);
+
+const NodeRow: Component<{ node: FleetNode }> = (props) => {
+ const style = () => STATE_STYLE[props.node.state];
+ // A task with no session yet (a queued spawn) has nowhere to drill into, so
+ // it renders as a plain row rather than a button that would do nothing.
+ const target = () => props.node.session;
+ const label = () =>
+ props.node.session?.name ?? `${props.node.task.kind} ${props.node.task.id.slice(0, 8)}`;
+
+ const body = (
+ <>
+
+ {/* The digest is why "Ready to review" is a lane — clamped, because the
+ rail is a board, not a reader. */}
+
+ {(d) => (
+
{d()}
+ )}
+
+
+ {(e) =>
{e()}
}
+
+ 0 && props.node.state !== "done"}>
+
+ attempt {props.node.task.attempts + 1}
+
+
+ >
+ );
+
+ return (
+
+ {body}}
+ >
+ {(s) => (
+
+ )}
+
+
+ );
+};
+
+export default FleetRail;
diff --git a/web/src/lib/fleet-lanes.test.ts b/web/src/lib/fleet-lanes.test.ts
new file mode 100644
index 0000000..bf71e6a
--- /dev/null
+++ b/web/src/lib/fleet-lanes.test.ts
@@ -0,0 +1,178 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ groupIntoLanes,
+ laneFor,
+ needsYouCount,
+ nodeState,
+ stateRank,
+ type FleetNodeState,
+} from "./fleet-lanes";
+import type { FleetTaskWire, SessionInfo, SessionStatus } from "../protocol/types";
+
+function task(id: string, over: Partial = {}): FleetTaskWire {
+ return {
+ id,
+ kind: "spawn",
+ shape: "scout",
+ status: "running",
+ attempts: 0,
+ createdAt: 1_000,
+ createdBy: "agent:conductor",
+ ...over,
+ };
+}
+
+const session = (status: SessionStatus): SessionInfo => ({ id: "s1", status }) as SessionInfo;
+
+describe("nodeState", () => {
+ it("ranks awaiting above everything — a wedged agent never hides behind its task status", () => {
+ // The dispatch status describes the QUEUE; the session status describes the
+ // AGENT. A worker stuck on an approval is what needs a human, whatever the
+ // queue currently says.
+ for (const s of ["running", "claimed", "done", "queued"] as const) {
+ expect(nodeState(task("t", { status: s }), session("waiting_approval"))).toBe("awaiting");
+ }
+ });
+
+ it("maps terminal dispatch states straight through", () => {
+ expect(nodeState(task("t", { status: "blocked" }), null)).toBe("blocked");
+ expect(nodeState(task("t", { status: "failed" }), null)).toBe("failed");
+ expect(nodeState(task("t", { status: "done" }), session("idle"))).toBe("done");
+ });
+
+ it("reads working vs idle from the agent, not the queue", () => {
+ expect(nodeState(task("t"), session("thinking"))).toBe("working");
+ expect(nodeState(task("t"), session("tool_running"))).toBe("working");
+ // Claimed by the dispatcher but the agent is quiet — in flight, not busy.
+ expect(nodeState(task("t", { status: "claimed" }), session("idle"))).toBe("idle");
+ });
+
+ it("treats a session error as a failure of the task", () => {
+ expect(nodeState(task("t"), session("error"))).toBe("failed");
+ });
+
+ it("distinguishes a dropped runner from a failure", () => {
+ // §6: `disconnected` is a transport event, not a task error. Rendering it
+ // red would train the operator to ignore red.
+ expect(nodeState(task("t", { workerSessionId: "gone" }), null)).toBe("disconnected");
+ expect(stateRank("disconnected")).toBeLessThan(stateRank("done"));
+ });
+
+ it("treats a claimed spawn with no worker yet as queued, not disconnected", () => {
+ // The gap between claim and spawn is normal; calling it disconnected would
+ // flag every healthy dispatch for a moment.
+ expect(nodeState(task("t", { status: "claimed" }), null)).toBe("queued");
+ });
+});
+
+describe("stateRank", () => {
+ it("puts attention ahead of activity", () => {
+ const order: FleetNodeState[] = [
+ "awaiting",
+ "blocked",
+ "failed",
+ "working",
+ "queued",
+ "disconnected",
+ "done",
+ "idle",
+ ];
+ const ranks = order.map(stateRank);
+ expect(ranks).toEqual([...ranks].sort((a, b) => a - b));
+ expect(stateRank("awaiting")).toBeLessThan(stateRank("working"));
+ });
+});
+
+describe("laneFor", () => {
+ it("routes stopped states to Needs you", () => {
+ expect(laneFor("awaiting", task("t"))).toBe("needs-you");
+ expect(laneFor("blocked", task("t"))).toBe("needs-you");
+ expect(laneFor("failed", task("t"))).toBe("needs-you");
+ });
+
+ it("keeps in-flight states under Working, including a quiet agent", () => {
+ expect(laneFor("working", task("t"))).toBe("working");
+ expect(laneFor("queued", task("t"))).toBe("working");
+ expect(laneFor("disconnected", task("t"))).toBe("working");
+ // A live task whose agent is idle still belongs to the queue, and must not
+ // look finished.
+ expect(laneFor("idle", task("t"))).toBe("working");
+ });
+
+ it("only calls something reviewable when there is a digest to read", () => {
+ expect(laneFor("done", task("t", { resultDigest: "found the bug" }))).toBe("review");
+ expect(laneFor("done", task("t"))).toBe("done");
+ });
+});
+
+describe("groupIntoLanes", () => {
+ const sessions: Record = {
+ busy: { id: "busy", status: "thinking" } as SessionInfo,
+ stuck: { id: "stuck", status: "waiting_approval" } as SessionInfo,
+ };
+ const lookup = (t: FleetTaskWire) =>
+ t.workerSessionId ? (sessions[t.workerSessionId] ?? null) : null;
+
+ it("orders lanes for triage and omits empty ones", () => {
+ const groups = groupIntoLanes(
+ [
+ task("done1", { status: "done", resultDigest: "d" }),
+ task("run1", { workerSessionId: "busy" }),
+ task("stuck1", { workerSessionId: "stuck" }),
+ ],
+ lookup,
+ );
+ expect(groups.map((g) => g.lane)).toEqual(["needs-you", "working", "review"]);
+ // "Done" had no members and is absent — four permanent headers over one
+ // running task would be chrome, not information.
+ expect(groups.some((g) => g.lane === "done")).toBe(false);
+ });
+
+ it("sorts by attention within a lane, keeping input order as the tiebreak", () => {
+ const groups = groupIntoLanes(
+ [
+ task("failed1", { status: "failed" }),
+ task("stuck1", { workerSessionId: "stuck" }),
+ task("blocked1", { status: "blocked" }),
+ ],
+ lookup,
+ );
+ const needsYou = groups[0]!;
+ expect(needsYou.lane).toBe("needs-you");
+ expect(needsYou.nodes.map((n) => n.state)).toEqual(["awaiting", "blocked", "failed"]);
+ });
+
+ it("preserves the caller's newest-first order for equal states", () => {
+ const groups = groupIntoLanes(
+ [task("newer", { status: "blocked" }), task("older", { status: "blocked" })],
+ lookup,
+ );
+ expect(groups[0]!.nodes.map((n) => n.task.id)).toEqual(["newer", "older"]);
+ });
+
+ it("carries the resolved session onto each node", () => {
+ const [g] = groupIntoLanes([task("run1", { workerSessionId: "busy" })], lookup);
+ expect(g!.nodes[0]!.session?.id).toBe("busy");
+ expect(g!.nodes[0]!.state).toBe("working");
+ });
+
+ it("returns nothing for an empty board", () => {
+ expect(groupIntoLanes([], lookup)).toEqual([]);
+ expect(needsYouCount([])).toBe(0);
+ });
+});
+
+describe("needsYouCount", () => {
+ it("counts everything stopped, since none of it clears itself", () => {
+ const groups = groupIntoLanes(
+ [
+ task("a", { status: "blocked" }),
+ task("b", { status: "failed" }),
+ task("c", { status: "done" }),
+ ],
+ () => null,
+ );
+ expect(needsYouCount(groups)).toBe(2);
+ });
+});
diff --git a/web/src/lib/fleet-lanes.ts b/web/src/lib/fleet-lanes.ts
new file mode 100644
index 0000000..ad800bc
--- /dev/null
+++ b/web/src/lib/fleet-lanes.ts
@@ -0,0 +1,195 @@
+/**
+ * The fleet's status vocabulary and its state-grouped lanes.
+ *
+ * conductor-frontends-design §4 makes a state-grouped list the primary fleet
+ * view — the operator's real job is triage ("who needs me, what is ready to
+ * read, what is still running"), and a list grouped by lifecycle answers that
+ * faster than a graph. §6 defines the vocabulary that grouping rests on.
+ *
+ * Pure functions, no Solid: the classification IS the design decision here, so
+ * it belongs somewhere a test can reach without a reactive root — the same
+ * split `lib/fleet.ts` and `state/fleet.ts` already use.
+ */
+
+import type { FleetTaskWire, SessionInfo, SessionStatus } from "../protocol/types";
+
+/**
+ * One node's state, derived from dispatch status × session status (§6).
+ *
+ * Two entries are load-bearing and most tools get them wrong:
+ *
+ * - `awaiting` **outranks** `working`. A node that needs a human must never
+ * hide behind one that is merely busy, so it is checked first and sorts
+ * first. This is the whole reason the vocabulary is ranked rather than a
+ * plain enum.
+ * - `disconnected` is **not** `failed`. A runner dropping is a transport
+ * event, not a task error; rendering it red would train the operator to
+ * ignore red.
+ */
+export type FleetNodeState =
+ | "awaiting"
+ | "blocked"
+ | "failed"
+ | "working"
+ | "queued"
+ | "disconnected"
+ | "done"
+ | "idle";
+
+/**
+ * Attention rank — lower sorts first. Attention outranks activity, which is
+ * why `awaiting` leads and `done` trails.
+ */
+const RANK: Record = {
+ awaiting: 0,
+ blocked: 1,
+ failed: 2,
+ working: 3,
+ queued: 4,
+ disconnected: 5,
+ done: 6,
+ idle: 7,
+};
+
+export function stateRank(s: FleetNodeState): number {
+ return RANK[s];
+}
+
+/** Session statuses that mean "this agent's turn is actually in flight". */
+const BUSY: ReadonlySet = new Set(["thinking", "tool_running"]);
+
+/**
+ * Classify one task, given the session it points at (null when the task has no
+ * session yet, or its session is gone).
+ *
+ * Order matters and encodes §6's ranking rather than the task's own lifecycle:
+ * a session sitting at `waiting_approval` is `awaiting` even while its task
+ * still reads `running`, because the dispatch status describes the QUEUE and
+ * the session status describes the AGENT — and it is the agent that is stuck
+ * on a human.
+ */
+export function nodeState(task: FleetTaskWire, session: SessionInfo | null): FleetNodeState {
+ // Needs a human — checked before anything else, including terminal states, so
+ // a task whose worker is wedged on an approval cannot be filed under "done"
+ // by a status that has not caught up yet.
+ if (session?.status === "waiting_approval") return "awaiting";
+
+ switch (task.status) {
+ case "blocked":
+ return "blocked";
+ case "failed":
+ return "failed";
+ case "done":
+ return "done";
+ case "queued":
+ return "queued";
+ case "claimed":
+ case "running": {
+ if (session === null) {
+ // Claimed or running with no session to point at. For a spawn that has
+ // not created its worker yet this is simply the gap between claim and
+ // spawn; for one whose worker has gone it is a dropped runner. Either
+ // way it is NOT a failure — the task is alive in the queue and the
+ // dispatcher will reconcile it.
+ return task.workerSessionId ? "disconnected" : "queued";
+ }
+ if (session.status === "error") return "failed";
+ return BUSY.has(session.status) ? "working" : "idle";
+ }
+ }
+}
+
+/** The four triage lanes of §4, in display order. */
+export type FleetLane = "needs-you" | "working" | "review" | "done";
+
+export const LANE_ORDER: readonly FleetLane[] = ["needs-you", "working", "review", "done"];
+
+export const LANE_LABEL: Record = {
+ "needs-you": "Needs you",
+ working: "Working",
+ review: "Ready to review",
+ done: "Done",
+};
+
+/**
+ * Which lane a node belongs in.
+ *
+ * "Ready to review" is deliberately narrow: a finished task that produced a
+ * digest, i.e. one with something for a human to actually read. A `done` task
+ * with no digest is settled and goes to Done. §4 envisages this lane eventually
+ * carrying worktree diffs and sequenced merge (P5.4); until then, promising a
+ * review lane that holds nothing readable would be worse than not having one.
+ */
+export function laneFor(state: FleetNodeState, task: FleetTaskWire): FleetLane {
+ switch (state) {
+ case "awaiting":
+ case "blocked":
+ case "failed":
+ return "needs-you";
+ case "working":
+ case "queued":
+ case "disconnected":
+ return "working";
+ case "done":
+ return task.resultDigest ? "review" : "done";
+ case "idle":
+ // A live task whose agent is quiet: still in flight from the queue's
+ // point of view, so it stays under Working rather than looking finished.
+ return "working";
+ }
+}
+
+export interface FleetNode {
+ task: FleetTaskWire;
+ session: SessionInfo | null;
+ state: FleetNodeState;
+}
+
+export interface FleetLaneGroup {
+ lane: FleetLane;
+ label: string;
+ nodes: FleetNode[];
+}
+
+/**
+ * Group tasks into the triage lanes, attention-first within each.
+ *
+ * Empty lanes are omitted: four permanent headers on a fleet with one running
+ * task is chrome, not information.
+ */
+export function groupIntoLanes(
+ tasks: readonly FleetTaskWire[],
+ sessionFor: (task: FleetTaskWire) => SessionInfo | null,
+): FleetLaneGroup[] {
+ const byLane = new Map();
+
+ for (const task of tasks) {
+ const session = sessionFor(task);
+ const state = nodeState(task, session);
+ const lane = laneFor(state, task);
+ const node: FleetNode = { task, session, state };
+ const bucket = byLane.get(lane);
+ if (bucket) bucket.push(node);
+ else byLane.set(lane, [node]);
+ }
+
+ const out: FleetLaneGroup[] = [];
+ for (const lane of LANE_ORDER) {
+ const nodes = byLane.get(lane);
+ if (!nodes || nodes.length === 0) continue;
+ // Attention first, then newest — the caller's list is already newest-first,
+ // so a stable sort on rank alone preserves that as the tiebreak.
+ nodes.sort((a, b) => stateRank(a.state) - stateRank(b.state));
+ out.push({ lane, label: LANE_LABEL[lane], nodes });
+ }
+ return out;
+}
+
+/**
+ * How many nodes are waiting on a human — the count worth surfacing ambiently
+ * (§8). Blocked and failed are included: both are stopped and neither clears
+ * itself.
+ */
+export function needsYouCount(groups: readonly FleetLaneGroup[]): number {
+ return groups.find((g) => g.lane === "needs-you")?.nodes.length ?? 0;
+}