mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:41:37 +00:00
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:
committed by
GitHub
parent
a653741c5b
commit
421e287c45
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ type BoardStoredWidget = BoardWidget & {
|
||||
readOnly?: false | undefined;
|
||||
};
|
||||
type BoardBuiltinWidget = Omit<BoardWidget, "contentKind"> & {
|
||||
builtin: "observer" | "swarm";
|
||||
builtin: "observer";
|
||||
contentKind: "builtin";
|
||||
readOnly: true;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
156
ui/src/pages/chat/components/chat-swarm-progress.test.ts
Normal file
156
ui/src/pages/chat/components/chat-swarm-progress.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user