From 605fd473f2789447898867b9db3fecc31099c16e Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Thu, 30 Jul 2026 00:55:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20show=20the=20fan-out=20while=20it=20run?= =?UTF-8?q?s=20=E2=80=94=20collaboration.panels=20+=20panel=20state=20in?= =?UTF-8?q?=20the=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last demo gap. A panel's whole point is that N agents work AT ONCE, and that was the one part of the feature a client could not see: the sidebar showed the fleet, the transcript showed the joined result, and the parallelism in between was invisible. Confirmed the hard way — a live run where opus 4.8 and gpt-5.6-sol reviewed the same file simultaneously rendered as a single ordinary transcript message. It was invisible because nothing on the wire carried it. No client verb could see dispatch state at all, so this is a protocol gap rather than a styling one. ## collaboration.panels Returns the goal's recent fan-outs, newest first, each with its members in dispatch order and their live queue status. `sessionId` accepts the orchestrator or any child, resolving the hop like `blackboard.index` does. Gated on `session:list` — the same tier as the blackboard index, and for the same reason: metadata about sessions the holder can already enumerate, carrying no prompts and no artifact bodies. Scoped by `createdBy` so one collaboration's panels never surface in another's UI. History is bounded to 5 groups in SQL; a sidebar wants the live fan-out plus a little context, not an orchestrator's whole dispatch career. `joined` is derived from the members in the same payload rather than stored, so the flag and the member list cannot disagree with each other. ## What the UI now shows - **On the orchestrator row:** `⇉ panel 2/3` with a progress bar while a fan-out is in flight. - **On each participating child:** a `⇉N` badge carrying its position and state — working, finished, or failed. Both count SETTLED members, not successful ones, because the barrier joins on all-terminal. Counting successes would leave a finished panel showing 2/3 forever, which is precisely the confusion the indicator exists to remove. A failed member is shown in red rather than omitted — §7's "disagreement is shown, not hidden" applies to a member that failed just as much as to one that disagreed. Polled every 3s for the FOCUSED collaboration only, so the cost stays one cheap metadata query per tick no matter how many fleets exist. A failed poll keeps the last state rather than blanking a live "2 of 3" and implying the panel vanished. ## Verification Daemon 2191 to 2200, web 370 to 382. Four mutations each caught by exactly the right tests: `joined` from a stored flag, `settled` counting only successes, panels unscoped from the goal, and the web recomputing liveness from statuses instead of trusting `joined`. Live over the real socket: no panels before a fan-out; the panel visible while all three members are still `queued`; ordinals in dispatch order; member sessions matching the children; `settled=2` with one member BLOCKED; `joined` flipping only when the third settled; and a child id resolving to its goal. --- packages/protocol/src/schemas.test.ts | 1 + packages/protocol/src/schemas.ts | 7 + packages/protocol/src/types.ts | 60 +++++++ src/daemon/session-manager.ts | 93 +++++++++++ src/daemon/store.ts | 41 +++++ src/tests/collaboration.test.ts | 168 ++++++++++++++++++++ src/tests/protocol.test.ts | 2 + web/src/components/SessionListPane.test.tsx | 88 ++++++++++ web/src/components/SessionListPane.tsx | 112 ++++++++++++- web/src/state/panels.test.ts | 153 ++++++++++++++++++ web/src/state/panels.ts | 103 ++++++++++++ 11 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 web/src/state/panels.test.ts create mode 100644 web/src/state/panels.ts 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;