feat(ui): render native Workboard widgets

This commit is contained in:
Peter Steinberger
2026-07-20 17:49:25 -07:00
parent 79b5c0fe6b
commit 8b9ebb965a
12 changed files with 1032 additions and 3 deletions
+1
View File
@@ -124,6 +124,7 @@ function isTrustedRetryEndpoint(url: string): boolean {
}
export type GatewayControlUiPluginTab = NonNullable<HelloOk["controlUiTabs"]>[number];
export type GatewayControlUiPluginWidgetKind = NonNullable<HelloOk["controlUiWidgetKinds"]>[number];
export type GatewayHelloOk = Omit<HelloOk, "server" | "features" | "snapshot" | "policy"> & {
server?: Partial<HelloOk["server"]>;
features?: Partial<HelloOk["features"]>;
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BoardViewWidget } from "../../lib/board/view-types.ts";
import type { BoardWidgetCellCallbacks } from "./board-widget-cell.ts";
import "./board-widget-cell.ts";
function callbacks(): BoardWidgetCellCallbacks {
const noAction = vi.fn(async () => undefined);
return {
grant: noAction,
movePointerDown: vi.fn(),
resizePointerDown: vi.fn(),
moveToTab: noAction,
resizeTo: noAction,
remove: noAction,
nudge: noAction,
focus: vi.fn(),
focusChanged: vi.fn(),
frameLoadFailed: noAction,
widgetAppView: vi.fn(async () => ({ status: "stale" as const, error: "unused" })),
refreshWidgetAppView: vi.fn(async () => ({ status: "stale" as const, error: "unused" })),
};
}
afterEach(() => {
document.body.replaceChildren();
});
describe("plugin board widget cells", () => {
it("renders a removable placeholder when the owning plugin is inactive", async () => {
const widget: BoardViewWidget = {
name: "work-item",
tabId: "main",
title: "Work item",
contentKind: "plugin",
pluginKind: "workboard:card",
props: { cardId: "card-123" },
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none",
revision: 1,
};
const cellCallbacks = callbacks();
const cell = document.createElement("openclaw-board-widget-cell");
cell.widget = widget;
cell.rect = { name: widget.name, x: 0, y: 0, w: 6, h: 4 };
cell.sessionKey = "agent:main:test";
cell.callbacks = cellCallbacks;
document.body.append(cell);
await cell.updateComplete;
const placeholder = cell.querySelector('[data-test-id="board-disabled-plugin"]');
expect(placeholder?.textContent).toContain("Widget from disabled plugin workboard");
const removeButton = placeholder?.querySelector("button");
expect(removeButton).not.toBeNull();
removeButton?.click();
await vi.waitFor(() => expect(cellCallbacks.remove).toHaveBeenCalledWith(widget));
});
});
@@ -98,6 +98,26 @@ export function renderBoardWidgetRejected(options: {
`;
}
export function renderBoardDisabledPlugin(options: {
pluginId: string;
disabled: boolean;
onRemove: () => void;
}): TemplateResult {
return html`
<div class="board-widget__disabled-plugin" data-test-id="board-disabled-plugin">
<strong>${t("board.widget.disabledPlugin", { pluginId: options.pluginId })}</strong>
<button
class="btn btn--small"
type="button"
?disabled=${options.disabled}
@click=${options.onRemove}
>
${t("board.widget.remove")}
</button>
</div>
`;
}
export function renderBoardWidgetError(error: unknown): TemplateResult {
const message = error instanceof Error ? error.message : String(error);
return html`
+87 -3
View File
@@ -14,7 +14,13 @@ import type {
BoardViewWidget,
BoardWidgetFrameUrl,
} from "../../lib/board/view-types.ts";
import { getBuiltinWidgetRenderer } from "../../lib/board/widgets/index.ts";
import {
getBuiltinWidgetRenderer,
getPluginWidgetKindContribution,
loadPluginWidgetRenderer,
pluginIdForWidgetKind,
type PluginBoardWidgetRenderer,
} from "../../lib/board/widgets/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { renderBoardMcpAppContent } from "./board-mcp-app-content.ts";
import { BoardMcpAppLifecycle } from "./board-mcp-app-lifecycle.ts";
@@ -22,6 +28,7 @@ import { renderBoardGrantedCapabilities } from "./board-widget-capabilities.ts";
import {
BOARD_SIZE_PRESETS,
closeBoardWidgetMenu,
renderBoardDisabledPlugin,
renderBoardWidgetActionError,
renderBoardWidgetError,
renderBoardWidgetMenu,
@@ -70,6 +77,11 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@state() private actionError = "";
@state() private actionPending = false;
@state() private pluginRenderer: PluginBoardWidgetRenderer | null = null;
@state() private pluginRendererError = "";
@state() private pluginRendererLabel = "";
private pluginRendererKind = "";
private pluginRendererLoadToken: object | null = null;
private readonly appView = new BoardMcpAppLifecycle({
connected: () => this.isConnected,
requestUpdate: () => this.requestUpdate(),
@@ -99,6 +111,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
this.frame.widgetChanged(previousWidget, this.widget);
}
this.appView.update(this.widget, this.callbacks);
this.syncPluginRenderer();
}
override updated(): void {
@@ -119,6 +132,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
override disconnectedCallback(): void {
this.resetPluginRenderer();
this.frame.disconnect();
this.appView.disconnect();
super.disconnectedCallback();
@@ -232,9 +246,76 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
return renderer({ sessions: this.sessions, sessionKey: this.sessionKey });
}
if (widget.contentKind === "plugin") {
if (this.pluginRendererError) {
return renderBoardWidgetError(this.pluginRendererError);
}
if (this.pluginRenderer) {
return this.pluginRenderer({
widget,
sessionKey: this.sessionKey,
requestUpdate: () => this.requestUpdate(),
});
}
const pluginId = pluginIdForWidgetKind(widget.pluginKind);
const activeKinds = this.context?.gateway.snapshot.hello?.controlUiWidgetKinds ?? [];
const contribution = getPluginWidgetKindContribution(widget.pluginKind, activeKinds);
return contribution
? html`<p class="board-widget__plugin-loading">${t("board.widget.pluginLoading")}</p>`
: renderBoardDisabledPlugin({
pluginId,
disabled: this.busy || this.actionPending || !this.canMutate,
onRemove: () => void this.runAction(() => callbacks.remove(widget)),
});
}
return this.frame.render(widget);
}
private syncPluginRenderer(): void {
const widget = this.widget;
const activeKinds = this.context?.gateway.snapshot.hello?.controlUiWidgetKinds ?? [];
const contribution =
widget?.contentKind === "plugin"
? getPluginWidgetKindContribution(widget.pluginKind, activeKinds)
: null;
if (!contribution) {
if (this.pluginRendererKind || this.pluginRenderer || this.pluginRendererError) {
this.resetPluginRenderer();
}
return;
}
if (this.pluginRendererKind === contribution.kind) {
return;
}
const loadToken = {};
this.pluginRendererKind = contribution.kind;
this.pluginRendererLabel = contribution.label;
this.pluginRenderer = null;
this.pluginRendererError = "";
this.pluginRendererLoadToken = loadToken;
void loadPluginWidgetRenderer(contribution)
.then((renderer) => {
if (this.pluginRendererLoadToken === loadToken) {
this.pluginRenderer = renderer;
this.requestUpdate();
}
})
.catch((error: unknown) => {
if (this.pluginRendererLoadToken === loadToken) {
this.pluginRendererError = error instanceof Error ? error.message : String(error);
this.requestUpdate();
}
});
}
private resetPluginRenderer(): void {
this.pluginRendererLoadToken = null;
this.pluginRendererKind = "";
this.pluginRendererLabel = "";
this.pluginRenderer = null;
this.pluginRendererError = "";
}
private handleKeyDown(
event: KeyboardEvent,
widget: BoardViewWidget,
@@ -289,7 +370,8 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
this.actionError !== "" ||
widget.grantState === "pending" ||
widget.grantState === "rejected";
const contentScrollable = bodyScrollable || widget.contentKind === "mcp-app";
const contentScrollable =
bodyScrollable || widget.contentKind === "mcp-app" || widget.contentKind === "plugin";
return html`
<section
class=${`board-widget ${this.dragging ? "board-widget--dragging" : ""}`}
@@ -321,7 +403,9 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
: html`<span class="board-widget__kind"
>${widget.contentKind === "mcp-app"
? t("board.widget.kindMcp")
: t("board.widget.kindHtml")}</span
: widget.contentKind === "plugin"
? this.pluginRendererLabel || t("board.widget.kindPlugin")
: t("board.widget.kindHtml")}</span
>`}
${widget.contentKind === "builtin" ? nothing : renderBoardGrantedCapabilities(widget)}
${readOnly
+14
View File
@@ -2640,9 +2640,23 @@ export const en: TranslationMap = {
errorShow: "Show details",
kindMcp: "MCP",
kindHtml: "HTML",
kindPlugin: "Plugin",
pluginLoading: "Loading plugin widget…",
disabledPlugin: "Widget from disabled plugin {pluginId}",
},
},
workboard: {
widget: {
cardLabel: "Workboard card",
summaryLabel: "Workboard summary",
loading: "Loading Workboard…",
cardIdRequired: "This widget needs a cardId prop.",
cardMissing: "This Workboard card is no longer available.",
unassigned: "Unassigned",
openBoard: "Open board",
statusCounts: "Cards by status",
noActiveCards: "No ready or running cards.",
},
disabledHelpStart: "Workboard is disabled. Enable",
enableConfigKey: "plugins.entries.workboard.enabled = true",
disabledHelpEnd: ", then reload this tab.",
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
getPluginWidgetKindContribution,
loadPluginWidgetRenderer,
pluginIdForWidgetKind,
type PluginBoardWidgetRenderer,
} from "./index.ts";
describe("plugin board widget registry", () => {
it("resolves only advertised first-party kinds", () => {
const active = [{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" }];
expect(getPluginWidgetKindContribution("workboard:card", active)).toMatchObject({
kind: "workboard:card",
label: "Workboard card",
loader: expect.any(Function),
});
expect(getPluginWidgetKindContribution("workboard:mini", active)).toBeNull();
expect(getPluginWidgetKindContribution("unknown:card", active)).toBeNull();
expect(pluginIdForWidgetKind("workboard:card")).toBe("workboard");
});
it("retries a renderer whose lazy import failed", async () => {
const renderer: PluginBoardWidgetRenderer = () => ({}) as never;
await expect(
loadPluginWidgetRenderer({
kind: "test:retry",
label: "Retry",
loader: async () => await Promise.reject(new Error("chunk unavailable")),
}),
).rejects.toThrow("chunk unavailable");
await expect(
loadPluginWidgetRenderer({
kind: "test:retry",
label: "Retry",
loader: async () => renderer,
}),
).resolves.toBe(renderer);
});
});
+73
View File
@@ -1,5 +1,8 @@
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 { renderSwarmWidget } from "./swarm.ts";
type BuiltinBoardWidgetRenderer = (context: {
@@ -7,6 +10,38 @@ type BuiltinBoardWidgetRenderer = (context: {
sessionKey: string;
}) => TemplateResult;
export type PluginBoardWidgetRenderer = (props: {
widget: BoardViewWidget;
sessionKey: string;
requestUpdate: () => void;
}) => TemplateResult;
export type PluginWidgetKindContribution = {
kind: string;
label: string;
loader: () => Promise<PluginBoardWidgetRenderer>;
};
/**
* Plugin renderers are trusted first-party Control UI code. They render in the
* cell without an iframe or grants, receive only widget/session/update props,
* and use the standard gateway client for RPCs owned by their plugin.
*/
const PLUGIN_WIDGET_KIND_CONTRIBUTIONS: Record<string, PluginWidgetKindContribution> = {
"workboard:card": {
kind: "workboard:card",
label: t("workboard.widget.cardLabel"),
loader: async () => (await import("./workboard-card.ts")).renderWorkboardCardWidget,
},
"workboard:mini": {
kind: "workboard:mini",
label: t("workboard.widget.summaryLabel"),
loader: async () => (await import("./workboard-mini.ts")).renderWorkboardMiniWidget,
},
};
const pluginRendererPromises = new Map<string, Promise<PluginBoardWidgetRenderer>>();
const BUILTIN_WIDGET_RENDERERS: Record<string, BuiltinBoardWidgetRenderer> = {
swarm: renderSwarmWidget,
};
@@ -16,3 +51,41 @@ export function getBuiltinWidgetRenderer(
): BuiltinBoardWidgetRenderer | null {
return name ? (BUILTIN_WIDGET_RENDERERS[name] ?? null) : null;
}
export function pluginIdForWidgetKind(kind: string | undefined): string {
return kind?.split(":", 1)[0]?.trim() || "unknown";
}
export function getPluginWidgetKindContribution(
kind: string | undefined,
activeKinds: readonly GatewayControlUiPluginWidgetKind[],
): PluginWidgetKindContribution | null {
if (!kind) {
return null;
}
const contribution = PLUGIN_WIDGET_KIND_CONTRIBUTIONS[kind];
if (!contribution) {
return null;
}
const pluginId = pluginIdForWidgetKind(kind);
return activeKinds.some((entry) => entry.kind === kind && entry.pluginId === pluginId)
? contribution
: null;
}
export function loadPluginWidgetRenderer(
contribution: PluginWidgetKindContribution,
): Promise<PluginBoardWidgetRenderer> {
const existing = pluginRendererPromises.get(contribution.kind);
if (existing) {
return existing;
}
const loaded = contribution.loader();
pluginRendererPromises.set(contribution.kind, loaded);
void loaded.catch(() => {
if (pluginRendererPromises.get(contribution.kind) === loaded) {
pluginRendererPromises.delete(contribution.kind);
}
});
return loaded;
}
+107
View File
@@ -0,0 +1,107 @@
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../../../i18n/index.ts";
import type { WorkboardStatus } from "../../workboard/types.ts";
import type { BoardViewWidget } from "../view-types.ts";
import type { PluginBoardWidgetRenderer } from "./index.ts";
import { WorkboardWidgetElement } from "./workboard-widget.ts";
class OpenClawWorkboardCardWidget extends WorkboardWidgetElement {
private async handleStatusChange(event: Event): Promise<void> {
const cardId = this.readStringProp("cardId");
const card = this.cards.find((candidate) => candidate.id === cardId);
const status = (event.currentTarget as HTMLSelectElement).value;
if (!card || !this.statuses.includes(status as WorkboardStatus)) {
return;
}
await this.moveCard(card, status as WorkboardStatus);
}
override render(): TemplateResult {
const cardId = this.readStringProp("cardId");
if (!cardId) {
return html`<p class="workboard-widget__state" role="alert">
${t("workboard.widget.cardIdRequired")}
</p>`;
}
if (this.loading && !this.loaded) {
return html`<p class="workboard-widget__state">${t("workboard.widget.loading")}</p>`;
}
if (this.error) {
return html`<p class="workboard-widget__state" role="alert">${this.error}</p>`;
}
const card = this.cards.find((candidate) => candidate.id === cardId);
if (!card) {
return html`<p class="workboard-widget__state">${t("workboard.widget.cardMissing")}</p>`;
}
const statuses = this.statuses.includes(card.status)
? this.statuses
: [card.status, ...this.statuses];
const priority = card.priority.charAt(0).toUpperCase() + card.priority.slice(1);
return html`
<article class="workboard-widget-card" data-test-id="workboard-card-widget">
<div class="workboard-widget-card__heading">
<strong>${card.title}</strong>
<span class=${`workboard-widget__status workboard-widget__status--${card.status}`}>
${t(`workboard.status.${card.status}`)}
</span>
</div>
<dl class="workboard-widget-card__meta">
<div>
<dt>${t("workboard.fieldPriority")}</dt>
<dd>${priority}</dd>
</div>
<div>
<dt>${t("workboard.fieldAgent")}</dt>
<dd>${card.agentId ?? t("workboard.widget.unassigned")}</dd>
</div>
</dl>
${statuses.length > 1
? html`
<label class="workboard-widget-card__move">
<span>${t("workboard.fieldStatus")}</span>
<select
aria-label=${`${t("workboard.fieldStatus")}: ${card.title}`}
.value=${card.status}
@change=${(event: Event) => void this.handleStatusChange(event)}
>
${statuses.map(
(status) => html`
<option value=${status} ?selected=${status === card.status}>
${t(`workboard.status.${status}`)}
</option>
`,
)}
</select>
</label>
`
: nothing}
</article>
`;
}
}
if (!customElements.get("openclaw-workboard-card-widget")) {
customElements.define("openclaw-workboard-card-widget", OpenClawWorkboardCardWidget);
}
export const renderWorkboardCardWidget: PluginBoardWidgetRenderer = ({
widget,
sessionKey,
requestUpdate,
}: {
widget: BoardViewWidget;
sessionKey: string;
requestUpdate: () => void;
}) => html`
<openclaw-workboard-card-widget
.widget=${widget}
.sessionKey=${sessionKey}
.hostRequestUpdate=${requestUpdate}
></openclaw-workboard-card-widget>
`;
declare global {
interface HTMLElementTagNameMap {
"openclaw-workboard-card-widget": OpenClawWorkboardCardWidget;
}
}
@@ -0,0 +1,95 @@
import { html, type TemplateResult } from "lit";
import { pathForRoute } from "../../../app-route-paths.ts";
import { t } from "../../../i18n/index.ts";
import { WORKBOARD_STATUSES, type WorkboardCard } from "../../workboard/types.ts";
import type { BoardViewWidget } from "../view-types.ts";
import type { PluginBoardWidgetRenderer } from "./index.ts";
import { WorkboardWidgetElement } from "./workboard-widget.ts";
function cardBoardId(card: WorkboardCard): string {
return card.metadata?.automation?.boardId ?? "default";
}
class OpenClawWorkboardMiniWidget extends WorkboardWidgetElement {
override render(): TemplateResult {
if (this.loading && !this.loaded) {
return html`<p class="workboard-widget__state">${t("workboard.widget.loading")}</p>`;
}
if (this.error) {
return html`<p class="workboard-widget__state" role="alert">${this.error}</p>`;
}
const boardId = this.readStringProp("boardId") ?? "default";
const limit = Math.min(10, this.readPositiveIntegerProp("limit", 5));
const cards = this.cards.filter((card) => cardBoardId(card) === boardId);
const topCards = cards
.filter((card) => card.status === "ready" || card.status === "running")
.toSorted(
(left, right) =>
Number(right.status === "running") - Number(left.status === "running") ||
left.position - right.position ||
left.title.localeCompare(right.title),
)
.slice(0, limit);
const workboardPath = `${pathForRoute("workboard", this.context?.basePath ?? "")}?board=${encodeURIComponent(boardId)}`;
return html`
<section class="workboard-widget-mini" data-test-id="workboard-mini-widget">
<header>
<strong>${boardId}</strong>
<a href=${workboardPath}>${t("workboard.widget.openBoard")}</a>
</header>
<div class="workboard-widget-mini__counts" aria-label=${t("workboard.widget.statusCounts")}>
${WORKBOARD_STATUSES.map(
(status) => html`
<span title=${t(`workboard.status.${status}`)}>
<b>${cards.filter((card) => card.status === status).length}</b>
${t(`workboard.status.${status}`)}
</span>
`,
)}
</div>
<div class="workboard-widget-mini__cards">
${topCards.length > 0
? topCards.map(
(card) => html`
<div class="workboard-widget-mini__card">
<span
class=${`workboard-widget__status workboard-widget__status--${card.status}`}
>
${t(`workboard.status.${card.status}`)}
</span>
<strong>${card.title}</strong>
</div>
`,
)
: html`<p class="workboard-widget__state">${t("workboard.widget.noActiveCards")}</p>`}
</div>
</section>
`;
}
}
if (!customElements.get("openclaw-workboard-mini-widget")) {
customElements.define("openclaw-workboard-mini-widget", OpenClawWorkboardMiniWidget);
}
export const renderWorkboardMiniWidget: PluginBoardWidgetRenderer = ({
widget,
sessionKey,
requestUpdate,
}: {
widget: BoardViewWidget;
sessionKey: string;
requestUpdate: () => void;
}) => html`
<openclaw-workboard-mini-widget
.widget=${widget}
.sessionKey=${sessionKey}
.hostRequestUpdate=${requestUpdate}
></openclaw-workboard-mini-widget>
`;
declare global {
interface HTMLElementTagNameMap {
"openclaw-workboard-mini-widget": OpenClawWorkboardMiniWidget;
}
}
@@ -0,0 +1,195 @@
import { consume } from "@lit/context";
import { property } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../../api/gateway.ts";
import { applicationContext, type ApplicationContext } from "../../../app/context.ts";
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../../lit/subscriptions-controller.ts";
import { moveWorkboardCard } from "../../workboard/mutations.ts";
import { normalizeCardsPayload } from "../../workboard/normalization.ts";
import { getWorkboardState, type WorkboardHost } from "../../workboard/runtime.ts";
import {
WORKBOARD_CHANGED_EVENT,
type WorkboardCard,
type WorkboardStatus,
} from "../../workboard/types.ts";
import type { BoardViewWidget } from "../view-types.ts";
export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
protected context?: ApplicationContext;
@property({ attribute: false }) widget?: BoardViewWidget;
@property({ attribute: false }) sessionKey = "";
@property({ attribute: false }) hostRequestUpdate?: () => void;
protected cards: WorkboardCard[] = [];
protected statuses: readonly WorkboardStatus[] = [];
protected loading = false;
protected loaded = false;
protected error = "";
private loadAttempted = false;
private readonly workboardHost: WorkboardHost = {};
private client: GatewayBrowserClient | null = null;
private refreshGeneration = 0;
private refreshPromise: Promise<void> | null = null;
private refreshPending = false;
private readonly subscriptions = new SubscriptionsController(this).effect(
() => this.context?.gateway,
(gateway) => {
const sync = () => this.syncGateway(gateway.snapshot);
sync();
const unsubscribeSnapshot = gateway.subscribe(sync);
const unsubscribeEvents = gateway.subscribeEvents((event) => {
if (event.event === WORKBOARD_CHANGED_EVENT && gateway.snapshot.connected) {
void this.refresh(true);
}
});
return () => {
unsubscribeSnapshot();
unsubscribeEvents();
};
},
);
override connectedCallback(): void {
super.connectedCallback();
this.syncGateway(this.context?.gateway.snapshot);
}
override updated(): void {
if (!this.loadAttempted && !this.loading) {
void this.refresh();
}
}
override disconnectedCallback(): void {
this.refreshGeneration += 1;
this.refreshPromise = null;
this.refreshPending = false;
this.subscriptions.clear();
super.disconnectedCallback();
}
protected readStringProp(key: string): string | undefined {
const value = this.widget?.props?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
protected readPositiveIntegerProp(key: string, fallback: number): number {
const value = this.widget?.props?.[key];
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
}
protected async moveCard(card: WorkboardCard, status: WorkboardStatus): Promise<void> {
const client = this.client;
if (!client || card.status === status) {
return;
}
const state = getWorkboardState(this.workboardHost);
state.cards = [...this.cards];
state.statuses = this.statuses;
state.loaded = true;
state.loadAttempted = true;
state.mutationReadiness = "ready";
const position =
Math.max(
-1,
...state.cards
.filter((candidate) => candidate.status === status)
.map((candidate) => candidate.position),
) + 1;
await moveWorkboardCard({
host: this.workboardHost,
client,
cardId: card.id,
status,
position,
requestUpdate: () => this.syncFromHost(),
});
this.syncFromHost();
}
private syncGateway(snapshot: ApplicationContext["gateway"]["snapshot"] | undefined): void {
const nextClient = snapshot?.connected ? snapshot.client : null;
if (this.client === nextClient) {
return;
}
this.client = nextClient;
this.refreshGeneration += 1;
this.refreshPromise = null;
this.refreshPending = false;
this.loaded = false;
this.loadAttempted = false;
this.loading = false;
this.error = "";
this.requestRender();
if (nextClient) {
void this.refresh(true);
}
}
private async refresh(force = false): Promise<void> {
const client = this.client;
if (!client || (!force && this.loaded)) {
return;
}
if (this.refreshPromise) {
if (force) {
this.refreshPending = true;
}
return await this.refreshPromise;
}
const generation = ++this.refreshGeneration;
this.loadAttempted = true;
this.loading = true;
this.error = "";
this.requestRender();
const refresh = (async () => {
try {
const normalized = normalizeCardsPayload(await client.request("workboard.cards.list", {}));
if (generation !== this.refreshGeneration || client !== this.client) {
return;
}
this.cards = normalized.cards;
this.statuses = normalized.statuses;
this.loaded = true;
} catch (error) {
if (generation === this.refreshGeneration && client === this.client) {
this.error = error instanceof Error ? error.message : String(error);
}
} finally {
if (generation === this.refreshGeneration) {
this.loading = false;
this.requestRender();
}
}
})();
this.refreshPromise = refresh;
try {
await refresh;
} finally {
if (this.refreshPromise === refresh) {
this.refreshPromise = null;
}
const shouldRefreshAgain = this.refreshPending && client === this.client;
this.refreshPending = false;
if (shouldRefreshAgain) {
await this.refresh(true);
}
}
}
private syncFromHost(): void {
const state = getWorkboardState(this.workboardHost);
this.cards = [...state.cards];
this.statuses = state.statuses;
this.error = state.error ?? "";
this.requestRender();
}
private requestRender(): void {
this.requestUpdate();
this.hostRequestUpdate?.();
}
}
+196
View File
@@ -0,0 +1,196 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ApplicationContext } from "../../../app/context.ts";
import { createApplicationContextProvider } from "../../../test-helpers/application-context.ts";
import type { BoardViewWidget } from "../view-types.ts";
import "./workboard-card.ts";
import "./workboard-mini.ts";
const cards = [
{
id: "card-ready",
title: "Ready card",
status: "ready",
priority: "high",
labels: [],
position: 0,
createdAt: 1,
updatedAt: 1,
agentId: "agent-a",
metadata: { automation: { boardId: "ops" } },
},
{
id: "card-running",
title: "Running card",
status: "running",
priority: "normal",
labels: [],
position: 1,
createdAt: 1,
updatedAt: 1,
metadata: { automation: { boardId: "ops" } },
},
{
id: "card-done",
title: "Done card",
status: "done",
priority: "low",
labels: [],
position: 2,
createdAt: 1,
updatedAt: 1,
metadata: { automation: { boardId: "ops" } },
},
] as const;
function pluginWidget(pluginKind: string, props: Record<string, unknown>): BoardViewWidget {
return {
name: pluginKind.replace(":", "-"),
tabId: "main",
contentKind: "plugin",
pluginKind,
props,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none",
revision: 1,
};
}
function createContext(
request: ReturnType<typeof vi.fn>,
events?: { listener?: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0] },
): ApplicationContext {
const subscribe = () => () => undefined;
return {
basePath: "/control",
gateway: {
snapshot: {
client: { request } as never,
connected: true,
reconnecting: false,
hello: null,
assistantAgentId: null,
sessionKey: "agent:main:test",
lastError: null,
lastErrorCode: null,
},
subscribe,
subscribeEvents: (
listener: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0],
) => {
if (events) {
events.listener = listener;
}
return () => undefined;
},
} as unknown as ApplicationContext["gateway"],
} as unknown as ApplicationContext;
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
async function mount<T extends HTMLElement>(
element: T,
context: ApplicationContext,
request: ReturnType<typeof vi.fn>,
): Promise<T> {
const provider = createApplicationContextProvider(context);
provider.append(element);
document.body.append(provider);
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("workboard.cards.list", {}));
await (element as T & { updateComplete: Promise<boolean> }).updateComplete;
await vi.waitFor(() => expect(element.textContent).not.toContain("Loading Workboard"));
return element;
}
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("Workboard plugin widgets", () => {
it("renders a card and moves it through the shared mutation helper", async () => {
const request = vi.fn(async (method: string) => {
if (method === "workboard.cards.list") {
return { cards, statuses: ["ready", "running", "done"] };
}
if (method === "workboard.cards.move") {
return { card: { ...cards[0], status: "running", position: 2 } };
}
throw new Error(`Unexpected method: ${method}`);
});
const element = document.createElement("openclaw-workboard-card-widget");
element.widget = pluginWidget("workboard:card", { cardId: "card-ready" });
element.sessionKey = "agent:main:test";
await mount(element, createContext(request), request);
expect(element.textContent).toContain("Ready card");
expect(element.textContent).toContain("agent-a");
const select = element.querySelector("select") as HTMLSelectElement;
select.value = "running";
select.dispatchEvent(new Event("change"));
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("workboard.cards.move", {
id: "card-ready",
status: "running",
position: 2,
}),
);
});
it("renders per-status board counts and the top ready/running cards", async () => {
const request = vi.fn(async () => ({ cards, statuses: ["ready", "running", "done"] }));
const element = document.createElement("openclaw-workboard-mini-widget");
element.widget = pluginWidget("workboard:mini", { boardId: "ops", limit: 2 });
element.sessionKey = "agent:main:test";
await mount(element, createContext(request), request);
const counts = [...element.querySelectorAll(".workboard-widget-mini__counts span")].map(
(entry) => entry.textContent?.replace(/\s+/g, " ").trim(),
);
expect(counts).toContain("1 Ready");
expect(counts).toContain("1 Running");
expect(counts).toContain("1 Done");
expect(element.textContent).toContain("Running card");
expect(element.textContent).toContain("Ready card");
expect(element.querySelector("a")?.getAttribute("href")).toBe("/control/workboard?board=ops");
});
it("queues a second refresh when a change arrives during an active list request", async () => {
const firstList = deferred<unknown>();
const request = vi.fn(async (method: string) => {
if (method !== "workboard.cards.list") {
throw new Error(`Unexpected method: ${method}`);
}
return request.mock.calls.length === 1
? await firstList.promise
: { cards, statuses: ["ready", "running", "done"] };
});
const events: {
listener?: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0];
} = {};
const element = document.createElement("openclaw-workboard-mini-widget");
element.widget = pluginWidget("workboard:mini", { boardId: "ops" });
const provider = createApplicationContextProvider(createContext(request, events));
provider.append(element);
document.body.append(provider);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
events.listener?.({
type: "event",
event: "plugin.workboard.changed",
payload: { epoch: "epoch-a", revision: 2 },
});
firstList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
});
});
+145
View File
@@ -390,6 +390,151 @@ openclaw-board-widget-cell {
background: var(--danger, #ff6b6b);
}
.board-widget__plugin-loading,
.board-widget__disabled-plugin,
.workboard-widget__state {
align-content: center;
color: var(--muted, #8a919e);
display: grid;
font-size: 11px;
gap: 10px;
justify-items: center;
margin: 0;
min-height: 100%;
padding: 14px;
text-align: center;
}
.board-widget__disabled-plugin strong {
color: var(--text, #d7dae0);
}
openclaw-workboard-card-widget,
openclaw-workboard-mini-widget {
display: block;
min-height: 100%;
}
.workboard-widget-card,
.workboard-widget-mini {
display: grid;
gap: 10px;
padding: 12px;
}
.workboard-widget-card__heading,
.workboard-widget-mini > header,
.workboard-widget-mini__card {
align-items: center;
display: flex;
gap: 8px;
justify-content: space-between;
min-width: 0;
}
.workboard-widget-card__heading > strong,
.workboard-widget-mini__card > strong {
color: var(--text, #d7dae0);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workboard-widget__status {
border: 1px solid var(--board-line);
border-radius: 999px;
color: var(--muted, #8a919e);
flex: 0 0 auto;
font-size: 9px;
padding: 2px 6px;
}
.workboard-widget__status--ready,
.workboard-widget__status--running {
border-color: color-mix(in srgb, var(--accent, #ff5c5c) 45%, transparent);
color: var(--accent, #ff5c5c);
}
.workboard-widget-card__meta {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
}
.workboard-widget-card__meta div,
.workboard-widget-mini__counts span {
background: color-mix(in srgb, var(--text, #d7dae0) 3%, transparent);
border: 1px solid var(--board-line);
border-radius: 8px;
min-width: 0;
padding: 7px;
}
.workboard-widget-card__meta dt,
.workboard-widget-card__move > span {
color: var(--muted, #8a919e);
font-size: 9px;
text-transform: uppercase;
}
.workboard-widget-card__meta dd {
color: var(--text, #d7dae0);
font-size: 11px;
margin: 3px 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workboard-widget-card__move {
display: grid;
gap: 4px;
}
.workboard-widget-card__move select {
background: var(--panel, #171a20);
border: 1px solid var(--board-line);
border-radius: 7px;
color: var(--text, #d7dae0);
font: inherit;
padding: 6px 8px;
}
.workboard-widget-mini > header a {
color: var(--accent, #ff5c5c);
font-size: 10px;
}
.workboard-widget-mini__counts {
display: grid;
gap: 5px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.workboard-widget-mini__counts span {
color: var(--muted, #8a919e);
display: grid;
font-size: 8px;
gap: 2px;
padding: 5px;
}
.workboard-widget-mini__counts b {
color: var(--text, #d7dae0);
font-size: 13px;
}
.workboard-widget-mini__cards {
display: grid;
gap: 5px;
}
.workboard-widget-mini__card {
justify-content: flex-start;
}
@keyframes swarm-widget-pulse {
50% {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent, #ff5c5c) 25%, transparent);