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
This commit is contained in:
Peter Steinberger
2026-07-25 04:36:50 -07:00
committed by GitHub
parent a653741c5b
commit 421e287c45
26 changed files with 515 additions and 564 deletions

View File

@@ -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

View File

@@ -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<ControlUiMockGatewayScenario> {
async function createChatPickerScenario(
fixture?: CliOptions["fixture"],
): Promise<ControlUiMockGatewayScenario> {
const baseTime = Date.parse("2026-05-22T09:00:00.000Z");
const selfProfile: UserProfile = {
id: "presence-riley",
@@ -1082,10 +1085,61 @@ async function createChatPickerScenario(): Promise<ControlUiMockGatewayScenario>
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<ControlUiMockGatewayScenario>
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<ControlUiMockGatewayScenario>
},
],
},
"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<ControlUiMockGatewayScenario>
// 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<void> {
}
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"),

View File

@@ -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<RouteId>;
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) {

View File

@@ -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),

View File

@@ -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}

View File

@@ -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,
});
}

View File

@@ -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);
}

View File

@@ -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<string, GatewaySessionRow>();
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<GatewaySessionRow[] | null> {
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;
}

View File

@@ -12,7 +12,7 @@ type BoardStoredWidget = BoardWidget & {
readOnly?: false | undefined;
};
type BoardBuiltinWidget = Omit<BoardWidget, "contentKind"> & {
builtin: "observer" | "swarm";
builtin: "observer";
contentKind: "builtin";
readOnly: true;
};

View File

@@ -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<string, Promise<PluginBoardWidgetRenderer
const BUILTIN_WIDGET_RENDERERS: Record<string, BuiltinBoardWidgetRenderer> = {
observer: renderObserverWidget,
swarm: renderSwarmWidget,
};
export function getBuiltinWidgetRenderer(

View File

@@ -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>): 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<HTMLElement>(".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"]);
});
});

View File

@@ -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 {

View File

@@ -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<string, GatewaySessionRow>();
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<GatewaySessionRow[] | null> {
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 };
}

View File

@@ -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 },

View File

@@ -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}

View File

@@ -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<void>;
protected abstract refreshSwarmRoster(): void;
protected abstract refreshBuiltinBoardSnapshot(): void;
protected abstract resolveBoardProvider(): BoardProvider;
protected abstract handleBoardCommand(event: BoardCommandEvent): void;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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(

View File

@@ -719,6 +719,38 @@ function createDeferred<T>() {
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();

View File

@@ -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) {
></openclaw-chat-observer-hud>
`
: nothing}
${scrollToBottomButton} ${chatColumnFooter}
${scrollToBottomButton}
${renderChatSwarmProgress({
sessions: props.swarmSessions ?? [],
sessionKey: props.sessionKey,
})}
${chatColumnFooter}
${renderSideChatPanel({
...sideChatProps,
// Detached slash sends are refused while disconnected (see

View File

@@ -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>): 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<HTMLElement>(".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"]);
});
});

View File

@@ -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`<p class="swarm-widget__empty" data-test-id="swarm-empty">
${t("labsPage.swarm.empty")}
</p>`;
return nothing;
}
return html`
<div class="swarm-widget" data-test-id="swarm-widget">
<aside
class="chat-swarm"
data-test-id="chat-swarm"
role="status"
aria-live="off"
aria-label=${t("labsPage.swarm.title")}
>
${groups.map(
(group) => html`
<section class="swarm-widget__group" data-swarm-group=${group.groupId}>
<header class="swarm-widget__group-header">
<div class="chat-swarm__group" data-swarm-group=${group.groupId}>
<div class="chat-swarm__header">
<strong title=${group.groupId}>${group.label}</strong>
<span
>${group.running} ${swarmStatusLabel("running")} · ${group.done}
${swarmStatusLabel("done")} · ${group.failed} ${swarmStatusLabel("failed")}</span
>
</header>
</div>
${group.narrator
? html`<div class="swarm-widget__narrator">${group.narrator}</div>`
? html`<div class="chat-swarm__narrator">${group.narrator}</div>`
: nothing}
${group.phases.map(
(phase) => html`
<div class="swarm-widget__phase-row">
<div class="swarm-widget__phase">
<div class="chat-swarm__phase-row">
<div class="chat-swarm__phase">
${phase.title ?? t("labsPage.swarm.defaultPhase")}
</div>
<div class="swarm-widget__dots" role="list">
<div class="chat-swarm__dots" role="list">
${phase.dots.map(
(dot) => html`
<span
class=${`swarm-widget__dot swarm-widget__dot--${dot.status}`}
class=${`chat-swarm__dot chat-swarm__dot--${dot.status}`}
role="listitem"
title=${`${dot.label}: ${swarmStatusLabel(dot.status)}`}
aria-label=${`${dot.label}: ${swarmStatusLabel(dot.status)}`}
@@ -215,17 +216,15 @@ export function renderSwarmWidget({
`,
)}
${phase.hidden > 0
? html`<span class="swarm-widget__more" role="listitem"
>+${phase.hidden}</span
>`
? html`<span class="chat-swarm__more" role="listitem">+${phase.hidden}</span>`
: nothing}
</div>
</div>
`,
)}
</section>
</div>
`,
)}
</div>
</aside>
`;
}

View File

@@ -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;
}

View File

@@ -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;