From 421e287c456f4e5feaf8435067f58f25791f8565 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 04:36:50 -0700 Subject: [PATCH] fix(ui): show Swarm progress in Chat (#113619) * fix(ui): show Swarm progress in chat * style(ui): format rebased Swarm integration * fix(ui): bound inline Swarm progress height --- docs/tools/swarm.md | 17 +- scripts/control-ui-mock-dev.ts | 97 +++++++-- .../board/board-view.test-support.ts | 3 - ui/src/components/board/board-view.test.ts | 42 ---- ui/src/components/board/board-view.ts | 3 - ui/src/components/board/board-widget-cell.ts | 3 - ui/src/lib/board/builtin-dashboard.ts | 9 +- ui/src/lib/board/swarm-dashboard-roster.ts | 40 ---- ui/src/lib/board/view-types.ts | 2 +- ui/src/lib/board/widgets/index.ts | 4 - ui/src/lib/board/widgets/swarm.test.ts | 197 ------------------ .../swarm-roster.test.ts} | 10 +- .../swarm-roster.ts} | 104 ++++----- .../pages/chat/board-session-surface.test.ts | 4 - ui/src/pages/chat/board-session-surface.ts | 3 - ui/src/pages/chat/chat-pane-base.ts | 2 + ui/src/pages/chat/chat-pane-board.ts | 60 +++--- ui/src/pages/chat/chat-pane-context.ts | 2 + ui/src/pages/chat/chat-pane-deps.ts | 2 +- ui/src/pages/chat/chat-pane-render.ts | 2 +- ui/src/pages/chat/chat-view.test.ts | 32 +++ ui/src/pages/chat/chat-view.ts | 11 +- .../components/chat-swarm-progress.test.ts | 156 ++++++++++++++ .../chat/components/chat-swarm-progress.ts} | 53 +++-- ui/src/styles/board.css | 103 +-------- ui/src/styles/chat/layout.css | 118 +++++++++++ 26 files changed, 515 insertions(+), 564 deletions(-) delete mode 100644 ui/src/lib/board/swarm-dashboard-roster.ts delete mode 100644 ui/src/lib/board/widgets/swarm.test.ts rename ui/src/lib/{board/swarm-dashboard.test.ts => sessions/swarm-roster.test.ts} (97%) rename ui/src/lib/{board/swarm-dashboard.ts => sessions/swarm-roster.ts} (63%) create mode 100644 ui/src/pages/chat/components/chat-swarm-progress.test.ts rename ui/src/{lib/board/widgets/swarm.ts => pages/chat/components/chat-swarm-progress.ts} (83%) diff --git a/docs/tools/swarm.md b/docs/tools/swarm.md index d6bce3b9e5dd..dcee8012ae28 100644 --- a/docs/tools/swarm.md +++ b/docs/tools/swarm.md @@ -6,7 +6,7 @@ read_when: - You want a Code Mode script to fan out work across several agents - You need structured child results, decision gates, or first-completion pipelines - You are enabling or tuning tools.swarm limits - - You want to observe collector children in the session dashboard + - You want to observe collector children in chat --- Swarm is an experimental, opt-in way to orchestrate many sub-agents from a @@ -311,14 +311,15 @@ is rejected with the relevant config key in the error. ## Observe a Swarm -Open the parent session's dashboard in the Control UI while a swarm is active. -The Swarm widget renders each active collector group as one dot per child with -queued, running, done, or failed state. Labels appear in dot tooltips, so short -stable labels make larger swarms easier to read. +Keep the parent session open in the Control UI while a swarm is active. A compact +Swarm progress widget appears between the chat transcript and composer, rendering +each active collector group as one dot per child with queued, running, done, or +failed state. Labels appear in dot tooltips, so short stable labels make larger +swarms easier to read. The widget disappears after every group child reaches a +terminal state. -The session sidebar keeps the normal parent/child tree. Expand the parent row -to inspect a collector child or open its transcript without losing the swarm -hierarchy. +The session sidebar keeps the normal parent/child tree. Expand the parent row to +inspect a collector child or open its transcript without losing the swarm hierarchy. Collector results remain waitable until their group is archived. After every member reaches its retention deadline, OpenClaw archives the group's children diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index c778fd0e16c7..981b83e8359a 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -24,7 +24,7 @@ import { buildSkillWorkshopMocks } from "./control-ui-mock-skill-workshop.js"; type CliOptions = { allowedHosts: string[]; - fixture?: "board"; + fixture?: "board" | "swarm"; host: string; port: number; }; @@ -140,11 +140,11 @@ function parseArgs(args: string[]): CliOptions { return options; } -function parseFixture(value: string | undefined): "board" | undefined { +function parseFixture(value: string | undefined): "board" | "swarm" | undefined { if (!value) { return undefined; } - if (value !== "board") { + if (value !== "board" && value !== "swarm") { throw new Error(`Unknown Control UI mock fixture: ${value}`); } return value; @@ -628,13 +628,14 @@ function buildProfileUsageMocks(baseTime: number) { * across a few real section keys, and `config.get` returns a matching * snapshot with the hash `config.set`/`config.apply` are guarded by. */ -function buildConfigMocks() { +function buildConfigMocks(options: { swarmEnabled?: boolean } = {}) { const config = { logging: { level: "info", consoleTimestamps: true }, messages: { queueLimit: 5, responsePrefix: "" }, gateway: { port: 18789, bind: "127.0.0.1" }, agents: { defaults: { thinkingDefault: "medium" } }, models: { mode: "merge" }, + ...(options.swarmEnabled ? { tools: { swarm: true } } : {}), mcp: { servers: { context7: { url: "https://mcp.context7.com/mcp", transport: "streamable-http" }, @@ -820,7 +821,9 @@ function searchPrefixes(term: string): string[] { return Array.from({ length: term.length }, (_value, index) => term.slice(0, index + 1)); } -async function createChatPickerScenario(): Promise { +async function createChatPickerScenario( + fixture?: CliOptions["fixture"], +): Promise { const baseTime = Date.parse("2026-05-22T09:00:00.000Z"); const selfProfile: UserProfile = { id: "presence-riley", @@ -1082,10 +1085,61 @@ async function createChatPickerScenario(): Promise runtimeMs: 200_000, }, ); + const swarmGroupId = "swarm:agent:main:main:mock-turn"; + const swarmChildRows = + fixture === "swarm" + ? [ + sessionRow("agent:main:subagent:swarm-plan", "National polling", baseTime - 9_000, { + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + swarmGroupId, + swarmPhase: "Research", + swarmPhaseRank: 0, + status: "done", + }), + sessionRow("agent:main:subagent:swarm-work", "Work and labor", baseTime - 8_000, { + hasActiveRun: true, + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + swarmGroupId, + swarmLog: "Comparing labor, education, and consumer signals.", + swarmPhase: "Research", + swarmPhaseRank: 0, + status: "running", + }), + sessionRow("agent:main:subagent:swarm-health", "Health", baseTime - 7_000, { + hasActiveRun: true, + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + swarmGroupId, + swarmPhase: "Research", + swarmPhaseRank: 0, + status: "running", + }), + sessionRow("agent:main:subagent:swarm-trust", "Governance and trust", baseTime - 6_000, { + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + subagentRunState: "active", + swarmGroupId, + swarmPhase: "Research", + swarmPhaseRank: 0, + status: undefined, + }), + sessionRow("agent:main:subagent:swarm-media", "Media signals", baseTime - 5_000, { + parentSessionKey: "agent:main:main", + spawnedBy: "agent:main:main", + swarmGroupId, + swarmPhase: "Research", + swarmPhaseRank: 0, + status: "failed", + }), + ] + : []; const sessions = [ sessionRow("agent:main:main", "Molty", baseTime - 1_000, { - childSessions: ["agent:main:lisbon-trip"], + childSessions: ["agent:main:lisbon-trip", ...swarmChildRows.map((row) => row.key)], }), + ...swarmChildRows, sessionRow(OBSERVER_DEMO_SESSION_KEY, "Session observer demo", baseTime - 3_000, { activeRunIds: [OBSERVER_DEMO_RUN_ID], hasActiveRun: true, @@ -1204,7 +1258,7 @@ async function createChatPickerScenario(): Promise const modelProviders = buildModelProviderMocks(Date.now()); const skillWorkshop = buildSkillWorkshopMocks(Date.now()); const channelWizard = buildChannelWizardMocks(); - const configMocks = buildConfigMocks(); + const configMocks = buildConfigMocks({ swarmEnabled: fixture === "swarm" }); return { assistantAgentId: "main", assistantName: "Molty", @@ -1356,17 +1410,20 @@ async function createChatPickerScenario(): Promise }, ], }, - "exec.approval.list": [ - { - id: "mock-production-export-approval", - request: { - command: "openclaw export --target production", - sessionKey: "agent:main:production-export", - }, - createdAtMs: baseTime - 75_000, - expiresAtMs: ATTENTION_FIXTURE_EXPIRES_AT, - }, - ], + "exec.approval.list": + fixture === "swarm" + ? [] + : [ + { + id: "mock-production-export-approval", + request: { + command: "openclaw export --target production", + sessionKey: "agent:main:production-export", + }, + createdAtMs: baseTime - 75_000, + expiresAtMs: ATTENTION_FIXTURE_EXPIRES_AT, + }, + ], "plugin.approval.list": [], "openclaw.approval.list": [], "sessions.patch": { ok: true }, @@ -1722,7 +1779,7 @@ async function createChatPickerScenario(): Promise // Child fetches must precede the catch-all page case (subset match). { match: { spawnedBy: "agent:main:main" }, - response: pagedSessionsListResponse([mainChildRow], 0), + response: pagedSessionsListResponse([mainChildRow, ...swarmChildRows], 0), }, { match: { spawnedBy: "agent:main:tax-research" }, @@ -1880,7 +1937,7 @@ async function waitForShutdown(): Promise { } const options = parseArgs(process.argv.slice(2)); -const scenario = await createChatPickerScenario(); +const scenario = await createChatPickerScenario(options.fixture); const server = await createServer({ base: "/", cacheDir: path.join(repoRoot, ".artifacts", "control-ui-mock-vite"), diff --git a/ui/src/components/board/board-view.test-support.ts b/ui/src/components/board/board-view.test-support.ts index b0f9d38dbfc0..f2a6866e4ae2 100644 --- a/ui/src/components/board/board-view.test-support.ts +++ b/ui/src/components/board/board-view.test-support.ts @@ -1,5 +1,4 @@ import { vi } from "vitest"; -import type { GatewaySessionRow } from "../../api/types.ts"; import type { RouteId } from "../../app-route-paths.ts"; import type { ApplicationContext } from "../../app/context.ts"; import type { BoardWidget } from "../../lib/board/types.ts"; @@ -108,7 +107,6 @@ export async function mount( activeTabId?: string; callbacks?: BoardViewCallbacks; widgetFrameUrl?: (name: string, revision: number) => string; - sessions?: readonly GatewaySessionRow[]; context?: ApplicationContext; canMutate?: boolean; canGrant?: boolean; @@ -119,7 +117,6 @@ export async function mount( view.activeTabId = options.activeTabId ?? "main"; view.widgetFrameUrl = options.widgetFrameUrl ?? (() => "about:blank"); view.callbacks = options.callbacks ?? callbacks(); - view.sessions = options.sessions ?? []; view.canMutate = options.canMutate ?? true; view.canGrant = options.canGrant ?? true; if (options.context) { diff --git a/ui/src/components/board/board-view.test.ts b/ui/src/components/board/board-view.test.ts index 8c62388f6f61..b14c72e10800 100644 --- a/ui/src/components/board/board-view.test.ts +++ b/ui/src/components/board/board-view.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { BoardSnapshot } from "../../lib/board/types.ts"; -import type { BoardViewWidget } from "../../lib/board/view-types.ts"; import { recordBoardWidgetTicketReceipt } from "../../lib/board/widget-ticket-lifetime.ts"; // Side-effect import: registers the custom elements mount() depends on // without relying on transitive fixture imports. @@ -42,47 +41,6 @@ describe("openclaw-board-view", () => { } }); - it("renders the native swarm card without a frame or persisted widget controls", async () => { - const swarm: BoardViewWidget = { - name: "builtin:swarm", - tabId: "builtin-swarm", - title: "Swarm progress", - contentKind: "builtin", - builtin: "swarm", - readOnly: true, - sizeW: 12, - sizeH: 4, - position: 0, - grantState: "granted", - revision: 1, - }; - const source = snapshot({ - sessionKey: "agent:main:parent", - tabs: [{ tabId: "builtin-swarm", title: "Swarm progress", position: 0, chatDock: "right" }], - widgets: [swarm], - }); - const view = await mount({ - snapshot: source, - activeTabId: "builtin-swarm", - sessions: [ - { - key: "agent:main:child", - kind: "direct", - updatedAt: 1, - parentSessionKey: "agent:main:parent", - swarmGroupId: "swarm:agent:main:parent:turn-42", - label: "Worker A", - status: "running", - }, - ], - }); - - expect(view.querySelector("[data-test-id=swarm-widget]")).not.toBeNull(); - expect(view.querySelector("iframe")).toBeNull(); - expect(view.querySelector(".board-widget__menu")).toBeNull(); - expect(view.querySelector(".board-widget__resize-handle")).toBeNull(); - }); - it("renders the shared sandbox for an empty same-origin gateway URL", async () => { const view = await mount({ context: gatewayContext(null), diff --git a/ui/src/components/board/board-view.ts b/ui/src/components/board/board-view.ts index a2111e1346b1..41c420832b24 100644 --- a/ui/src/components/board/board-view.ts +++ b/ui/src/components/board/board-view.ts @@ -2,7 +2,6 @@ import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { property, state } from "lit/decorators.js"; import { keyed } from "lit/directives/keyed.js"; import { repeat } from "lit/directives/repeat.js"; -import type { GatewaySessionRow } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; import { BOARD_GRID_COLUMNS, @@ -78,7 +77,6 @@ class OpenClawBoardView extends OpenClawLightDomElement { @property({ attribute: false }) activeTabId = ""; @property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl; @property({ attribute: false }) callbacks?: BoardViewCallbacks; - @property({ attribute: false }) sessions: readonly GatewaySessionRow[] = []; @property({ attribute: false }) observer?: BoardObserverContext; @property({ type: Boolean }) canMutate = true; @property({ type: Boolean }) canGrant = true; @@ -660,7 +658,6 @@ class OpenClawBoardView extends OpenClawLightDomElement { .sessionKey=${sessionKey} .widgetFrameUrl=${this.widgetFrameUrl} .callbacks=${this.cellCallbacks} - .sessions=${this.sessions} .observer=${this.observer} .dragging=${widget.name === this.gestureName} .focusTabIndex=${widget.name === focusName ? 0 : -1} diff --git a/ui/src/components/board/board-widget-cell.ts b/ui/src/components/board/board-widget-cell.ts index e29ce6d3bcce..9846105fa65a 100644 --- a/ui/src/components/board/board-widget-cell.ts +++ b/ui/src/components/board/board-widget-cell.ts @@ -1,7 +1,6 @@ import { consume } from "@lit/context"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { property, state } from "lit/decorators.js"; -import type { GatewaySessionRow } from "../../api/types.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts"; import { t } from "../../i18n/index.ts"; @@ -69,7 +68,6 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement { @property({ attribute: false }) sessionKey = ""; @property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl; @property({ attribute: false }) callbacks?: BoardWidgetCellCallbacks; - @property({ attribute: false }) sessions: readonly GatewaySessionRow[] = []; @property({ attribute: false }) observer?: BoardObserverContext; @property({ type: Boolean }) dragging = false; @property({ type: Number }) focusTabIndex = -1; @@ -256,7 +254,6 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement { } return renderer({ observer: this.observer, - sessions: this.sessions, sessionKey: this.sessionKey, }); } diff --git a/ui/src/lib/board/builtin-dashboard.ts b/ui/src/lib/board/builtin-dashboard.ts index 2b658e9da621..54fd3ab6f2b2 100644 --- a/ui/src/lib/board/builtin-dashboard.ts +++ b/ui/src/lib/board/builtin-dashboard.ts @@ -1,18 +1,11 @@ import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js"; -import type { GatewaySessionRow } from "../../api/types.ts"; import { withObserverWidget } from "./observer-dashboard.ts"; -import { isSwarmEnabledInConfig, SwarmRosterHydrator, withSwarmWidget } from "./swarm-dashboard.ts"; - -export { isSwarmEnabledInConfig, SwarmRosterHydrator }; import type { BoardSnapshot } from "./types.ts"; import type { BoardViewSnapshot } from "./view-types.ts"; export function withBuiltinDashboardWidgets( snapshot: BoardSnapshot, - sessions: readonly GatewaySessionRow[], observerDigests: readonly SessionObserverDigest[], - swarmEnabled = true, ): BoardViewSnapshot { - const withSwarm = swarmEnabled ? withSwarmWidget(snapshot, sessions) : snapshot; - return withObserverWidget(withSwarm, observerDigests); + return withObserverWidget(snapshot, observerDigests); } diff --git a/ui/src/lib/board/swarm-dashboard-roster.ts b/ui/src/lib/board/swarm-dashboard-roster.ts deleted file mode 100644 index 9b69e1a0b184..000000000000 --- a/ui/src/lib/board/swarm-dashboard-roster.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { GatewaySessionRow } from "../../api/types.ts"; -import { fetchChildSessionRows } from "../sessions/child-session-data.ts"; -import type { SessionCapability } from "../sessions/index.ts"; - -const SWARM_SESSION_PAGE_SIZE = 10_000; - -function isNewerSessionRow(candidate: GatewaySessionRow, current: GatewaySessionRow): boolean { - // Callers pass hydrated rows first and the current lifecycle-decorated page - // second, so equal persisted timestamps intentionally prefer the latter. - return (candidate.updatedAt ?? 0) >= (current.updatedAt ?? 0); -} - -export function mergeSwarmSessionRows( - childRows: readonly GatewaySessionRow[], - currentRows: readonly GatewaySessionRow[], -): GatewaySessionRow[] { - const merged = new Map(); - for (const row of [...childRows, ...currentRows]) { - const current = merged.get(row.key); - if (!current || isNewerSessionRow(row, current)) { - merged.set(row.key, row); - } - } - return [...merged.values()]; -} - -export async function hydrateSwarmSessionRows(params: { - sessions: SessionCapability; - parentKey: string; - currentRows: readonly GatewaySessionRow[]; - isCurrent: () => boolean; -}): Promise { - const childRows = await fetchChildSessionRows({ - sessions: params.sessions, - parentKey: params.parentKey, - isCurrent: params.isCurrent, - pageSize: SWARM_SESSION_PAGE_SIZE, - }); - return childRows ? mergeSwarmSessionRows(params.currentRows, childRows) : null; -} diff --git a/ui/src/lib/board/view-types.ts b/ui/src/lib/board/view-types.ts index a93cfc83e475..c685f2b8a42c 100644 --- a/ui/src/lib/board/view-types.ts +++ b/ui/src/lib/board/view-types.ts @@ -12,7 +12,7 @@ type BoardStoredWidget = BoardWidget & { readOnly?: false | undefined; }; type BoardBuiltinWidget = Omit & { - builtin: "observer" | "swarm"; + builtin: "observer"; contentKind: "builtin"; readOnly: true; }; diff --git a/ui/src/lib/board/widgets/index.ts b/ui/src/lib/board/widgets/index.ts index b8a1f1e29d89..de53912f6719 100644 --- a/ui/src/lib/board/widgets/index.ts +++ b/ui/src/lib/board/widgets/index.ts @@ -1,15 +1,12 @@ import type { TemplateResult } from "lit"; import type { GatewayControlUiPluginWidgetKind } from "../../../api/gateway.ts"; -import type { GatewaySessionRow } from "../../../api/types.ts"; import { t } from "../../../i18n/index.ts"; import type { BoardViewWidget } from "../view-types.ts"; import type { BoardObserverContext } from "../view-types.ts"; import { renderObserverWidget } from "./observer.ts"; -import { renderSwarmWidget } from "./swarm.ts"; type BuiltinBoardWidgetRenderer = (context: { observer?: BoardObserverContext; - sessions: readonly GatewaySessionRow[]; sessionKey: string; }) => TemplateResult; @@ -47,7 +44,6 @@ const pluginRendererPromises = new Map = { observer: renderObserverWidget, - swarm: renderSwarmWidget, }; export function getBuiltinWidgetRenderer( diff --git a/ui/src/lib/board/widgets/swarm.test.ts b/ui/src/lib/board/widgets/swarm.test.ts deleted file mode 100644 index 485ef0db80ac..000000000000 --- a/ui/src/lib/board/widgets/swarm.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* @vitest-environment jsdom */ - -import { render } from "lit"; -import { afterEach, describe, expect, it } from "vitest"; -import type { GatewaySessionRow } from "../../../api/types.ts"; -import { renderSwarmWidget } from "./swarm.ts"; - -const parentSessionKey = "agent:main:parent"; - -type SwarmTestSession = GatewaySessionRow & { - swarmLog?: string; - swarmPhase?: string; - swarmPhaseRank?: number; -}; - -function session(overrides: Partial): SwarmTestSession { - return { - key: "agent:main:child", - kind: "direct", - updatedAt: 1, - parentSessionKey, - swarmGroupId: "swarm:agent:main:parent:turn-42", - ...overrides, - }; -} - -afterEach(() => { - document.body.replaceChildren(); -}); - -describe("swarm board widget", () => { - it("groups live collector children and maps their dot states", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderSwarmWidget({ - sessionKey: parentSessionKey, - sessions: [ - session({ key: "queued", label: "Queued child", subagentRunState: "active" }), - session({ key: "running", label: "Running child", status: "running" }), - session({ key: "done", label: "Done child", status: "done" }), - session({ key: "failed", label: "Timed out child", status: "timeout" }), - session({ - key: "finished-group", - swarmGroupId: "swarm:agent:main:parent:finished", - status: "done", - }), - ], - }), - container, - ); - - const group = container.querySelector("[data-swarm-group]"); - expect(group?.getAttribute("data-swarm-group")).toBe("swarm:agent:main:parent:turn-42"); - expect(group?.textContent).toContain("turn-42"); - expect(group?.textContent?.replace(/\s+/g, " ")).toContain("1 Running · 1 Done · 1 Failed"); - expect( - [...container.querySelectorAll(".swarm-widget__dot")].map((dot) => dot.className), - ).toEqual([ - "swarm-widget__dot swarm-widget__dot--queued", - "swarm-widget__dot swarm-widget__dot--running", - "swarm-widget__dot swarm-widget__dot--done", - "swarm-widget__dot swarm-widget__dot--failed", - ]); - }); - - it("renders every child beyond the ordinary 50-row session page", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderSwarmWidget({ - sessionKey: parentSessionKey, - sessions: Array.from({ length: 55 }, (_, index) => - session({ key: `child-${index}`, status: "running" }), - ), - }), - container, - ); - - expect(container.querySelectorAll(".swarm-widget__dot")).toHaveLength(55); - }); - - it("caps historical dots while keeping active workers visible", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderSwarmWidget({ - sessionKey: parentSessionKey, - sessions: [ - ...Array.from({ length: 300 }, (_, index) => - session({ key: `done-${index}`, status: "done" }), - ), - session({ key: "running", status: "running" }), - ], - }), - container, - ); - - expect(container.querySelectorAll(".swarm-widget__dot")).toHaveLength(256); - expect(container.querySelector(".swarm-widget__dot--running")).not.toBeNull(); - expect(container.querySelector(".swarm-widget__more")?.textContent?.trim()).toBe("+45"); - expect(container.textContent?.replace(/\s+/g, " ")).toContain("1 Running · 300 Done"); - }); - - it("renders dot tooltips and keeps an empty state when no group is active", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderSwarmWidget({ - sessionKey: parentSessionKey, - sessions: [session({ label: "Worker A", status: "running" })], - }), - container, - ); - - const dot = container.querySelector(".swarm-widget__dot--running"); - expect(dot?.title).toBe("Worker A: Running"); - expect(container.textContent).toContain("turn-42"); - expect(container.textContent?.replace(/\s+/g, " ")).toContain("1 Running · 0 Done · 0 Failed"); - - render(renderSwarmWidget({ sessionKey: parentSessionKey, sessions: [] }), container); - expect(container.querySelector("[data-test-id=swarm-empty]")?.textContent?.trim()).toBe( - "No active swarms.", - ); - }); - - it("buckets children by phase, labels the default bucket, and shows the latest log", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderSwarmWidget({ - sessionKey: parentSessionKey, - sessions: [ - session({ key: "unphased", label: "Older child", status: "running" }), - session({ key: "planning", label: "Planner", status: "done", swarmPhase: "Plan" }), - session({ - key: "building", - label: "Builder", - subagentRunState: "active", - swarmPhase: "Build", - swarmLog: "Implementing the selected plan.", - }), - ], - }), - container, - ); - - expect( - [...container.querySelectorAll(".swarm-widget__phase")].map((phase) => - phase.textContent?.trim(), - ), - ).toEqual(["Unphased", "Plan", "Build"]); - expect( - [...container.querySelectorAll(".swarm-widget__phase-row")].map( - (row) => row.querySelectorAll(".swarm-widget__dot").length, - ), - ).toEqual([1, 1, 1]); - expect(container.querySelector(".swarm-widget__narrator")?.textContent).toContain( - "Implementing the selected plan.", - ); - }); - - it("orders phase buckets by observation rank, not canonical row order", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderSwarmWidget({ - sessionKey: parentSessionKey, - sessions: [ - // Canonical list order is reversed vs phase announcement order. - session({ - key: "builder", - label: "Builder", - status: "running", - swarmPhase: "Build", - swarmPhaseRank: 1, - }), - session({ key: "late-unphased", label: "Late child", status: "running" }), - session({ - key: "planner", - label: "Planner", - status: "done", - swarmPhase: "Plan", - swarmPhaseRank: 0, - }), - ], - }), - container, - ); - - expect( - [...container.querySelectorAll(".swarm-widget__phase")].map((phase) => - phase.textContent?.trim(), - ), - ).toEqual(["Plan", "Build", "Unphased"]); - }); -}); diff --git a/ui/src/lib/board/swarm-dashboard.test.ts b/ui/src/lib/sessions/swarm-roster.test.ts similarity index 97% rename from ui/src/lib/board/swarm-dashboard.test.ts rename to ui/src/lib/sessions/swarm-roster.test.ts index abc66459ef23..e1ead7ab4b55 100644 --- a/ui/src/lib/board/swarm-dashboard.test.ts +++ b/ui/src/lib/sessions/swarm-roster.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; -import type { SessionCapability, SessionListOptions } from "../sessions/index.ts"; -import { hydrateSwarmSessionRows, mergeSwarmSessionRows } from "./swarm-dashboard-roster.ts"; -import { isSwarmEnabledInConfig, SwarmRosterHydrator } from "./swarm-dashboard.ts"; +import type { SessionCapability, SessionListOptions } from "./index.ts"; +import { + hydrateSwarmSessionRows, + isSwarmEnabledInConfig, + mergeSwarmSessionRows, + SwarmRosterHydrator, +} from "./swarm-roster.ts"; function row(index: number): GatewaySessionRow { return { diff --git a/ui/src/lib/board/swarm-dashboard.ts b/ui/src/lib/sessions/swarm-roster.ts similarity index 63% rename from ui/src/lib/board/swarm-dashboard.ts rename to ui/src/lib/sessions/swarm-roster.ts index 03527936b85b..8e7820e53972 100644 --- a/ui/src/lib/board/swarm-dashboard.ts +++ b/ui/src/lib/sessions/swarm-roster.ts @@ -1,14 +1,9 @@ import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import type { GatewaySessionRow } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import type { SessionCapability } from "../sessions/index.ts"; -import { areUiSessionKeysEquivalent } from "../sessions/session-key.ts"; -import { hydrateSwarmSessionRows, mergeSwarmSessionRows } from "./swarm-dashboard-roster.ts"; -import type { BoardSnapshot } from "./types.ts"; -import type { BoardViewSnapshot } from "./view-types.ts"; +import { fetchChildSessionRows } from "./child-session-data.ts"; +import type { SessionCapability } from "./index.ts"; -const SWARM_TAB_ID = "builtin-swarm"; -const SWARM_WIDGET_NAME = "builtin:swarm"; +const SWARM_SESSION_PAGE_SIZE = 10_000; function readSwarmEnabled(value: unknown): boolean | undefined { if (typeof value === "boolean") { @@ -36,6 +31,41 @@ export function isSwarmEnabledInConfig(config: unknown, agentId?: string): boole return agentEnabled ?? globalEnabled ?? false; } +function isNewerSessionRow(candidate: GatewaySessionRow, current: GatewaySessionRow): boolean { + // Callers pass hydrated rows first and the current lifecycle-decorated page + // second, so equal persisted timestamps intentionally prefer the latter. + return (candidate.updatedAt ?? 0) >= (current.updatedAt ?? 0); +} + +export function mergeSwarmSessionRows( + childRows: readonly GatewaySessionRow[], + currentRows: readonly GatewaySessionRow[], +): GatewaySessionRow[] { + const merged = new Map(); + for (const row of [...childRows, ...currentRows]) { + const current = merged.get(row.key); + if (!current || isNewerSessionRow(row, current)) { + merged.set(row.key, row); + } + } + return [...merged.values()]; +} + +export async function hydrateSwarmSessionRows(params: { + sessions: SessionCapability; + parentKey: string; + currentRows: readonly GatewaySessionRow[]; + isCurrent: () => boolean; +}): Promise { + const childRows = await fetchChildSessionRows({ + sessions: params.sessions, + parentKey: params.parentKey, + isCurrent: params.isCurrent, + pageSize: SWARM_SESSION_PAGE_SIZE, + }); + return childRows ? mergeSwarmSessionRows(params.currentRows, childRows) : null; +} + type SwarmHydrationParams = { sessions: SessionCapability; parentKey: string; @@ -144,61 +174,3 @@ export class SwarmRosterHydrator { this.timer = null; } } - -function hasSwarmRowsForSession( - sessions: readonly GatewaySessionRow[], - sessionKey: string, -): boolean { - return sessions.some( - (row) => - Boolean(row.swarmGroupId?.trim()) && - ((row.parentSessionKey && areUiSessionKeysEquivalent(row.parentSessionKey, sessionKey)) || - (row.spawnedBy && areUiSessionKeysEquivalent(row.spawnedBy, sessionKey)) || - ((): boolean => { - const owner = row.swarmGroupId?.split(":").slice(1, -1).join(":"); - return Boolean(owner && areUiSessionKeysEquivalent(owner, sessionKey)); - })()), - ); -} - -/** Creates the ephemeral board card from the live session roster, never from persisted board state. */ -export function withSwarmWidget( - snapshot: BoardSnapshot, - sessions: readonly GatewaySessionRow[], -): BoardViewSnapshot { - // Keep the card mounted through terminal collector updates so its explicit - // empty state is visible before the retention sweep removes the group. - if (!hasSwarmRowsForSession(sessions, snapshot.sessionKey)) { - return snapshot; - } - const tabs = snapshot.tabs.some((tab) => tab.tabId === SWARM_TAB_ID) - ? snapshot.tabs - : [ - ...snapshot.tabs, - { - tabId: SWARM_TAB_ID, - title: t("labsPage.swarm.title"), - position: Math.max(-1, ...snapshot.tabs.map((tab) => tab.position)) + 1, - chatDock: "right" as const, - }, - ]; - const widget = { - name: SWARM_WIDGET_NAME, - tabId: SWARM_TAB_ID, - title: t("labsPage.swarm.title"), - contentKind: "builtin" as const, - builtin: "swarm" as const, - readOnly: true, - sizeW: 12, - sizeH: 4, - position: 0, - grantState: "granted" as const, - revision: snapshot.revision, - } satisfies BoardViewSnapshot["widgets"][number]; - const widgets = snapshot.widgets.some((candidate) => candidate.name === SWARM_WIDGET_NAME) - ? snapshot.widgets.map((candidate) => - candidate.name === SWARM_WIDGET_NAME ? widget : candidate, - ) - : [...snapshot.widgets, widget]; - return { ...snapshot, tabs, widgets }; -} diff --git a/ui/src/pages/chat/board-session-surface.test.ts b/ui/src/pages/chat/board-session-surface.test.ts index 18e8032b2340..bbed6e290085 100644 --- a/ui/src/pages/chat/board-session-surface.test.ts +++ b/ui/src/pages/chat/board-session-surface.test.ts @@ -36,7 +36,6 @@ describe("board session shell", () => { } as never; const props = { snapshot: provider.snapshot$.value, - sessions: [], activeTabId: "main", dock: "right" as const, reopenDock: "right" as const, @@ -112,7 +111,6 @@ describe("board session shell", () => { render( renderBoardSessionSurface({ snapshot: provider.snapshot$.value, - sessions: [], activeTabId: "main", dock, reopenDock: "right", @@ -145,7 +143,6 @@ describe("board session shell", () => { render( renderBoardSessionSurface({ snapshot: provider.snapshot$.value, - sessions: [], activeTabId: "main", dock: "hidden", reopenDock: "left", @@ -177,7 +174,6 @@ describe("board session shell", () => { const provider = boardProviderForSession("agent:main:main"); const props = { snapshot: provider.snapshot$.value, - sessions: [], activeTabId: "main", reopenDock: "left" as const, dockSize: { height: 300, width: 420 }, diff --git a/ui/src/pages/chat/board-session-surface.ts b/ui/src/pages/chat/board-session-surface.ts index 6383567d17e0..5d1e6172a34d 100644 --- a/ui/src/pages/chat/board-session-surface.ts +++ b/ui/src/pages/chat/board-session-surface.ts @@ -1,6 +1,5 @@ import { html, nothing, type TemplateResult } from "lit"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; -import type { GatewaySessionRow } from "../../api/types.ts"; import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts"; import { icons } from "../../components/icons.ts"; import { renderSettingsSegmented } from "../../components/settings-ui.ts"; @@ -27,7 +26,6 @@ export type WorkboardCardChipProps = { type BoardSessionSurfaceProps = { snapshot: BoardViewSnapshot; - sessions: readonly GatewaySessionRow[]; observer?: BoardObserverContext; activeTabId: string; dock: BoardTab["chatDock"]; @@ -170,7 +168,6 @@ function renderBoardView(props: BoardSessionSurfaceProps) { .activeTabId=${props.activeTabId} .widgetFrameUrl=${props.widgetFrameUrl} .callbacks=${props.callbacks} - .sessions=${props.sessions} .observer=${props.observer} .canMutate=${props.canMutate} .canGrant=${props.canGrant} diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 253b8984d9d4..a61c03ed217a 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -226,6 +226,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { () => this.context?.runtimeConfig, (runtimeConfig, notify) => runtimeConfig.subscribe(() => { + this.refreshSwarmRoster(); this.refreshBuiltinBoardSnapshot(); notify(); }), @@ -245,6 +246,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { } protected abstract refreshSessionPullRequests(options?: { refresh?: boolean }): Promise; + protected abstract refreshSwarmRoster(): void; protected abstract refreshBuiltinBoardSnapshot(): void; protected abstract resolveBoardProvider(): BoardProvider; protected abstract handleBoardCommand(event: BoardCommandEvent): void; diff --git a/ui/src/pages/chat/chat-pane-board.ts b/ui/src/pages/chat/chat-pane-board.ts index a99c3ace7dcd..016d87f8ca54 100644 --- a/ui/src/pages/chat/chat-pane-board.ts +++ b/ui/src/pages/chat/chat-pane-board.ts @@ -22,7 +22,6 @@ import { type BoardSessionView, type BoardTab, type BoardViewSnapshot, - type GatewaySessionRow, type SessionObserverDigest, type WorkboardCardChipProps, } from "./chat-pane-deps.ts"; @@ -140,15 +139,15 @@ export abstract class ChatPaneBoard extends ChatPaneHistory { return normalized === "main" ? buildAgentMainSessionKey({ agentId: "main" }) : normalized; } - protected refreshBuiltinBoardSnapshot(): void { + protected refreshSwarmRoster(): void { const state = this.state; if (!state) { return; } const parentKey = this.resolveBoardSessionKey(); const sourceEpoch = state.connectionEpoch; - void import("../../lib/board/builtin-dashboard.ts").then( - ({ isSwarmEnabledInConfig, SwarmRosterHydrator, withBuiltinDashboardWidgets }) => { + void import("../../lib/sessions/swarm-roster.ts").then( + ({ isSwarmEnabledInConfig, SwarmRosterHydrator }) => { if ( !this.state || this.state.connectionEpoch !== sourceEpoch || @@ -156,28 +155,16 @@ export abstract class ChatPaneBoard extends ChatPaneHistory { ) { return; } - const swarmEnabled = + const enabled = this.state.connected && isSwarmEnabledInConfig( this.context.runtimeConfig?.state.configSnapshot?.config, resolveAgentIdFromSessionKey(parentKey), ); - const applyRows = (rows: readonly GatewaySessionRow[], includeSwarm: boolean) => { - const base = this.resolveBoardProvider().snapshot$.value; - const sessionKey = this.resolveBoardSessionKey(base.sessionKey); - this.builtinBoardSnapshotBase = base; - this.builtinBoardSnapshot = withBuiltinDashboardWidgets( - base, - rows, - this.observerDigestHistory.get(sessionKey), - includeSwarm, - ); - this.requestUpdate(); - }; - if (!swarmEnabled) { + if (!enabled) { this.swarmHydrator?.dispose(); this.swarmHydrator = null; - applyRows(this.state.sessionsResult?.sessions ?? [], false); + this.requestUpdate(); return; } this.swarmHydrator ??= new SwarmRosterHydrator(); @@ -189,12 +176,38 @@ export abstract class ChatPaneBoard extends ChatPaneHistory { this.state?.connectionEpoch === sourceEpoch ? (this.state.sessionsResult?.sessions ?? []) : [], - onRows: (rows) => applyRows(rows, true), + onRows: () => this.requestUpdate(), }); }, ); } + protected refreshBuiltinBoardSnapshot(): void { + const state = this.state; + if (!state) { + return; + } + const parentKey = this.resolveBoardSessionKey(); + const sourceEpoch = state.connectionEpoch; + void import("../../lib/board/builtin-dashboard.ts").then(({ withBuiltinDashboardWidgets }) => { + if ( + !this.state || + this.state.connectionEpoch !== sourceEpoch || + parentKey !== this.resolveBoardSessionKey() + ) { + return; + } + const base = this.resolveBoardProvider().snapshot$.value; + const sessionKey = this.resolveBoardSessionKey(base.sessionKey); + this.builtinBoardSnapshotBase = base; + this.builtinBoardSnapshot = withBuiltinDashboardWidgets( + base, + this.observerDigestHistory.get(sessionKey), + ); + this.requestUpdate(); + }); + } + protected recordObserverDigest(digest: SessionObserverDigest): void { const sessionKey = this.resolveBoardSessionKey(digest.sessionKey); if (this.observerDigestHistory.record({ ...digest, sessionKey })) { @@ -217,12 +230,7 @@ export abstract class ChatPaneBoard extends ChatPaneHistory { const savedTab = snapshot.tabs.some((tab) => tab.tabId === saved?.activeTabId) ? saved?.activeTabId : undefined; - const activeTabId = - savedTab ?? - snapshot.widgets.find((candidate) => candidate.builtin === "swarm")?.tabId ?? - snapshot.tabs[0]?.tabId ?? - snapshot.widgets[0]?.tabId ?? - ""; + const activeTabId = savedTab ?? snapshot.tabs[0]?.tabId ?? snapshot.widgets[0]?.tabId ?? ""; const tab = snapshot.tabs.find((candidate) => candidate.tabId === activeTabId); const activeTabReadOnly = snapshot.widgets.some( (candidate) => candidate.tabId === activeTabId && candidate.readOnly === true, diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index 17118a34d8b5..3f678cb329d6 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -59,6 +59,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { this.observerDigestHistory.hydrate(sessionKey, row.observerDigest, row.sessionId); } } + this.refreshSwarmRoster(); this.refreshBuiltinBoardSnapshot(); const selectedSession = stateValue.result?.sessions.find((row) => areUiSessionKeysEquivalent(row.key, state.sessionKey), @@ -257,6 +258,7 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { state.requestUpdate?.(); return; } + this.refreshSwarmRoster(); if (clientChanged && snapshot.client) { const startupClient = snapshot.client; const startupGeneration = this.connectionGeneration; diff --git a/ui/src/pages/chat/chat-pane-deps.ts b/ui/src/pages/chat/chat-pane-deps.ts index 6dc13e10bf10..8c799dbbec52 100644 --- a/ui/src/pages/chat/chat-pane-deps.ts +++ b/ui/src/pages/chat/chat-pane-deps.ts @@ -100,7 +100,7 @@ export { type BoardFace, type BoardSessionView, } from "../../lib/board/settings.ts"; -export type { SwarmRosterHydrator } from "../../lib/board/swarm-dashboard.ts"; +export type { SwarmRosterHydrator } from "../../lib/sessions/swarm-roster.ts"; export type { BoardSnapshot, BoardTab } from "../../lib/board/types.ts"; export type { BoardViewSnapshot } from "../../lib/board/view-types.ts"; export { diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index ec9378939e1e..c36cc0ac5a68 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -320,6 +320,7 @@ export class ChatPaneRender extends ChatPaneHeaderRender { } : undefined, sessions: state.sessionsResult, + swarmSessions: this.swarmHydrator?.rows ?? [], sessionHost: { assistantAgentId: state.assistantAgentId, agentsList: state.agentsList, @@ -578,7 +579,6 @@ export class ChatPaneRender extends ChatPaneHeaderRender { board.hasBoard && board.face === "dashboard" ? renderBoardSessionSurface({ snapshot: board.snapshot, - sessions: this.swarmHydrator?.rows ?? state.sessionsResult?.sessions ?? [], observer: { activeRunId: observerRunId, digests: this.observerDigestHistory.get( diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 65ec02f94449..d01ab896005b 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -719,6 +719,38 @@ function createDeferred() { return { promise, resolve, reject }; } +describe("chat Swarm progress", () => { + it("stays visible during an active run between the transcript and composer", () => { + const parentSessionKey = "agent:main:parent"; + const container = renderChatView({ + sessionKey: parentSessionKey, + canAbort: true, + showNewMessages: true, + swarmSessions: [ + { + key: "agent:main:subagent:worker", + kind: "direct", + updatedAt: 1, + parentSessionKey, + swarmGroupId: "swarm:agent:main:parent:turn-42", + label: "Worker A", + status: "running", + }, + ], + }); + + const widget = container.querySelector("[data-test-id=chat-swarm]"); + expect(widget).not.toBeNull(); + const scrollAnchor = widget?.previousElementSibling; + expect(scrollAnchor?.classList.contains("chat-scroll-to-bottom-wrap")).toBe(true); + expect(scrollAnchor?.previousElementSibling?.classList.contains("chat-thread")).toBe(true); + expect(widget?.nextElementSibling?.classList.contains("agent-chat__composer-shell")).toBe(true); + expect(container.querySelector(".chat-swarm__dot--running")?.getAttribute("title")).toBe( + "Worker A: Running", + ); + }); +}); + describe("inline approval card", () => { it("renders between the transcript and composer and forwards its decision id", () => { const onApprovalDecision = vi.fn(); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 687f5f386e85..a45b0a6868ea 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -17,7 +17,7 @@ import type { ControlUiSessionPullRequest, } from "../../../../src/gateway/control-ui-contract.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; -import type { SessionsListResult } from "../../api/types.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; import type { ExecApprovalDecision, ExecApprovalRequest } from "../../app/exec-approval.ts"; import type { QuestionPrompt } from "../../app/question-prompt.ts"; import type { ChatSendShortcut } from "../../app/settings.ts"; @@ -63,6 +63,7 @@ import type { SidebarContent, SidebarFullMessageRequest, } from "./components/chat-sidebar.ts"; +import { renderChatSwarmProgress } from "./components/chat-swarm-progress.ts"; import { renderChatTaskSuggestions } from "./components/chat-task-suggestions.ts"; import { type ChatTranscriptController, @@ -163,6 +164,7 @@ export type ChatProps = { workspaceConflict?: WorkspaceResultConflict; onDismissWorkspaceConflict?: () => void; sessions: SessionsListResult | null; + swarmSessions?: readonly GatewaySessionRow[]; /** Host context resolving global-alias session keys (scope=global fleets). */ sessionHost?: UiSessionDefaultsHost | null; providerUsage?: ProviderUsageDisplayProps; @@ -631,7 +633,12 @@ export function renderChat(props: ChatProps) { > ` : nothing} - ${scrollToBottomButton} ${chatColumnFooter} + ${scrollToBottomButton} + ${renderChatSwarmProgress({ + sessions: props.swarmSessions ?? [], + sessionKey: props.sessionKey, + })} + ${chatColumnFooter} ${renderSideChatPanel({ ...sideChatProps, // Detached slash sends are refused while disconnected (see diff --git a/ui/src/pages/chat/components/chat-swarm-progress.test.ts b/ui/src/pages/chat/components/chat-swarm-progress.test.ts new file mode 100644 index 000000000000..2442361dd647 --- /dev/null +++ b/ui/src/pages/chat/components/chat-swarm-progress.test.ts @@ -0,0 +1,156 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { afterEach, describe, expect, it } from "vitest"; +import type { GatewaySessionRow } from "../../../api/types.ts"; +import { renderChatSwarmProgress } from "./chat-swarm-progress.ts"; + +const parentSessionKey = "agent:main:parent"; + +type SwarmTestSession = GatewaySessionRow & { + swarmLog?: string; + swarmPhase?: string; + swarmPhaseRank?: number; +}; + +function session(overrides: Partial): SwarmTestSession { + return { + key: "agent:main:child", + kind: "direct", + updatedAt: 1, + parentSessionKey, + swarmGroupId: "swarm:agent:main:parent:turn-42", + ...overrides, + }; +} + +function renderProgress(sessions: readonly GatewaySessionRow[]) { + const container = document.createElement("div"); + document.body.append(container); + render(renderChatSwarmProgress({ sessionKey: parentSessionKey, sessions }), container); + return container; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("chat Swarm progress", () => { + it("groups live collector children and maps their dot states", () => { + const container = renderProgress([ + session({ key: "queued", label: "Queued child", subagentRunState: "active" }), + session({ key: "running", label: "Running child", status: "running" }), + session({ key: "done", label: "Done child", status: "done" }), + session({ key: "failed", label: "Timed out child", status: "timeout" }), + session({ + key: "finished-group", + swarmGroupId: "swarm:agent:main:parent:finished", + status: "done", + }), + ]); + + const group = container.querySelector("[data-swarm-group]"); + expect(group?.getAttribute("data-swarm-group")).toBe("swarm:agent:main:parent:turn-42"); + expect(group?.textContent).toContain("turn-42"); + expect(group?.textContent?.replace(/\s+/g, " ")).toContain("1 Running · 1 Done · 1 Failed"); + expect([...container.querySelectorAll(".chat-swarm__dot")].map((dot) => dot.className)).toEqual( + [ + "chat-swarm__dot chat-swarm__dot--queued", + "chat-swarm__dot chat-swarm__dot--running", + "chat-swarm__dot chat-swarm__dot--done", + "chat-swarm__dot chat-swarm__dot--failed", + ], + ); + }); + + it("renders every child beyond the ordinary 50-row session page", () => { + const container = renderProgress( + Array.from({ length: 55 }, (_, index) => + session({ key: `child-${index}`, status: "running" }), + ), + ); + + expect(container.querySelectorAll(".chat-swarm__dot")).toHaveLength(55); + }); + + it("caps historical dots while keeping active workers visible", () => { + const container = renderProgress([ + ...Array.from({ length: 300 }, (_, index) => + session({ key: `done-${index}`, status: "done" }), + ), + session({ key: "running", status: "running" }), + ]); + + expect(container.querySelectorAll(".chat-swarm__dot")).toHaveLength(256); + expect(container.querySelector(".chat-swarm__dot--running")).not.toBeNull(); + expect(container.querySelector(".chat-swarm__more")?.textContent?.trim()).toBe("+45"); + expect(container.textContent?.replace(/\s+/g, " ")).toContain("1 Running · 300 Done"); + }); + + it("labels dot states and disappears when no group is active", () => { + const container = renderProgress([session({ label: "Worker A", status: "running" })]); + + const widget = container.querySelector("[data-test-id=chat-swarm]"); + const dot = container.querySelector(".chat-swarm__dot--running"); + expect(widget?.getAttribute("role")).toBe("status"); + expect(widget?.getAttribute("aria-live")).toBe("off"); + expect(dot?.title).toBe("Worker A: Running"); + + render(renderChatSwarmProgress({ sessionKey: parentSessionKey, sessions: [] }), container); + expect(container.querySelector("[data-test-id=chat-swarm]")).toBeNull(); + }); + + it("buckets children by phase, labels the default bucket, and shows the latest log", () => { + const container = renderProgress([ + session({ key: "unphased", label: "Older child", status: "running" }), + session({ key: "planning", label: "Planner", status: "done", swarmPhase: "Plan" }), + session({ + key: "building", + label: "Builder", + subagentRunState: "active", + swarmPhase: "Build", + swarmLog: "Implementing the selected plan.", + }), + ]); + + expect( + [...container.querySelectorAll(".chat-swarm__phase")].map((phase) => + phase.textContent?.trim(), + ), + ).toEqual(["Unphased", "Plan", "Build"]); + expect( + [...container.querySelectorAll(".chat-swarm__phase-row")].map( + (row) => row.querySelectorAll(".chat-swarm__dot").length, + ), + ).toEqual([1, 1, 1]); + expect(container.querySelector(".chat-swarm__narrator")?.textContent).toContain( + "Implementing the selected plan.", + ); + }); + + it("orders phase buckets by observation rank, not canonical row order", () => { + const container = renderProgress([ + session({ + key: "builder", + label: "Builder", + status: "running", + swarmPhase: "Build", + swarmPhaseRank: 1, + }), + session({ key: "late-unphased", label: "Late child", status: "running" }), + session({ + key: "planner", + label: "Planner", + status: "done", + swarmPhase: "Plan", + swarmPhaseRank: 0, + }), + ]); + + expect( + [...container.querySelectorAll(".chat-swarm__phase")].map((phase) => + phase.textContent?.trim(), + ), + ).toEqual(["Plan", "Build", "Unphased"]); + }); +}); diff --git a/ui/src/lib/board/widgets/swarm.ts b/ui/src/pages/chat/components/chat-swarm-progress.ts similarity index 83% rename from ui/src/lib/board/widgets/swarm.ts rename to ui/src/pages/chat/components/chat-swarm-progress.ts index fb3d230b94c0..df1fa7e1ee88 100644 --- a/ui/src/lib/board/widgets/swarm.ts +++ b/ui/src/pages/chat/components/chat-swarm-progress.ts @@ -1,11 +1,12 @@ import { html, nothing, type TemplateResult } from "lit"; import type { GatewaySessionRow } from "../../../api/types.ts"; import { t } from "../../../i18n/index.ts"; -import { areUiSessionKeysEquivalent } from "../../sessions/session-key.ts"; +import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts"; type SwarmDotStatus = "queued" | "running" | "done" | "failed"; const SWARM_DOT_STATUS_RANK = { running: 0, queued: 1, failed: 2, done: 3 } as const; +const MAX_RENDERED_DOTS_PER_PHASE = 256; type SwarmDot = { key: string; @@ -13,8 +14,6 @@ type SwarmDot = { status: SwarmDotStatus; }; -const MAX_RENDERED_DOTS_PER_PHASE = 256; - type SwarmPhase = { title?: string; dots: SwarmDot[]; @@ -69,7 +68,6 @@ function groupTail(groupId: string): string { function swarmPhaseRank(row: GatewaySessionRow): number { const rank = (row as GatewaySessionRow & SwarmPhaseCarrier).swarmPhaseRank; - // Unranked (unphased or pre-rank) buckets sort after announced phases. return typeof rank === "number" && Number.isFinite(rank) ? rank : Number.MAX_SAFE_INTEGER; } @@ -94,7 +92,6 @@ function isSwarmChildForSession(row: GatewaySessionRow, sessionKey: string): boo return Boolean(owner && areUiSessionKeysEquivalent(owner, sessionKey)); } -/** Groups the live session roster into the active collector swarms for one dashboard. */ function collectActiveSwarmGroups( sessions: readonly GatewaySessionRow[], sessionKey: string, @@ -108,12 +105,12 @@ function collectActiveSwarmGroups( if (!groupId || !isSwarmChildForSession(row, sessionKey)) { continue; } - const dots = byGroup.get(groupId) ?? []; const status = swarmDotStatus(row); if (!status) { continue; } - dots.push({ + const entries = byGroup.get(groupId) ?? []; + entries.push({ phase: swarmPhase(row), phaseRank: swarmPhaseRank(row), log: swarmLog(row), @@ -123,7 +120,7 @@ function collectActiveSwarmGroups( status, }, }); - byGroup.set(groupId, dots); + byGroup.set(groupId, entries); } return [...byGroup.entries()] @@ -169,45 +166,49 @@ function collectActiveSwarmGroups( .toSorted((left, right) => left.groupId.localeCompare(right.groupId)); } -export function renderSwarmWidget({ +export function renderChatSwarmProgress({ sessions, sessionKey, }: { sessions: readonly GatewaySessionRow[]; sessionKey: string; -}): TemplateResult { +}): TemplateResult | typeof nothing { const groups = collectActiveSwarmGroups(sessions, sessionKey); if (groups.length === 0) { - return html`

- ${t("labsPage.swarm.empty")} -

`; + return nothing; } return html` -
+
${group.narrator - ? html`
${group.narrator}
` + ? html`
${group.narrator}
` : nothing} ${group.phases.map( (phase) => html` -
-
+
+
${phase.title ?? t("labsPage.swarm.defaultPhase")}
-
+
${phase.dots.map( (dot) => html` 0 - ? html`+${phase.hidden}` + ? html`+${phase.hidden}` : nothing}
`, )} - +
`, )} -
+ `; } diff --git a/ui/src/styles/board.css b/ui/src/styles/board.css index 6d539f101488..32469bba6190 100644 --- a/ui/src/styles/board.css +++ b/ui/src/styles/board.css @@ -314,99 +314,6 @@ openclaw-board-widget-cell { width: 100%; } -.swarm-widget { - display: grid; - gap: 8px; - padding: 10px; -} - -.swarm-widget__empty { - color: var(--muted, #8a919e); - font-size: 12px; - margin: 0; - padding: 12px; -} - -.swarm-widget__group { - background: color-mix(in srgb, var(--text, #d7dae0) 3%, transparent); - border: 1px solid var(--board-line); - border-radius: 8px; - display: grid; - gap: 7px; - padding: 8px; -} - -.swarm-widget__group-header { - align-items: baseline; - display: flex; - gap: 8px; - min-width: 0; -} - -.swarm-widget__group-header strong { - color: var(--text, #d7dae0); - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.swarm-widget__group-header span, -.swarm-widget__narrator, -.swarm-widget__phase { - color: var(--muted, #8a919e); - font-size: 10px; -} - -.swarm-widget__narrator { - line-height: 1.35; -} - -.swarm-widget__phase-row { - align-items: center; - display: grid; - gap: 8px; - grid-template-columns: minmax(56px, auto) 1fr; -} - -.swarm-widget__phase { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.swarm-widget__dots { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.swarm-widget__dot { - background: var(--muted, #8a919e); - border-radius: 50%; - height: 9px; - width: 9px; -} - -.swarm-widget__more { - color: var(--muted, #8a919e); - font-size: 10px; - line-height: 9px; -} - -.swarm-widget__dot--running { - animation: swarm-widget-pulse 1.25s ease-in-out infinite; - background: var(--accent, #ff5c5c); -} - -.swarm-widget__dot--done { - background: var(--success, #45c486); -} - -.swarm-widget__dot--failed { - background: var(--danger, #ff6b6b); -} - .observer-widget { box-sizing: border-box; display: grid; @@ -770,13 +677,6 @@ openclaw-workboard-mini-widget { justify-content: flex-start; } -@keyframes swarm-widget-pulse { - 50% { - box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #ff5c5c) 25%, transparent); - opacity: 0.55; - } -} - .board-widget__mcp-app { display: grid; min-height: 100%; @@ -1108,8 +1008,7 @@ openclaw-workboard-mini-widget { .board-widget, .board-widget__bar, .board-widget__resize-handle, - .board-tabs__tab, - .swarm-widget__dot--running { + .board-tabs__tab { animation: none; transition: none; } diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index e406bffcc05f..90c1bd6c2596 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -1536,6 +1536,124 @@ openclaw-chat-page { } } +.chat-swarm { + display: grid; + gap: 6px; + width: calc(100% - 36px); + max-width: var(--chat-thread-max-width, 48rem); + /* Completed phases stay visible while any child runs; bound large swarms so + their retained dots cannot displace the composer. */ + max-height: clamp(160px, 32vh, 260px); + margin: 0 auto; + padding: 0 2px 8px 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.chat-swarm__group { + display: grid; + gap: 6px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--accent) 3%, var(--panel)); + box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 5%, transparent); +} + +.chat-swarm__header { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.chat-swarm__header strong { + overflow: hidden; + color: var(--text); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-swarm__header span, +.chat-swarm__narrator, +.chat-swarm__phase { + color: var(--muted); + font-size: 10px; +} + +.chat-swarm__narrator { + overflow: hidden; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-swarm__phase-row { + display: grid; + grid-template-columns: minmax(56px, auto) 1fr; + align-items: center; + gap: 8px; +} + +.chat-swarm__phase { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-swarm__dots { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chat-swarm__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--muted); +} + +.chat-swarm__dot--queued { + border: 1px solid var(--muted); + background: transparent; +} + +.chat-swarm__dot--running { + animation: chat-swarm-pulse 1.25s ease-in-out infinite; + background: var(--accent); +} + +.chat-swarm__dot--done { + background: var(--ok); +} + +.chat-swarm__dot--failed { + border-radius: 3px; + background: var(--danger); + transform: rotate(45deg) scale(0.82); +} + +.chat-swarm__more { + color: var(--muted); + font-size: 10px; + line-height: 9px; +} + +@keyframes chat-swarm-pulse { + 50% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 25%, transparent); + opacity: 0.55; + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-swarm__dot--running { + animation: none; + } +} + .chat-prs { display: grid; gap: 6px;