+ >
+ )}
+
+
+ );
+};
+
+/**
+ * An artifact the index listed but that reads back empty. Normal mid-flight —
+ * the daemon returns `null` rather than an error precisely so this renders as
+ * pending instead of as a failure.
+ */
+const Unwritten: Component<{ loading: boolean }> = (props) => (
+
+
+ Not written yet — the role responsible for it hasn't published a version.
+
+
@@ -108,6 +111,34 @@ const SessionControls: Component = () => {
);
};
+/**
+ * Opens the goal blackboard for a session that is part of a collaboration —
+ * the orchestrator or any role-child, since the daemon resolves the hop.
+ *
+ * Capability-gated rather than always rendered: against a daemon that predates
+ * `blackboard.index` the affordance simply doesn't appear, instead of
+ * appearing and erroring on click.
+ */
+const BlackboardButton: Component<{ session: SessionInfo }> = (props) => {
+ const partOfCollab = () =>
+ Boolean(props.session.collaboration ?? props.session.collaborationRole);
+ const supported = () =>
+ authIdentity()?.capabilities?.includes(CAPABILITIES.BLACKBOARD) ?? false;
+ return (
+
+
+
+ );
+};
+
/** Lineage chip for a forked session — "⑃ from · turn N". Clicking
* focuses the parent when it's still in the list; otherwise it's a static
* label (the parent may have been destroyed since). */
diff --git a/web/src/components/SessionListPane.test.tsx b/web/src/components/SessionListPane.test.tsx
index 6324252..5b8e7e0 100644
--- a/web/src/components/SessionListPane.test.tsx
+++ b/web/src/components/SessionListPane.test.tsx
@@ -30,6 +30,31 @@ function sess(id: string, name: string, workdir = "/tmp"): SessionInfo {
} as SessionInfo;
}
+/** An orchestrator: a session carrying a `collaboration` config. */
+function orchestrator(id: string, name: string, goal: string): SessionInfo {
+ return {
+ ...sess(id, name, "/repo/fleet"),
+ collaboration: { goal, roles: [{ name: "orchestrator", providerId: "claude" }] },
+ } as SessionInfo;
+}
+
+/** A role-child: a session carrying `collaborationRole` pointing at its parent. */
+function roleChild(
+ parentId: string,
+ parentName: string,
+ roleName: string,
+ ordinal: number,
+ write: boolean,
+ providerId = "claude",
+): SessionInfo {
+ const suffix = ordinal > 1 ? `-${ordinal}` : "";
+ return {
+ ...sess(`${parentId}:${roleName}${suffix}`, `${parentName}:${roleName}${suffix}`, "/repo/fleet"),
+ providerId,
+ collaborationRole: { parentSessionId: parentId, roleName, ordinal, write },
+ } as SessionInfo;
+}
+
afterEach(() => {
cleanup();
_resetSessionsForTest();
@@ -82,3 +107,91 @@ describe("SessionListPane — session filter", () => {
expect(getByText("beta")).toBeTruthy();
});
});
+
+describe("SessionListPane — fleet rendering", () => {
+ const FLEET = [
+ orchestrator("p", "refactor-auth", "make auth boring again"),
+ roleChild("p", "refactor-auth", "review", 2, false, "gemini"),
+ roleChild("p", "refactor-auth", "search", 1, false, "claude"),
+ roleChild("p", "refactor-auth", "review", 1, false, "gemini"),
+ roleChild("p", "refactor-auth", "reasoning", 1, true, "openai"),
+ sess("solo", "unrelated", "/tmp/other"),
+ ];
+
+ it("renders children by role label, not by their prefixed session name", () => {
+ ingestSessionList(FLEET);
+ const { getByText, queryByText } = render(() => );
+
+ expect(getByText("refactor-auth")).toBeTruthy();
+ // `review` ×2 fan-out gets ordinals; singletons don't.
+ expect(getByText("review")).toBeTruthy();
+ expect(getByText("review#2")).toBeTruthy();
+ expect(getByText("search")).toBeTruthy();
+ expect(getByText("reasoning")).toBeTruthy();
+ // The daemon-generated name is in the tooltip, not the visible label.
+ expect(queryByText("refactor-auth:review-2")).toBeNull();
+ });
+
+ it("shows the goal on the orchestrator row", () => {
+ ingestSessionList(FLEET);
+ const { getByText } = render(() => );
+ expect(getByText(/make auth boring again/)).toBeTruthy();
+ });
+
+ it("badges read-only roles and marks the writer differently", () => {
+ ingestSessionList(FLEET);
+ const { getAllByTitle } = render(() => );
+ // search, review, review#2 are read-only; reasoning writes.
+ expect(getAllByTitle(/Read-only role/)).toHaveLength(3);
+ expect(getAllByTitle(/may write to the workspace/)).toHaveLength(1);
+ });
+
+ it("shows every child's backend, including claude", () => {
+ // A mixed fleet is the point; "claude" must be stated, not implied by the
+ // absence of a chip the way it is for standalone sessions.
+ ingestSessionList(FLEET);
+ const { getAllByTitle, queryAllByTitle } = render(() => );
+ expect(getAllByTitle(/Backend: gemini/)).toHaveLength(2);
+ expect(getAllByTitle(/Backend: openai/)).toHaveLength(1);
+ expect(getAllByTitle(/Backend: claude/)).toHaveLength(1);
+ // ...but the standalone session still suppresses the default-backend chip.
+ expect(queryAllByTitle(/Backend: claude/)).toHaveLength(1);
+ });
+
+ it("collapses and re-expands the fleet from the group toggle", () => {
+ ingestSessionList(FLEET);
+ const { getByLabelText, queryByText, getByText } = render(() => );
+
+ fireEvent.click(getByLabelText(/Collapse fleet \(4 roles\)/));
+ expect(queryByText("search")).toBeNull();
+ expect(queryByText("review#2")).toBeNull();
+ // The orchestrator itself stays put.
+ expect(getByText("refactor-auth")).toBeTruthy();
+
+ fireEvent.click(getByLabelText(/Expand fleet \(4 roles\)/));
+ expect(getByText("search")).toBeTruthy();
+ });
+
+ it("keeps the orchestrator visible as context when only a child matches", () => {
+ ingestSessionList(FLEET);
+ const { getByLabelText, getByText, queryByText } = render(() => );
+ fireEvent.input(getByLabelText("Filter sessions by name"), {
+ target: { value: "reasoning" },
+ });
+ // A bare role row with no indication of its goal would be unreadable.
+ expect(getByText("refactor-auth")).toBeTruthy();
+ expect(getByText("reasoning")).toBeTruthy();
+ expect(queryByText("search")).toBeNull();
+ expect(queryByText("unrelated")).toBeNull();
+ });
+
+ it("keeps an orphan child visible, at top level, with its role badges intact", () => {
+ // Parent destroyed while the child drains — the child must not vanish. It
+ // shows its full name (no parent row above it to supply the context) but
+ // still reads as a role-child.
+ ingestSessionList([roleChild("ghost", "gone", "search", 1, false)]);
+ const { getByText, getByTitle } = render(() => );
+ expect(getByText("gone:search")).toBeTruthy();
+ expect(getByTitle(/Read-only role/)).toBeTruthy();
+ });
+});
diff --git a/web/src/components/SessionListPane.tsx b/web/src/components/SessionListPane.tsx
index a93bdb3..ab1e290 100644
--- a/web/src/components/SessionListPane.tsx
+++ b/web/src/components/SessionListPane.tsx
@@ -5,9 +5,16 @@
* chat area dominates the viewport.
*/
-import { Component, createSignal, For, Show } from "solid-js";
+import { Component, createMemo, createSignal, For, Show } from "solid-js";
import { formatCostUsd, formatTokens, relativeTime } from "../lib/format";
+import {
+ countVisible,
+ filterFleet,
+ groupFleet,
+ roleLabel,
+ type FilteredFleetGroup,
+} from "../lib/fleet";
import { sessionAgentLabel, shortSub } from "../lib/identity";
import { nowTick } from "../state/clock";
import {
@@ -43,19 +50,38 @@ function newSession(): void {
openNewSessionModal();
}
+/**
+ * Fleets the user has folded shut, by orchestrator id. Collapsed is the
+ * exception, so absence means expanded — a fleet that spawns while you're
+ * looking at it opens rather than hiding its own arrival.
+ *
+ * Module-level so the choice survives the pane unmounting (mobile drawer,
+ * sidebar collapse) — a fold that reopens itself every time you close the
+ * drawer isn't a fold.
+ */
+const [collapsedFleets, setCollapsedFleets] = createSignal>(
+ new Set(),
+);
+
+function toggleFleet(parentId: string): void {
+ setCollapsedFleets((prev) => {
+ const next = new Set(prev);
+ if (!next.delete(parentId)) next.add(parentId);
+ return next;
+ });
+}
+
const SessionListPane: Component = () => {
const [showAnalytics, setShowAnalytics] = createSignal(false);
- // Instant client-side filter by session name/workdir. Complements the
+ // Instant client-side filter by session name/workdir/role. Complements the
// semantic cross-session content search (Ctrl+K) — this is the fast
// "find the session called X" pass, and it works without the memory engine.
const [filter, setFilter] = createSignal("");
- const filtered = (): SessionInfo[] => {
- const q = filter().trim().toLowerCase();
- if (!q) return sessionList();
- return sessionList().filter(
- (s) => s.name.toLowerCase().includes(q) || s.workdir.toLowerCase().includes(q),
- );
- };
+ // Group BEFORE filtering: a filter that matches only a child still needs its
+ // orchestrator around to say which goal that child belongs to.
+ const groups = createMemo(() =>
+ filterFleet(groupFleet(sessionList()), filter()),
+ );
return (
{
);
+const RailButton: Component<{
+ session: SessionInfo;
+ label: string;
+ title: string;
+}> = (props) => (
+
+);
+
const SectionHeader: Component<{
title: string;
count: number;
@@ -228,24 +285,100 @@ const NoMatch: Component<{ query: string }> = (props) => (
);
-const SessionRow: Component<{ session: SessionInfo }> = (props) => {
+/**
+ * One top-level session and, when it orchestrates a collaboration, its
+ * role-children indented beneath it.
+ *
+ * The children are real sessions you can focus and read — they are long-lived,
+ * not transient dispatch workers — so they stay clickable rows rather than a
+ * summary line. What changes is that they're visibly *of* the orchestrator.
+ */
+const FleetGroupRows: Component<{ group: FilteredFleetGroup }> = (props) => {
+ const collapsed = () => collapsedFleets().has(props.group.lead.id);
+ const hasChildren = () => props.group.children.length > 0;
+
+ return (
+ <>
+ toggleFleet(props.group.lead.id),
+ }
+ : undefined
+ }
+ />
+
+
+ {/* The rail is the grouping: one continuous line down the left of
+ the fleet, so a child is unmistakably subordinate even when the
+ orchestrator has scrolled out of view. */}
+
+
+ {(c) => }
+
+
+
+
+ >
+ );
+};
+
+interface FleetLeadProps {
+ childCount: number;
+ collapsed: boolean;
+ onToggle: () => void;
+}
+
+const SessionRow: Component<{
+ session: SessionInfo;
+ /**
+ * Rendered indented under its orchestrator. Distinct from "is a role-child":
+ * an orphan child (parent destroyed or not yet delivered) is a role-child
+ * rendered at top level, and must keep its role badges while showing its
+ * full name — there's no parent row above it to supply the context.
+ */
+ nested?: boolean;
+ /** Present when this session orchestrates a collaboration. */
+ fleet?: FleetLeadProps;
+ /** Shown only to give matching children a parent — not a filter hit itself. */
+ dimmed?: boolean;
+}> = (props) => {
const isActive = () => focusedSessionId() === props.session.id;
+ const role = () => props.session.collaborationRole;
+ // A child's daemon-generated name is `:[-N]`; under the parent
+ // that prefix is pure repetition, so a nested row leads with the role and
+ // keeps the full name in the tooltip.
+ const title = () =>
+ (props.nested && roleLabel(props.session)) || props.session.name;
return (
-
+
+ {/* Sibling of the row, not nested inside it — a button inside a button is
+ invalid HTML and browsers resolve the click ambiguity differently. */}
+
+ {(f) => (
+
+ )}
+
);
};
+/**
+ * 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
+ * `tools:write` at all), so it gets the badge and write gets a quiet marker
+ * rather than both shouting equally.
+ */
+const WriteBadge: Component<{ write: boolean }> = (props) => (
+
+ ro
+
+ }
+ >
+
+ ✎
+
+
+);
+
const StatusDot: Component<{ status: SessionStatus }> = (props) => {
const cls = () => {
switch (props.status) {
diff --git a/web/src/components/Shell.tsx b/web/src/components/Shell.tsx
index 02c902f..87109db 100644
--- a/web/src/components/Shell.tsx
+++ b/web/src/components/Shell.tsx
@@ -17,6 +17,7 @@
import { Component, Show, createMemo } from "solid-js";
+import BlackboardDrawer from "./BlackboardDrawer";
import CapabilitiesDrawer from "./CapabilitiesDrawer";
import CenterPane from "./CenterPane";
import FileViewer from "./files/FileViewer";
@@ -60,6 +61,7 @@ const Shell: Component = () => {
+
diff --git a/web/src/lib/fleet.test.ts b/web/src/lib/fleet.test.ts
new file mode 100644
index 0000000..1a15696
--- /dev/null
+++ b/web/src/lib/fleet.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect } from "vitest";
+
+import { countVisible, filterFleet, groupFleet, roleLabel } from "./fleet";
+import type { CollaborationConfig, SessionInfo } from "../protocol/types";
+
+function session(
+ id: string,
+ over: Partial = {},
+): SessionInfo {
+ return {
+ id,
+ name: id,
+ workdir: `/repo/${id}`,
+ status: "idle",
+ createdAt: "2026-07-27T00:00:00.000Z",
+ ...over,
+ } as SessionInfo;
+}
+
+const GOAL: CollaborationConfig = {
+ goal: "ship the thing",
+ roles: [{ name: "orchestrator", providerId: "claude" }],
+};
+
+function child(
+ id: string,
+ parentSessionId: string,
+ roleName: string,
+ ordinal = 1,
+ write = false,
+): SessionInfo {
+ return session(id, {
+ collaborationRole: { parentSessionId, roleName, ordinal, write },
+ });
+}
+
+describe("groupFleet", () => {
+ it("nests role-children under their orchestrator and keeps lead order", () => {
+ const parent = session("p", { collaboration: GOAL });
+ const groups = groupFleet([
+ session("standalone-b"),
+ parent,
+ child("c2", "p", "review", 2),
+ child("c1", "p", "review", 1),
+ session("standalone-a"),
+ ]);
+
+ expect(groups.map((g) => g.lead.id)).toEqual([
+ "standalone-b",
+ "p",
+ "standalone-a",
+ ]);
+ const fleet = groups[1]!;
+ expect(fleet.isFleet).toBe(true);
+ expect(fleet.children.map((c) => c.id)).toEqual(["c1", "c2"]);
+ });
+
+ it("orders children by role name then ordinal, not by list position", () => {
+ const parent = session("p", { collaboration: GOAL });
+ const groups = groupFleet([
+ parent,
+ child("r2", "p", "review", 2),
+ child("s1", "p", "search", 1),
+ child("r1", "p", "review", 1),
+ child("a1", "p", "architecture", 1),
+ ]);
+ expect(groups[0]!.children.map((c) => c.id)).toEqual(["a1", "r1", "r2", "s1"]);
+ });
+
+ it("marks a collaboration with no children yet as a fleet", () => {
+ // Between session.create and #spawnCollaborationChildren the parent exists
+ // alone; it must not render as a plain session and then become a fleet.
+ const groups = groupFleet([session("p", { collaboration: GOAL })]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]!.isFleet).toBe(true);
+ expect(groups[0]!.children).toEqual([]);
+ });
+
+ it("promotes an orphan child to a top-level group instead of dropping it", () => {
+ // Parent destroyed while the child drains, or not yet delivered to this
+ // client. Either way the child must stay visible.
+ const groups = groupFleet([child("c1", "gone", "search", 1)]);
+ expect(groups.map((g) => g.lead.id)).toEqual(["c1"]);
+ expect(groups[0]!.isFleet).toBe(false);
+ });
+
+ it("does not nest a child under a parent that is merely name-similar", () => {
+ const groups = groupFleet([
+ session("p", { collaboration: GOAL }),
+ child("c1", "p-other", "search", 1),
+ ]);
+ expect(groups.map((g) => g.lead.id)).toEqual(["p", "c1"]);
+ expect(groups[0]!.children).toEqual([]);
+ });
+
+ it("returns an empty list for no sessions", () => {
+ expect(groupFleet([])).toEqual([]);
+ });
+});
+
+describe("filterFleet", () => {
+ const parent = session("p", { name: "refactor-auth", collaboration: GOAL });
+ const groups = groupFleet([
+ parent,
+ child("c1", "p", "search", 1),
+ child("c2", "p", "review", 1),
+ session("unrelated", { name: "scratch", workdir: "/tmp/scratch" }),
+ ]);
+
+ it("passes everything through for an empty query", () => {
+ const out = filterFleet(groups, " ");
+ expect(out).toHaveLength(2);
+ expect(out.every((g) => g.leadMatched)).toBe(true);
+ expect(out[0]!.children).toHaveLength(2);
+ });
+
+ it("keeps the whole fleet when the lead matches", () => {
+ const out = filterFleet(groups, "refactor");
+ expect(out).toHaveLength(1);
+ expect(out[0]!.leadMatched).toBe(true);
+ expect(out[0]!.children.map((c) => c.id)).toEqual(["c2", "c1"]);
+ });
+
+ it("keeps the lead as unmatched context when only a child matches", () => {
+ const out = filterFleet(groups, "search");
+ expect(out).toHaveLength(1);
+ expect(out[0]!.lead.id).toBe("p");
+ expect(out[0]!.leadMatched).toBe(false);
+ expect(out[0]!.children.map((c) => c.id)).toEqual(["c1"]);
+ });
+
+ it("matches a child on its role name, which is not part of its session name", () => {
+ // The child's own `name` is daemon-generated; the role is what the user
+ // actually thinks in.
+ const out = filterFleet(groups, "review");
+ expect(out).toHaveLength(1);
+ expect(out[0]!.children.map((c) => c.id)).toEqual(["c2"]);
+ });
+
+ it("matches on workdir", () => {
+ const out = filterFleet(groups, "/tmp/scratch");
+ expect(out.map((g) => g.lead.id)).toEqual(["unrelated"]);
+ });
+
+ it("drops groups where nothing matches", () => {
+ expect(filterFleet(groups, "nothing-here")).toEqual([]);
+ });
+});
+
+describe("countVisible", () => {
+ it("counts leads plus their children", () => {
+ const groups = groupFleet([
+ session("p", { collaboration: GOAL }),
+ child("c1", "p", "search", 1),
+ child("c2", "p", "review", 1),
+ session("solo"),
+ ]);
+ expect(countVisible(groups)).toBe(4);
+ expect(countVisible([])).toBe(0);
+ });
+});
+
+describe("roleLabel", () => {
+ it("omits the ordinal for a singleton role and shows it for a fan-out", () => {
+ expect(roleLabel(child("c", "p", "search", 1))).toBe("search");
+ expect(roleLabel(child("c", "p", "review", 2))).toBe("review#2");
+ });
+
+ it("returns null for a session that is not a role-child", () => {
+ expect(roleLabel(session("solo"))).toBeNull();
+ });
+});
diff --git a/web/src/lib/fleet.ts b/web/src/lib/fleet.ts
new file mode 100644
index 0000000..5440093
--- /dev/null
+++ b/web/src/lib/fleet.ts
@@ -0,0 +1,149 @@
+/**
+ * Fleet grouping — turning a flat session list into orchestrator + role-children.
+ *
+ * A collaborative session (docs/collaborative-session-design.md) is really N+1
+ * sessions: the orchestrator the user created, and one long-lived role-child per
+ * role binding. The daemon already tells us how they relate — the parent carries
+ * `collaboration`, each child carries `collaborationRole` — but a flat list
+ * renders them as N+1 unrelated sessions, which is exactly the wrong mental
+ * model for something whose whole point is that it's ONE unit of work.
+ *
+ * Pure functions, no Solid: grouping is the part worth testing, and the tests
+ * shouldn't need a reactive root to run.
+ */
+
+import type { SessionInfo } from "../protocol/types";
+
+/**
+ * One top-level row in the session list, plus whatever hangs under it.
+ *
+ * A standalone session is a group of one — the list is uniformly groups, so the
+ * renderer never branches on "is this a fleet" to decide its outer shape.
+ */
+export interface FleetGroup {
+ /** The orchestrator, or the standalone session. */
+ lead: SessionInfo;
+ /** Role-children of `lead`, role-then-ordinal ordered. Empty unless a fleet. */
+ children: SessionInfo[];
+ /**
+ * True when `lead` orchestrates a collaboration. Distinct from
+ * `children.length > 0`: between create and spawn a fleet legitimately has
+ * zero children, and it should still render as a fleet rather than blinking
+ * from plain session to fleet a second later.
+ */
+ isFleet: boolean;
+}
+
+/**
+ * Group sessions into fleets, preserving the caller's ordering for leads.
+ *
+ * Orphan children — `parentSessionId` names a session that isn't in the list —
+ * are promoted to their own top-level group rather than dropped. The parent can
+ * be legitimately absent (destroyed while children drain, or not yet delivered
+ * to this client), and a session that silently disappears from the sidebar is a
+ * far worse failure than one rendered without its group.
+ */
+export function groupFleet(sessions: readonly SessionInfo[]): FleetGroup[] {
+ const present = new Set(sessions.map((s) => s.id));
+ const childrenByParent = new Map();
+
+ for (const s of sessions) {
+ const parentId = s.collaborationRole?.parentSessionId;
+ // A child whose parent is missing is treated as a lead below, so only
+ // bucket the ones that actually have somewhere to go.
+ if (!parentId || !present.has(parentId)) continue;
+ const bucket = childrenByParent.get(parentId);
+ if (bucket) bucket.push(s);
+ else childrenByParent.set(parentId, [s]);
+ }
+
+ const groups: FleetGroup[] = [];
+ for (const s of sessions) {
+ const parentId = s.collaborationRole?.parentSessionId;
+ if (parentId && present.has(parentId)) continue; // rendered under its parent
+ const children = childrenByParent.get(s.id) ?? [];
+ children.sort(compareChildren);
+ groups.push({ lead: s, children, isFleet: Boolean(s.collaboration) });
+ }
+ return groups;
+}
+
+/**
+ * Children order: role name, then fan-out ordinal. Deliberately NOT createdAt —
+ * `review` ×3 spawn within milliseconds of each other, so creation order is
+ * effectively arbitrary and the list would reshuffle between renders.
+ */
+function compareChildren(a: SessionInfo, b: SessionInfo): number {
+ const ra = a.collaborationRole;
+ const rb = b.collaborationRole;
+ if (!ra || !rb) return 0;
+ if (ra.roleName !== rb.roleName) return ra.roleName < rb.roleName ? -1 : 1;
+ return ra.ordinal - rb.ordinal;
+}
+
+/**
+ * Apply the sidebar's name/workdir filter across a grouped list.
+ *
+ * Matching is per-session, but visibility is per-group, and the two directions
+ * differ on purpose:
+ *
+ * - lead matches → keep the whole group. You searched for the fleet; you want
+ * the fleet, not a fleet with its members hidden.
+ * - child matches → keep the lead as context, with only the matching children.
+ * Showing a bare `search#1` row with no indication of which
+ * goal it belongs to is worse than not filtering at all.
+ *
+ * A lead kept only as context is reported via `leadMatched: false` so the
+ * renderer can de-emphasise it instead of implying it matched the query.
+ */
+export interface FilteredFleetGroup extends FleetGroup {
+ /** False when the lead is present only to give matching children a parent. */
+ leadMatched: boolean;
+}
+
+export function filterFleet(
+ groups: readonly FleetGroup[],
+ query: string,
+): FilteredFleetGroup[] {
+ const q = query.trim().toLowerCase();
+ if (!q) return groups.map((g) => ({ ...g, leadMatched: true }));
+
+ const out: FilteredFleetGroup[] = [];
+ for (const g of groups) {
+ const leadMatched = matchesSession(g.lead, q);
+ if (leadMatched) {
+ out.push({ ...g, leadMatched: true });
+ continue;
+ }
+ const children = g.children.filter((c) => matchesSession(c, q));
+ if (children.length > 0) out.push({ ...g, children, leadMatched: false });
+ }
+ return out;
+}
+
+/** Name, workdir, or — for a child — its role name. */
+function matchesSession(s: SessionInfo, lowercaseQuery: string): boolean {
+ if (s.name.toLowerCase().includes(lowercaseQuery)) return true;
+ if (s.workdir.toLowerCase().includes(lowercaseQuery)) return true;
+ const role = s.collaborationRole?.roleName;
+ return role !== undefined && role.includes(lowercaseQuery);
+}
+
+/** How many sessions a filtered group puts on screen — for the header count. */
+export function countVisible(groups: readonly FleetGroup[]): number {
+ let n = 0;
+ for (const g of groups) n += 1 + g.children.length;
+ return n;
+}
+
+/**
+ * Display label for a role-child: `search` for a singleton, `review#2` for a
+ * member of a fan-out. Mirrors the blackboard's own slot naming
+ * (`RoleBlackboard#ownSlot`), so a `findings` slot in the artifact panel reads
+ * the same as the session that wrote it.
+ */
+export function roleLabel(s: SessionInfo): string | null {
+ const r = s.collaborationRole;
+ if (!r) return null;
+ return r.ordinal > 1 ? `${r.roleName}#${r.ordinal}` : r.roleName;
+}
diff --git a/web/src/state/blackboard.test.ts b/web/src/state/blackboard.test.ts
new file mode 100644
index 0000000..222630b
--- /dev/null
+++ b/web/src/state/blackboard.test.ts
@@ -0,0 +1,241 @@
+// @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 {
+ blackboard,
+ clearSelection,
+ closeBlackboard,
+ fetchIndex,
+ isBlackboardOpen,
+ openBlackboard,
+ refKey,
+ refreshBlackboard,
+ selectArtifact,
+ _resetBlackboardForTest,
+} from "./blackboard";
+import type { BlackboardIndexEntry } from "../protocol/types";
+
+function entry(kind: string, slot: string | null = null): BlackboardIndexEntry {
+ return {
+ kind,
+ slot,
+ version: 1,
+ authorSub: `agent:goal:${kind}`,
+ authorRole: kind === "findings" ? "review" : "search",
+ updatedAt: 1_700_000_000_000,
+ bytes: 42,
+ };
+}
+
+const indexResult = (sessionId: string, entries: BlackboardIndexEntry[], goal = "ship it") => ({
+ type: "blackboard.index.result" as const,
+ requestId: "x",
+ sessionId,
+ goal,
+ entries,
+});
+
+const readResult = (content: string | null, kind = "spec") => ({
+ type: "blackboard.read.result" as const,
+ requestId: "x",
+ sessionId: "goal-1",
+ artifact:
+ content === null
+ ? null
+ : {
+ kind,
+ slot: null,
+ version: 2,
+ content,
+ authorSub: "agent:goal:orch",
+ authorRole: "orchestrator",
+ createdAt: 1_700_000_000_000,
+ },
+});
+
+beforeEach(() => _resetBlackboardForTest());
+afterEach(() => {
+ requestMock.mockReset();
+ _resetBlackboardForTest();
+});
+
+describe("fetchIndex", () => {
+ it("adopts the daemon's goal session id, not the one it was asked about", async () => {
+ // Focusing a role-child must land on the same board as focusing its
+ // orchestrator; the daemon does the hop and the client must not re-derive it.
+ requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec")]));
+ await fetchIndex("child-7");
+
+ expect(requestMock.mock.calls[0]![0]).toMatchObject({
+ type: "blackboard.index",
+ sessionId: "child-7",
+ });
+ expect(blackboard().goalSessionId).toBe("goal-1");
+ expect(blackboard().goal).toBe("ship it");
+ expect(blackboard().entries).toHaveLength(1);
+ expect(blackboard().error).toBeNull();
+ });
+
+ it("surfaces a daemon rejection instead of showing an empty board", async () => {
+ requestMock.mockRejectedValueOnce(new Error("Session not found"));
+ await fetchIndex("nope");
+ expect(blackboard().error).toBe("Session not found");
+ expect(blackboard().loading).toBe(false);
+ });
+
+ it("drops a slow reply for a board the user already navigated away from", async () => {
+ let releaseFirst: (v: unknown) => void = () => {};
+ requestMock
+ .mockImplementationOnce(() => new Promise((res) => (releaseFirst = res)))
+ .mockResolvedValueOnce(indexResult("goal-2", [entry("diff")], "second goal"));
+
+ const first = fetchIndex("goal-1");
+ const second = fetchIndex("goal-2");
+ await second;
+ releaseFirst(indexResult("goal-1", [entry("spec")], "first goal"));
+ await first;
+
+ expect(blackboard().goal).toBe("second goal");
+ expect(blackboard().entries.map((e) => e.kind)).toEqual(["diff"]);
+ });
+
+ it("keeps the previous board on screen while re-fetching the same one", async () => {
+ requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec")]));
+ await fetchIndex("goal-1");
+
+ let release: (v: unknown) => void = () => {};
+ requestMock.mockImplementationOnce(() => new Promise((res) => (release = res)));
+ const pending = refreshBlackboard();
+ // Mid-refresh the panel must not blank out — a fleet polling every few
+ // seconds would flicker on every tick.
+ expect(blackboard().loading).toBe(true);
+ expect(blackboard().entries).toHaveLength(1);
+ release(indexResult("goal-1", [entry("spec"), entry("diff")]));
+ await pending;
+ expect(blackboard().entries).toHaveLength(2);
+ });
+
+ it("clears a selection whose artifact left the board", async () => {
+ requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec")]));
+ await fetchIndex("goal-1");
+ requestMock.mockResolvedValueOnce(readResult("SPEC"));
+ await selectArtifact({ kind: "spec", slot: null });
+ expect(blackboard().artifact?.content).toBe("SPEC");
+
+ // Goal torn down and rebuilt: `spec` is gone. Leaving the body pane showing
+ // it would display an artifact nothing in the list points at.
+ requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("diff")]));
+ await refreshBlackboard();
+ expect(blackboard().selected).toBeNull();
+ expect(blackboard().artifact).toBeNull();
+ });
+
+ it("keeps a selection that is still on the board", async () => {
+ requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec")]));
+ await fetchIndex("goal-1");
+ requestMock.mockResolvedValueOnce(readResult("SPEC"));
+ await selectArtifact({ kind: "spec", slot: null });
+
+ requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec"), entry("diff")]));
+ await refreshBlackboard();
+ expect(blackboard().selected).toEqual({ kind: "spec", slot: null });
+ expect(blackboard().artifact?.content).toBe("SPEC");
+ });
+
+ it("refreshBlackboard is a no-op with no board open", async () => {
+ await refreshBlackboard();
+ expect(requestMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("selectArtifact", () => {
+ beforeEach(async () => {
+ requestMock.mockResolvedValueOnce(
+ indexResult("goal-1", [entry("findings", "review"), entry("findings", "review#2")]),
+ );
+ await fetchIndex("goal-1");
+ requestMock.mockReset();
+ });
+
+ it("requests the exact slot, so one reviewer is never served for another", async () => {
+ requestMock.mockResolvedValueOnce(readResult("SECOND", "findings"));
+ await selectArtifact({ kind: "findings", slot: "review#2" });
+ expect(requestMock.mock.calls[0]![0]).toMatchObject({
+ type: "blackboard.read",
+ sessionId: "goal-1",
+ kind: "findings",
+ slot: "review#2",
+ });
+ expect(blackboard().artifact?.content).toBe("SECOND");
+ });
+
+ it("treats a null artifact as pending, not as an error", async () => {
+ requestMock.mockResolvedValueOnce(readResult(null));
+ await selectArtifact({ kind: "findings", slot: "review" });
+ expect(blackboard().artifact).toBeNull();
+ expect(blackboard().artifactError).toBeNull();
+ expect(blackboard().artifactLoading).toBe(false);
+ });
+
+ it("drops a slow body for an artifact that is no longer selected", async () => {
+ let releaseFirst: (v: unknown) => void = () => {};
+ requestMock
+ .mockImplementationOnce(() => new Promise((res) => (releaseFirst = res)))
+ .mockResolvedValueOnce(readResult("SECOND", "findings"));
+
+ const first = selectArtifact({ kind: "findings", slot: "review" });
+ const second = selectArtifact({ kind: "findings", slot: "review#2" });
+ await second;
+ releaseFirst(readResult("FIRST", "findings"));
+ await first;
+
+ expect(blackboard().selected).toEqual({ kind: "findings", slot: "review#2" });
+ expect(blackboard().artifact?.content).toBe("SECOND");
+ });
+
+ it("does nothing when no board is loaded", async () => {
+ _resetBlackboardForTest();
+ await selectArtifact({ kind: "spec", slot: null });
+ expect(requestMock).not.toHaveBeenCalled();
+ });
+
+ it("clearSelection empties the body pane without touching the index", async () => {
+ requestMock.mockResolvedValueOnce(readResult("BODY", "findings"));
+ await selectArtifact({ kind: "findings", slot: "review" });
+ clearSelection();
+ expect(blackboard().selected).toBeNull();
+ expect(blackboard().artifact).toBeNull();
+ expect(blackboard().entries).toHaveLength(2);
+ });
+});
+
+describe("open / close", () => {
+ it("openBlackboard opens and fetches; closeBlackboard leaves the data cached", async () => {
+ requestMock.mockResolvedValue(indexResult("goal-1", [entry("spec")]));
+ openBlackboard("goal-1");
+ await vi.waitFor(() => expect(blackboard().entries).toHaveLength(1));
+ expect(isBlackboardOpen()).toBe(true);
+
+ closeBlackboard();
+ expect(isBlackboardOpen()).toBe(false);
+ // Cached, so re-opening the same board doesn't flash empty.
+ expect(blackboard().entries).toHaveLength(1);
+ });
+});
+
+describe("refKey", () => {
+ it("distinguishes slots within a kind and survives a null slot", () => {
+ expect(refKey({ kind: "findings", slot: "review" })).not.toBe(
+ refKey({ kind: "findings", slot: "review#2" }),
+ );
+ expect(refKey({ kind: "spec", slot: null })).toBe(refKey({ kind: "spec", slot: null }));
+ });
+});
diff --git a/web/src/state/blackboard.ts b/web/src/state/blackboard.ts
new file mode 100644
index 0000000..0d49d39
--- /dev/null
+++ b/web/src/state/blackboard.ts
@@ -0,0 +1,208 @@
+/**
+ * Goal-blackboard slice — the index of a collaboration's artifacts and the
+ * body of whichever one the user selected.
+ *
+ * Daemon-canonical, like every other slice here: nothing is derived locally.
+ * The daemon resolves a role-child's session id to its parent's goal, so
+ * `goalSessionId` is always taken from the RESULT rather than from whatever
+ * the caller passed — focusing a child and focusing its orchestrator must land
+ * on the same board.
+ */
+
+import { createSignal } from "solid-js";
+
+import { getClient, newRequestId } from "./connection";
+import type {
+ BlackboardArtifact,
+ BlackboardIndexEntry,
+ BlackboardIndexResultMsg,
+ BlackboardReadResultMsg,
+} from "../protocol/types";
+
+/** Identifies one artifact within a board. `slot: null` = the singleton. */
+export interface ArtifactRef {
+ kind: string;
+ slot: string | null;
+}
+
+interface State {
+ /** Session id the board was requested for (the goal, per the daemon). */
+ goalSessionId: string | null;
+ goal: string;
+ entries: BlackboardIndexEntry[];
+ loading: boolean;
+ error: string | null;
+ fetchedAt: number;
+ selected: ArtifactRef | null;
+ artifact: BlackboardArtifact | null;
+ artifactLoading: boolean;
+ artifactError: string | null;
+}
+
+const EMPTY: State = {
+ goalSessionId: null,
+ goal: "",
+ entries: [],
+ loading: false,
+ error: null,
+ fetchedAt: 0,
+ selected: null,
+ artifact: null,
+ artifactLoading: false,
+ artifactError: null,
+};
+
+const [state, setState] = createSignal(EMPTY);
+const [openSignal, setOpenSignal] = createSignal(false);
+
+export const blackboard = state;
+export const isBlackboardOpen = openSignal;
+
+/**
+ * Stable key for an artifact ref — also the row key in the index list.
+ *
+ * Separated by an explicit U+0000 escape rather than a printable character:
+ * `kind` is an open namespace (`extra/`) and `slot` is daemon-generated,
+ * so any visible delimiter is one future naming choice away from letting two
+ * distinct artifacts collide onto one key — which would silently show the
+ * wrong body under the right row. Written as `\u0000`, never as a literal
+ * control byte: a raw NUL in source is invisible and makes git treat the whole
+ * file as binary.
+ */
+const REF_KEY_SEP = "\u0000";
+
+export function refKey(ref: ArtifactRef): string {
+ return `${ref.kind}${REF_KEY_SEP}${ref.slot ?? ""}`;
+}
+
+/**
+ * Which request each async path is currently serving. Compared on arrival so a
+ * slow reply for a board (or artifact) the user has already navigated away
+ * from is dropped instead of overwriting the newer one.
+ */
+let indexInflight: string | null = null;
+let artifactInflight: string | null = null;
+
+/**
+ * Open the drawer for a session that is part of a collaboration — either the
+ * orchestrator or any of its role-children; the daemon resolves the hop.
+ *
+ * Switching to a DIFFERENT session resets the board. Re-opening the same one
+ * keeps the current entries on screen and refreshes underneath, so the panel
+ * doesn't blank out on every open.
+ */
+export function openBlackboard(sessionId: string): void {
+ setOpenSignal(true);
+ void fetchIndex(sessionId);
+}
+
+export function closeBlackboard(): void {
+ setOpenSignal(false);
+}
+
+export async function fetchIndex(sessionId: string): Promise {
+ indexInflight = sessionId;
+ setState((s) =>
+ s.goalSessionId === sessionId
+ ? { ...s, loading: true, error: null }
+ : { ...EMPTY, goalSessionId: sessionId, loading: true },
+ );
+ try {
+ const id = newRequestId();
+ const result = await getClient().request(
+ { type: "blackboard.index", id, sessionId },
+ {
+ waitForResult: (m) =>
+ m.type === "blackboard.index.result" && m.requestId === id ? m : undefined,
+ timeoutMs: 8_000,
+ },
+ );
+ if (indexInflight !== sessionId) return;
+ setState((s) => ({
+ ...s,
+ // The daemon's answer, not our question: a child id in, its parent out.
+ goalSessionId: result.sessionId,
+ goal: result.goal,
+ entries: result.entries,
+ loading: false,
+ error: null,
+ fetchedAt: Date.now(),
+ // Drop a selection whose artifact is no longer on the board (goal torn
+ // down and rebuilt) — otherwise the body pane shows a stale artifact
+ // with nothing in the list pointing at it.
+ ...(s.selected && !result.entries.some((e) => refKey(e) === refKey(s.selected!))
+ ? { selected: null, artifact: null, artifactError: null }
+ : {}),
+ }));
+ } catch (err) {
+ if (indexInflight !== sessionId) return;
+ setState((s) => ({ ...s, loading: false, error: message(err) }));
+ }
+}
+
+/** Re-fetch the currently-open board. No-op when nothing is open. */
+export function refreshBlackboard(): Promise {
+ const id = state().goalSessionId;
+ return id ? fetchIndex(id) : Promise.resolve();
+}
+
+/** Load one artifact's body into the detail pane. */
+export async function selectArtifact(ref: ArtifactRef): Promise {
+ const sessionId = state().goalSessionId;
+ if (!sessionId) return;
+ const key = refKey(ref);
+ artifactInflight = key;
+ setState((s) => ({
+ ...s,
+ selected: ref,
+ // Keep the previous body visible while the new one loads rather than
+ // flashing empty — but never show it as if it were the new selection.
+ artifact: s.selected && refKey(s.selected) === key ? s.artifact : null,
+ artifactLoading: true,
+ artifactError: null,
+ }));
+ try {
+ const id = newRequestId();
+ const result = await getClient().request(
+ { type: "blackboard.read", id, sessionId, kind: ref.kind, slot: ref.slot },
+ {
+ waitForResult: (m) =>
+ m.type === "blackboard.read.result" && m.requestId === id ? m : undefined,
+ timeoutMs: 15_000,
+ },
+ );
+ if (artifactInflight !== key) return;
+ setState((s) => ({
+ ...s,
+ artifact: result.artifact,
+ artifactLoading: false,
+ artifactError: null,
+ }));
+ } catch (err) {
+ if (artifactInflight !== key) return;
+ setState((s) => ({ ...s, artifactLoading: false, artifactError: message(err) }));
+ }
+}
+
+export function clearSelection(): void {
+ artifactInflight = null;
+ setState((s) => ({
+ ...s,
+ selected: null,
+ artifact: null,
+ artifactLoading: false,
+ artifactError: null,
+ }));
+}
+
+function message(err: unknown): string {
+ return err instanceof Error ? err.message : String(err);
+}
+
+/** Test hook — reset the slice between tests. */
+export function _resetBlackboardForTest(): void {
+ indexInflight = null;
+ artifactInflight = null;
+ setState(EMPTY);
+ setOpenSignal(false);
+}