diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 2f92702..cde0075 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -107,6 +107,7 @@ const samples: { [T in ClientTypes]: Extract } = { "fs.browse_dir": { type: "fs.browse_dir", id: "r19", path: "/home" }, "claude.config": { type: "claude.config", id: "r20", sessionId: "s1" }, "blackboard.index": { type: "blackboard.index", id: "r60", sessionId: "s1" }, + "collaboration.panels": { type: "collaboration.panels", id: "r62", sessionId: "s1" }, "blackboard.read": { type: "blackboard.read", id: "r61", diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index f8c1362..dbc9dea 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -348,6 +348,12 @@ export const claudeConfigSchema = z.object({ * namespace, and the daemon's `isValidArtifactKind` gives a specific error * naming the valid core kinds instead of an opaque schema rejection. */ +export const collaborationPanelsSchema = z.object({ + ...base, + type: z.literal("collaboration.panels"), + sessionId: sessionIdField, +}); + export const blackboardIndexSchema = z.object({ ...base, type: z.literal("blackboard.index"), @@ -610,6 +616,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ claudeConfigSchema, blackboardIndexSchema, blackboardReadSchema, + collaborationPanelsSchema, modelsListSchema, sessionExportSchema, sessionImportSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 2b19cfe..41fcdc0 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -86,6 +86,11 @@ export const CAPABILITIES = { * showing one that errors on click. */ BLACKBOARD: "blackboard", + /** + * Live collaboration panel state (`collaboration.panels`). Declared by the + * daemon so a client only offers panel UI when the data exists behind it. + */ + PANELS: "collaboration.panels", /** * Push via NATIVE device tokens (APNs/FCM) rather than Expo. Advertised by * the daemon when the `native` or `relay` transport is configured; a client @@ -860,6 +865,7 @@ export type ClientMessage = | ClaudeConfigMsg | BlackboardIndexMsg | BlackboardReadMsg + | CollaborationPanelsMsg | ModelsListMsg | SessionExportMsg | SessionImportMsg @@ -1479,6 +1485,23 @@ export interface BlackboardIndexMsg extends BaseClientMsg { sessionId: string; } +/** + * Live panel state for a collaboration — which fan-outs are in flight and how + * far along each member is (docs/collaborative-session-design.md §7). + * + * Exists because nothing else on the wire carries it. A panel's whole point is + * that N agents work AT ONCE, and a client could previously see the fleet and + * the joined result but never the parallelism itself — the most legible part of + * the feature was the one part invisible to the UI. + * + * `sessionId` may name the orchestrator or any of its role-children; the daemon + * resolves a child to its parent's goal, exactly as `blackboard.index` does. + */ +export interface CollaborationPanelsMsg extends BaseClientMsg { + type: "collaboration.panels"; + sessionId: string; +} + /** * Read one artifact body from a collaboration's goal blackboard. * @@ -1708,6 +1731,42 @@ export interface ClaudeConfigResultMsg { hooks: ClaudeConfigHook[]; } +/** One member of a dispatch group, as a client sees it. */ +export interface CollaborationPanelMember { + /** Target session id. Null for a member with no session (a spawn-shaped one). */ + sessionId: string | null; + /** 1-based position within the fan-out, as dispatched — stable across reads. */ + ordinal: number; + /** + * Queue state. Terminal values are `done` / `failed` / `blocked`; the barrier + * fires once every member reaches one, which is why a client can render + * "2 of 3 settled" without knowing the barrier's rules. + */ + status: "queued" | "claimed" | "running" | "done" | "failed" | "blocked"; +} + +/** One fan-out (a dispatch group) and how far along it is. */ +export interface CollaborationPanel { + groupId: string; + /** Epoch ms the fan-out was queued. */ + createdAt: number; + /** Members in fan-out order. */ + members: CollaborationPanelMember[]; + /** How many members have reached a terminal state. */ + settled: number; + /** True once every member is terminal — i.e. the barrier has joined. */ + joined: boolean; +} + +export interface CollaborationPanelsResultMsg { + type: "collaboration.panels.result"; + requestId: string; + /** The GOAL session — the orchestrator, even when a child was asked for. */ + sessionId: string; + /** Newest fan-out first. */ + panels: CollaborationPanel[]; +} + /** One index row: what exists, at what version, by whom — never a body. */ export interface BlackboardIndexEntry { /** A core kind (`spec`, `research`, …) or `extra/`. */ @@ -2129,6 +2188,7 @@ export type DaemonMessage = | ClaudeConfigResultMsg | BlackboardIndexResultMsg | BlackboardReadResultMsg + | CollaborationPanelsResultMsg | ModelsListResultMsg | SessionExportResultMsg | SessionImportResultMsg diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index ad1ab9f..eb979ff 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -109,6 +109,7 @@ import type { ClientMessage, CollaborationConfig, CollaborationCost, + CollaborationPanel, CollaborationRole, DaemonMessage, McpServerStatus, @@ -193,6 +194,19 @@ function normalizeWorkdir(input: string): string | null { * unbounded resume can block startup or OOM. Cap to the newest-N sessions and * stop past a deadline (applied WITHIN each transcript parse too, not just * between sessions); the rest stay on disk (loadable on a future restart). */ +/** + * Recent fan-outs a client is shown per goal. A sidebar needs the live one plus + * a little history for context, not an orchestrator's whole dispatch career. + */ +const PANEL_HISTORY_LIMIT = 5; + +/** Task statuses that will never change again — mirrors the barrier's rule. */ +const TERMINAL_TASK_STATUS: ReadonlySet = new Set([ + "done", + "failed", + "blocked", +]); + const RESUME_MAX_SESSIONS = 50; const RESUME_DEADLINE_MS = 20_000; /** Per-session transcript read budget on resume. Scrollback keeps at most @@ -851,6 +865,8 @@ mcpHub: this.#mcpHub, return this.#blackboardIndex(msg, auth); case "blackboard.read": return this.#blackboardRead(msg, auth); + case "collaboration.panels": + return this.#collaborationPanels(msg, auth); case "models.list": return this.#modelsList(msg); case "session.export": @@ -1626,6 +1642,83 @@ mcpHub: this.#mcpHub, }; } + /** + * Live panel state for one goal (§7) — what a client needs to SHOW a fan-out + * while it is running. + * + * Gated on `session:list`, the same tier as `blackboard.index`: this is + * metadata about sessions the holder can already enumerate, carrying no + * prompts and no artifact bodies. Scoped to the goal's OWN dispatches via + * `createdBy`, so one collaboration's panels never surface in another's UI. + */ + #collaborationPanels( + msg: Extract, + auth: AuthContext, + ): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_LIST)) { + return { + type: "response.error", + requestId: msg.id, + error: "Missing scope: session:list", + code: "forbidden", + }; + } + const resolved = this.#resolveGoalSession(msg.sessionId, auth); + if (!resolved.ok) { + return { + type: "response.error", + requestId: msg.id, + error: resolved.error, + code: resolved.code, + }; + } + const goal = resolved.goal; + const rows = this.#store.dispatchRecentGroups( + goal.accountId, + goal.projectId, + orchestratorCreatedBy(goal.id), + PANEL_HISTORY_LIMIT, + ); + + // Group in insertion order of first appearance: the store already returns + // newest-group-first, so a Map preserves that without a second sort. + const byGroup = new Map(); + for (const t of rows) { + if (!t.groupId) continue; + let panel = byGroup.get(t.groupId); + if (!panel) { + panel = { + groupId: t.groupId, + createdAt: t.createdAt, + members: [], + settled: 0, + joined: false, + }; + byGroup.set(t.groupId, panel); + } + panel.members.push({ + sessionId: t.targetSession, + ordinal: t.groupOrdinal ?? panel.members.length + 1, + status: t.status, + }); + if (TERMINAL_TASK_STATUS.has(t.status)) panel.settled++; + } + const panels = [...byGroup.values()].map((p) => ({ + ...p, + members: [...p.members].sort((a, b) => a.ordinal - b.ordinal), + // `joined` is derived from the members rather than from a stored flag, so + // it cannot disagree with what the same payload shows. + joined: p.members.length > 0 && p.settled === p.members.length, + })); + + return { + type: "collaboration.panels.result", + requestId: msg.id, + sessionId: goal.id, + panels, + }; + } + #fsErr(requestId: string, err: unknown): DaemonMessage { if (err instanceof FsAccessError) { return { type: "response.error", requestId, error: err.message, code: err.code }; diff --git a/src/daemon/store.ts b/src/daemon/store.ts index 9108fa1..383d632 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -1053,6 +1053,47 @@ export class Store { .immediate(); } + /** + * Members of the most recent dispatch GROUPS created by one dispatcher, newest + * group first and ordinal-ordered within each — the client-facing panel view. + * + * Returns member rows rather than pre-grouped shapes so the caller decides how + * many groups to keep; `limitGroups` bounds the work in SQL via a subquery on + * distinct group ids, because an orchestrator that has run fifty panels should + * not stream fifty groups to a sidebar. + */ + dispatchRecentGroups( + accountId: string, + projectId: string, + createdBy: string, + limitGroups = 5, + ): DispatchTaskRow[] { + const rows = this.#db + .prepare( + `SELECT * FROM dispatch_tasks + WHERE account_id = ? AND project_id = ? AND created_by = ? + AND group_id IN ( + SELECT group_id FROM dispatch_tasks + WHERE account_id = ? AND project_id = ? AND created_by = ? + AND group_id IS NOT NULL + GROUP BY group_id + ORDER BY MAX(created_at) DESC + LIMIT ? + ) + ORDER BY created_at DESC, group_ordinal ASC, id ASC`, + ) + .all( + accountId, + projectId, + createdBy, + accountId, + projectId, + createdBy, + limitGroups, + ) as RawDispatchRow[]; + return rows.map(rowToDispatchTask); + } + /** * Every member of one dispatch group, oldest first — the barrier's read * (docs/collaborative-session-design.md §7 step 3). diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts index e055e83..0e48c7f 100644 --- a/src/tests/collaboration.test.ts +++ b/src/tests/collaboration.test.ts @@ -1938,3 +1938,171 @@ describe("the collaboration cost roll-up", () => { expect(next._collaborationCostForTest(goal.id)!.children).toBe(2); }); }); + +// ── collaboration.panels — making the parallelism visible ─────────────────── + +// A panel's whole point is that N agents work AT ONCE, and until this verb +// existed nothing on the wire carried that: a client could see the fleet and +// the joined result but never the fan-out in flight. Verified live before it was +// built — two frontier models reviewed the same file simultaneously and the web +// UI rendered it as an ordinary transcript message. +describe("collaboration.panels", () => { + const CONFIG: CollaborationConfig = { + goal: "watch me fan out", + roles: [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "gemini", count: 3 }, + ], + }; + + function withDispatch(): void { + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: mkConfig({ + dispatch: { + enabled: true, tickMs: 999_999, leaseMs: 60_000, failureLimit: 2, + maxConcurrentWorkers: 2, workerToolBudget: 7, retryBaseMs: 0, + }, + }), + providers: makeRegistry(), + }); + } + + const createGoal = async (id: string): Promise => { + const resp = await run({ type: "session.create", id, name: id, workdir, collaboration: CONFIG }); + if (resp.type !== "response.ok") throw new Error("create failed"); + return resp.data as SessionInfo; + }; + + const panelsOf = async (sessionId: string) => { + const resp = await run({ type: "collaboration.panels", id: `p-${Math.random()}`, sessionId }); + if (resp.type !== "collaboration.panels.result") throw new Error(`unexpected ${resp.type}`); + return resp; + }; + + test("reports members in fan-out order with live status, before anything settles", async () => { + withDispatch(); + const goal = await createGoal("pn1"); + const kids = childrenOf(await allSessions(), goal.id); + const deps = manager._orchestratorFleetDepsForTest(goal.id, AUTH.accountId, AUTH.projectId); + deps.dispatch!.enqueuePanel!({ targets: kids.map((k) => k.id), prompt: "review", shape: "scout" }); + + const { panels, sessionId } = await panelsOf(goal.id); + expect(sessionId).toBe(goal.id); + expect(panels).toHaveLength(1); + const p = panels[0]!; + expect(p.members.map((m) => m.ordinal)).toEqual([1, 2, 3]); + expect(p.members.map((m) => m.sessionId)).toEqual(kids.map((k) => k.id)); + expect(p.members.every((m) => m.status === "queued")).toBe(true); + // Nothing terminal yet — this is the state a UI must be able to render. + expect(p.settled).toBe(0); + expect(p.joined).toBe(false); + }); + + test("settled count rises as members finish, and joined flips only at the end", async () => { + withDispatch(); + const goal = await createGoal("pn2"); + const kids = childrenOf(await allSessions(), goal.id); + const deps = manager._orchestratorFleetDepsForTest(goal.id, AUTH.accountId, AUTH.projectId); + const { groupId } = deps.dispatch!.enqueuePanel!({ + targets: kids.map((k) => k.id), prompt: "review", shape: "scout", + }); + const members = store.dispatchGroupMembers(AUTH.accountId, AUTH.projectId, groupId); + + store.dispatchComplete(members[0]!.id, "one", Date.now()); + let p = (await panelsOf(goal.id)).panels[0]!; + expect(p.settled).toBe(1); + expect(p.joined).toBe(false); // 1/3 — a UI shows progress, not completion + + store.dispatchComplete(members[1]!.id, "two", Date.now()); + store.dispatchComplete(members[2]!.id, "three", Date.now()); + p = (await panelsOf(goal.id)).panels[0]!; + expect(p.settled).toBe(3); + expect(p.joined).toBe(true); + }); + + test("a FAILED member counts as settled — the barrier joins on terminal, not success", async () => { + // If `joined` waited for success, a UI would show a panel spinning forever + // on a member that already gave up. The rendered state has to match the + // barrier's actual rule. + withDispatch(); + const goal = await createGoal("pn3"); + const kids = childrenOf(await allSessions(), goal.id); + const deps = manager._orchestratorFleetDepsForTest(goal.id, AUTH.accountId, AUTH.projectId); + const { groupId } = deps.dispatch!.enqueuePanel!({ + targets: kids.map((k) => k.id), prompt: "review", shape: "scout", + }); + const members = store.dispatchGroupMembers(AUTH.accountId, AUTH.projectId, groupId); + store.dispatchComplete(members[0]!.id, "ok", Date.now()); + store.dispatchComplete(members[1]!.id, "ok", Date.now()); + // Burn the third to blocked. + for (let i = 0; i < 3; i++) store.dispatchFail(members[2]!.id, "nope", Date.now(), { retryable: false }); + + const p = (await panelsOf(goal.id)).panels[0]!; + expect(p.members.some((m) => m.status === "failed" || m.status === "blocked")).toBe(true); + expect(p.settled).toBe(3); + expect(p.joined).toBe(true); + }); + + test("a child id resolves to its parent's panels", async () => { + withDispatch(); + const goal = await createGoal("pn4"); + const kids = childrenOf(await allSessions(), goal.id); + manager._orchestratorFleetDepsForTest(goal.id, AUTH.accountId, AUTH.projectId) + .dispatch!.enqueuePanel!({ targets: kids.map((k) => k.id), prompt: "r", shape: "scout" }); + const viaChild = await panelsOf(kids[0]!.id); + expect(viaChild.sessionId).toBe(goal.id); + expect(viaChild.panels).toHaveLength(1); + }); + + test("one goal never sees another's panels", async () => { + withDispatch(); + const mine = await createGoal("pn5"); + const other = await createGoal("pn6"); + const all = await allSessions(); + manager._orchestratorFleetDepsForTest(other.id, AUTH.accountId, AUTH.projectId) + .dispatch!.enqueuePanel!({ + targets: childrenOf(all, other.id).map((k) => k.id), prompt: "r", shape: "scout", + }); + // Their panel exists; mine has none. + expect((await panelsOf(other.id)).panels).toHaveLength(1); + expect((await panelsOf(mine.id)).panels).toHaveLength(0); + }); + + test("a plain session is told it has no collaboration", async () => { + withDispatch(); + const resp0 = await run({ type: "session.create", id: "pn7", name: "plain", workdir }); + const plain = (resp0 as { data: SessionInfo }).data; + const resp = await run({ type: "collaboration.panels", id: "pn7q", sessionId: plain.id }); + expect(resp.type).toBe("response.error"); + if (resp.type === "response.error") expect(resp.code).toBe("invalid_request"); + }); + + test("requires session:list", async () => { + withDispatch(); + const goal = await createGoal("pn8"); + const noList: AuthContext = { + ...AUTH, + scopes: AUTH.scopes.filter((s) => s !== "session:list") as AuthContext["scopes"], + }; + const resp = await manager.handle( + { type: "collaboration.panels", id: "pn8q", sessionId: goal.id }, + noList, + { id: "c2", auth: noList, send: () => {} }, + ); + expect(resp.type).toBe("response.error"); + if (resp.type === "response.error") expect(resp.code).toBe("forbidden"); + }); + + test("history is bounded — a long-running goal does not stream every fan-out", async () => { + withDispatch(); + const goal = await createGoal("pn9"); + const kids = childrenOf(await allSessions(), goal.id).map((k) => k.id); + const deps = manager._orchestratorFleetDepsForTest(goal.id, AUTH.accountId, AUTH.projectId); + for (let i = 0; i < 8; i++) { + deps.dispatch!.enqueuePanel!({ targets: kids, prompt: `round ${i}`, shape: "scout" }); + } + const { panels } = await panelsOf(goal.id); + expect(panels.length).toBeLessThanOrEqual(5); + expect(panels.length).toBeGreaterThan(0); + }); +}); diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts index 250e8fa..03d41fd 100644 --- a/src/tests/protocol.test.ts +++ b/src/tests/protocol.test.ts @@ -436,6 +436,8 @@ describe("DaemonMessage routing", () => { return `bb.index:${msg.entries.length}`; case "blackboard.read.result": return `bb.read:${msg.artifact?.kind ?? "none"}`; + case "collaboration.panels.result": + return `panels:${msg.panels.length}`; case "models.list.result": return `models:${msg.models.length}`; case "session.export.result": diff --git a/web/src/components/SessionListPane.test.tsx b/web/src/components/SessionListPane.test.tsx index 5b8e7e0..ce42bf0 100644 --- a/web/src/components/SessionListPane.test.tsx +++ b/web/src/components/SessionListPane.test.tsx @@ -12,6 +12,16 @@ vi.mock("../state/connection", () => ({ vi.mock("./files/FileTree", () => ({ default: () => null })); vi.mock("./AnalyticsPanel", () => ({ default: () => null })); vi.mock("./NewSessionModal", () => ({ openNewSessionModal: vi.fn() })); +// Panel state is daemon-fed; drive it directly so the render is under test +// rather than the polling transport. +const livePanelMock = vi.hoisted(() => vi.fn<() => unknown>(() => null)); +const livePanelMemberMock = vi.hoisted(() => vi.fn<(id: string) => unknown>(() => null)); +vi.mock("../state/panels", () => ({ + fetchPanels: vi.fn(() => Promise.resolve()), + resetPanels: vi.fn(), + livePanel: livePanelMock, + livePanelMember: livePanelMemberMock, +})); import SessionListPane from "./SessionListPane"; import { ingestSessionList, _resetSessionsForTest } from "../state/sessions"; @@ -195,3 +205,81 @@ describe("SessionListPane — fleet rendering", () => { expect(getByTitle(/Read-only role/)).toBeTruthy(); }); }); + +describe("SessionListPane — live panel state", () => { + const FLEET = [ + orchestrator("p", "refactor-auth", "make auth boring again"), + roleChild("p", "refactor-auth", "review", 1, false, "gemini"), + roleChild("p", "refactor-auth", "review", 2, false, "gemini"), + roleChild("p", "refactor-auth", "search", 1, false, "claude"), + ]; + + afterEach(() => { + livePanelMock.mockReturnValue(null); + livePanelMemberMock.mockReturnValue(null); + }); + + it("shows nothing when no fan-out is in flight", () => { + ingestSessionList(FLEET); + const { queryByText } = render(() => ); + expect(queryByText(/panel/)).toBeNull(); + }); + + it("shows settled-of-total on the orchestrator while a panel runs", () => { + livePanelMock.mockReturnValue({ + groupId: "g1", + createdAt: 0, + settled: 2, + joined: false, + members: [ + { sessionId: "p:review", ordinal: 1, status: "done" }, + { sessionId: "p:review-2", ordinal: 2, status: "blocked" }, + { sessionId: "p:search", ordinal: 3, status: "running" }, + ], + }); + ingestSessionList(FLEET); + const { getByText, getByRole } = render(() => ); + + expect(getByText("2/3")).toBeTruthy(); + const bar = getByRole("progressbar"); + expect(bar.getAttribute("aria-valuenow")).toBe("2"); + expect(bar.getAttribute("aria-valuemax")).toBe("3"); + }); + + it("counts SETTLED members, so a failed member does not stall the bar", () => { + // The barrier joins on all-terminal. Counting successes would leave a + // finished panel showing 2/3 forever — the exact confusion this removes. + livePanelMock.mockReturnValue({ + groupId: "g1", createdAt: 0, settled: 3, joined: false, + members: [ + { sessionId: "p:review", ordinal: 1, status: "done" }, + { sessionId: "p:review-2", ordinal: 2, status: "failed" }, + { sessionId: "p:search", ordinal: 3, status: "blocked" }, + ], + }); + ingestSessionList(FLEET); + const { getByText, getByTitle } = render(() => ); + expect(getByText("3/3")).toBeTruthy(); + expect(getByTitle(/including failures/)).toBeTruthy(); + }); + + it("badges each participating child with its position and state", () => { + livePanelMock.mockReturnValue({ + groupId: "g1", createdAt: 0, settled: 1, joined: false, + members: [{ sessionId: "x", ordinal: 1, status: "running" }], + }); + livePanelMemberMock.mockImplementation((id: string) => + id === "p:review" ? { ordinal: 1, status: "running" } + : id === "p:review-2" ? { ordinal: 2, status: "failed" } + : null, + ); + ingestSessionList(FLEET); + const { getByTitle, queryAllByTitle } = render(() => ); + + expect(getByTitle("Panel member 1 — working")).toBeTruthy(); + // A failed member is SHOWN, not hidden — same rule as the joined digest. + expect(getByTitle("Panel member 2 — failed")).toBeTruthy(); + // The non-participating child carries no badge. + expect(queryAllByTitle(/Panel member/)).toHaveLength(2); + }); +}); diff --git a/web/src/components/SessionListPane.tsx b/web/src/components/SessionListPane.tsx index ab1e290..6c8c4a4 100644 --- a/web/src/components/SessionListPane.tsx +++ b/web/src/components/SessionListPane.tsx @@ -5,7 +5,7 @@ * chat area dominates the viewport. */ -import { Component, createMemo, createSignal, For, Show } from "solid-js"; +import { Component, createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; import { formatCostUsd, formatTokens, relativeTime } from "../lib/format"; import { @@ -16,8 +16,10 @@ import { type FilteredFleetGroup, } from "../lib/fleet"; import { sessionAgentLabel, shortSub } from "../lib/identity"; +import { fetchPanels, livePanel, livePanelMember, resetPanels } from "../state/panels"; import { nowTick } from "../state/clock"; import { + focusedSession, focusedSessionId, focusSession, sessionList, @@ -50,6 +52,12 @@ function newSession(): void { openNewSessionModal(); } +/** + * Panel-state poll cadence. Matches the blackboard drawer's: a fan-out moves on + * the scale of a model turn, and this is a metadata read with no bodies. + */ +const PANEL_POLL_MS = 3_000; + /** * Fleets the user has folded shut, by orchestrator id. Collapsed is the * exception, so absence means expanded — a fleet that spawns while you're @@ -83,6 +91,26 @@ const SessionListPane: Component = () => { filterFleet(groupFleet(sessionList()), filter()), ); + // Poll panel state for the focused collaboration only. There is no push + // channel for dispatch state, and a fan-out changes over minutes — a static + // indicator would show a finished panel as still running. Scoped to the + // focused goal rather than every visible fleet so the cost stays one cheap + // metadata query per tick regardless of how many collaborations exist. + createEffect(() => { + const focused = focusedSession(); + const goalId = + focused?.collaboration !== undefined + ? focused.id + : focused?.collaborationRole?.parentSessionId; + if (!goalId) { + resetPanels(); + return; + } + void fetchPanels(goalId); + const t = setInterval(() => void fetchPanels(goalId), PANEL_POLL_MS); + onCleanup(() => clearInterval(t)); + }); + return ( {(r) => } + {/* Which children are in the LIVE fan-out, and where each one is. + Session-keyed, because a role can have several members. */} + + {(m) => } + {(u) => ( @@ -408,6 +441,16 @@ const SessionRow: Component<{ )} + {/* The fan-out, while it runs. This is the whole point of a panel and + was invisible until the daemon started reporting it. */} + + {(p) => ( + + )} +
@@ -492,6 +535,73 @@ const SessionRow: Component<{ ); }; +/** + * Live fan-out progress on the orchestrator row: "⇉ panel 2/3". + * + * Counts SETTLED members, not successful ones — the barrier joins on terminal, + * so a panel with a failed member is 3/3 and finished. Showing successes would + * leave the bar short of full on a panel that is already done, which is exactly + * the confusion the indicator exists to remove. + */ +const PanelProgress: Component<{ settled: number; total: number }> = (props) => { + const pct = () => (props.total === 0 ? 0 : (props.settled / props.total) * 100); + return ( +
+ ⇉ panel + + + + + {props.settled}/{props.total} + +
+ ); +}; + +/** A child's position and state within the live fan-out. */ +const PanelMemberBadge: Component<{ + ordinal: number; + status: "queued" | "claimed" | "running" | "done" | "failed" | "blocked"; +}> = (props) => { + const look = () => { + switch (props.status) { + case "running": + case "claimed": + return { cls: "border-warn/50 bg-warn/10 text-warn animate-pulse", note: "working" }; + case "done": + return { cls: "border-success/50 bg-success/10 text-success", note: "finished" }; + case "failed": + case "blocked": + // Shown, never hidden — §7's "disagreement is shown" applies to a + // member that failed just as much as to one that disagreed. + return { cls: "border-danger/50 bg-danger/10 text-danger", note: props.status }; + default: + return { cls: "border-border bg-bg text-fg-muted", note: "queued" }; + } + }; + return ( + + ⇉{props.ordinal} + + ); +}; + /** * Whether a role-child may write. Read-only is the interesting state — it's the * §6 independence property made visible (a scout's leaf identity carries no diff --git a/web/src/state/panels.test.ts b/web/src/state/panels.test.ts new file mode 100644 index 0000000..e4db7f0 --- /dev/null +++ b/web/src/state/panels.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; + +const requestMock = vi.hoisted(() => + vi.fn<(msg: unknown, opts?: unknown) => Promise>(), +); +vi.mock("./connection", () => ({ + getClient: () => ({ request: requestMock }), + newRequestId: () => `r-${Math.random()}`, +})); + +import { + fetchPanels, + lastJoinedPanel, + livePanel, + livePanelMember, + panelState, + _resetPanelsForTest, +} from "./panels"; +import type { CollaborationPanel } from "../protocol/types"; + +type Status = CollaborationPanel["members"][number]["status"]; + +function panel( + groupId: string, + statuses: Status[], + over: Partial = {}, +): CollaborationPanel { + const members = statuses.map((status, i) => ({ + sessionId: `kid-${i + 1}`, + ordinal: i + 1, + status, + })); + const settled = members.filter((m) => + ["done", "failed", "blocked"].includes(m.status), + ).length; + return { + groupId, + createdAt: 1_700_000_000_000, + members, + settled, + joined: settled === members.length, + ...over, + }; +} + +const result = (panels: CollaborationPanel[], sessionId = "goal-1") => ({ + type: "collaboration.panels.result" as const, + requestId: "x", + sessionId, + panels, +}); + +beforeEach(() => _resetPanelsForTest()); +afterEach(() => { + requestMock.mockReset(); + _resetPanelsForTest(); +}); + +describe("fetchPanels", () => { + it("adopts the daemon's goal id, not the one it asked about", async () => { + // Focusing a child must land on the same panels as focusing its orchestrator. + requestMock.mockResolvedValueOnce(result([panel("g1", ["running", "queued"])])); + await fetchPanels("kid-1"); + expect(requestMock.mock.calls[0]![0]).toMatchObject({ + type: "collaboration.panels", + sessionId: "kid-1", + }); + expect(panelState().goalSessionId).toBe("goal-1"); + }); + + it("keeps the last state on a transient failure instead of blanking", async () => { + // A poll that fails must not erase a live "2 of 3" and imply the panel + // vanished — it polls again in three seconds. + requestMock.mockResolvedValueOnce(result([panel("g1", ["done", "running"])])); + await fetchPanels("goal-1"); + expect(livePanel()?.settled).toBe(1); + + requestMock.mockRejectedValueOnce(new Error("socket blip")); + await fetchPanels("goal-1"); + expect(panelState().error).toBe("socket blip"); + expect(livePanel()?.settled).toBe(1); + }); + + it("drops a slow reply for a goal the user navigated away from", async () => { + let release: (v: unknown) => void = () => {}; + requestMock + .mockImplementationOnce(() => new Promise((res) => (release = res))) + .mockResolvedValueOnce(result([panel("g2", ["queued"])], "goal-2")); + + const first = fetchPanels("goal-1"); + const second = fetchPanels("goal-2"); + await second; + release(result([panel("g1", ["done", "done"])], "goal-1")); + await first; + + expect(panelState().goalSessionId).toBe("goal-2"); + expect(livePanel()?.groupId).toBe("g2"); + }); +}); + +describe("livePanel", () => { + it("is the unjoined fan-out, and null once everything has joined", async () => { + requestMock.mockResolvedValueOnce( + result([panel("live", ["running", "done"]), panel("old", ["done", "done"])]), + ); + await fetchPanels("goal-1"); + expect(livePanel()?.groupId).toBe("live"); + expect(lastJoinedPanel()?.groupId).toBe("old"); + + requestMock.mockResolvedValueOnce(result([panel("old", ["done", "done"])])); + await fetchPanels("goal-1"); + expect(livePanel()).toBeNull(); + }); + + it("keys liveness off `joined`, not off member statuses", async () => { + // The daemon derives `joined` from the barrier's own all-terminal rule. If + // this recomputed it from statuses the two could disagree, and the UI would + // show a panel spinning that the barrier has already closed. + requestMock.mockResolvedValueOnce( + result([panel("weird", ["done", "done"], { joined: false, settled: 2 })]), + ); + await fetchPanels("goal-1"); + expect(livePanel()?.groupId).toBe("weird"); + }); +}); + +describe("livePanelMember", () => { + beforeEach(async () => { + requestMock.mockResolvedValueOnce( + result([panel("g1", ["done", "running", "blocked"])]), + ); + await fetchPanels("goal-1"); + requestMock.mockReset(); + }); + + it("maps a session to its position and state in the live fan-out", () => { + expect(livePanelMember("kid-1")).toEqual({ ordinal: 1, status: "done" }); + expect(livePanelMember("kid-2")).toEqual({ ordinal: 2, status: "running" }); + // A failed member is still reported — never dropped. + expect(livePanelMember("kid-3")).toEqual({ ordinal: 3, status: "blocked" }); + }); + + it("is null for a session outside the fan-out", () => { + expect(livePanelMember("kid-99")).toBeNull(); + }); + + it("is null when nothing is live", async () => { + requestMock.mockResolvedValueOnce(result([panel("g1", ["done", "done"])])); + await fetchPanels("goal-1"); + expect(livePanelMember("kid-1")).toBeNull(); + }); +}); diff --git a/web/src/state/panels.ts b/web/src/state/panels.ts new file mode 100644 index 0000000..2119336 --- /dev/null +++ b/web/src/state/panels.ts @@ -0,0 +1,103 @@ +/** + * Live panel state for the focused collaboration — the fan-out, while it runs. + * + * The gap this fills: a panel's whole point is that N agents work AT ONCE, and + * the UI could show the fleet and the joined result but never the parallelism + * itself. Verified live before this existed — two frontier models reviewed the + * same file simultaneously on different backends and the UI rendered it as one + * ordinary transcript message. + * + * Daemon-canonical like every other slice: `goalSessionId` comes from the RESULT, + * never from the id we asked about, so focusing a child and focusing its + * orchestrator converge on the same board. + */ + +import { createMemo, createSignal } from "solid-js"; + +import { getClient, newRequestId } from "./connection"; +import type { + CollaborationPanel, + CollaborationPanelsResultMsg, +} from "../protocol/types"; + +interface State { + goalSessionId: string | null; + panels: CollaborationPanel[]; + error: string | null; + fetchedAt: number; +} + +const EMPTY: State = { goalSessionId: null, panels: [], error: null, fetchedAt: 0 }; + +const [state, setState] = createSignal(EMPTY); + +export const panelState = state; + +/** Drops a slow reply for a goal the user has already navigated away from. */ +let inflight: string | null = null; + +export async function fetchPanels(sessionId: string): Promise { + inflight = sessionId; + try { + const id = newRequestId(); + const result = await getClient().request( + { type: "collaboration.panels", id, sessionId }, + { + waitForResult: (m) => + m.type === "collaboration.panels.result" && m.requestId === id ? m : undefined, + timeoutMs: 8_000, + }, + ); + if (inflight !== sessionId) return; + setState({ + goalSessionId: result.sessionId, + panels: result.panels, + error: null, + fetchedAt: Date.now(), + }); + } catch (err) { + if (inflight !== sessionId) return; + // Keep whatever we last showed. A transient failure mid-poll should not + // blank a live "2 of 3" indicator and imply the panel vanished. + setState((s) => ({ ...s, error: err instanceof Error ? err.message : String(err) })); + } +} + +/** + * The fan-out currently in flight, if any. + * + * "Live" means not yet joined — deliberately keyed off `joined` rather than off + * member statuses, so this can never disagree with the barrier's own rule about + * when a panel is finished. + */ +export const livePanel = createMemo( + () => state().panels.find((p) => !p.joined) ?? null, +); + +/** The most recently joined fan-out — context once the live one is gone. */ +export const lastJoinedPanel = createMemo( + () => state().panels.find((p) => p.joined) ?? null, +); + +/** + * How a given session is participating in the live fan-out, or null. + * + * Session-keyed rather than role-keyed: a panel targets sessions, and the same + * role can have several members, so a role name cannot identify a participant. + */ +export function livePanelMember( + sessionId: string, +): { ordinal: number; status: CollaborationPanel["members"][number]["status"] } | null { + const p = livePanel(); + if (!p) return null; + const m = p.members.find((x) => x.sessionId === sessionId); + return m ? { ordinal: m.ordinal, status: m.status } : null; +} + +export function resetPanels(): void { + inflight = null; + setState(EMPTY); +} + +/** Test hook. */ +export const _resetPanelsForTest = resetPanels;