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) => ( +
+

+ {props.group.label} + {props.group.nodes.length} +

+
    + {(node) => } +
+
+); + +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 = ( + <> +
+ + {props.node.state} + + {label()} + + {props.node.task.shape} + +
+ {/* 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; +}