feat(dashboard): plugin widget kinds — native WorkBoard card and mini-board widgets (#112434)

* feat(board): add plugin widget kinds

* feat(ui): render native Workboard widgets

* fix(dashboard): compose plugin widgets with current main

* chore: internalize widget-kind contribution types

* fix(ui): retry plugin widget renderer loads

* fix(ui): harden Workboard widget refresh lifecycle

* fix(ci): clear plugin widget landing gates

* fix(boards): migrate plugin widget storage

* fix(db): migrate unreleased board widget constraint

* fix(ui): retry failed Workboard widget loads

* fix(ui): keep stale widget refresh cleanup inert

* fix(ci): align plugin widget landing guards
This commit is contained in:
Peter Steinberger
2026-07-21 17:59:20 -07:00
committed by GitHub
parent 0baf0495ab
commit 0f066eec81
44 changed files with 2108 additions and 117 deletions
+13 -13
View File
@@ -38771,7 +38771,7 @@
},
{
"kind": "ui-modifier",
"line": 692,
"line": 688,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Add Image",
"surface": "apple",
@@ -38779,7 +38779,7 @@
},
{
"kind": "ui-modifier",
"line": 693,
"line": 689,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Attachments",
"surface": "apple",
@@ -38787,7 +38787,7 @@
},
{
"kind": "conditional-branch",
"line": 886,
"line": 882,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Start",
"surface": "apple",
@@ -38795,7 +38795,7 @@
},
{
"kind": "conditional-branch",
"line": 886,
"line": 882,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Stop",
"surface": "apple",
@@ -38803,7 +38803,7 @@
},
{
"kind": "ui-call",
"line": 997,
"line": 993,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Loading commands",
"surface": "apple",
@@ -38811,7 +38811,7 @@
},
{
"kind": "ui-call",
"line": 1006,
"line": 1002,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Commands unavailable",
"surface": "apple",
@@ -38819,7 +38819,7 @@
},
{
"kind": "ui-call",
"line": 1015,
"line": 1011,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Retry",
"surface": "apple",
@@ -38827,7 +38827,7 @@
},
{
"kind": "ui-call",
"line": 1024,
"line": 1020,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "No matching commands",
"surface": "apple",
@@ -38835,7 +38835,7 @@
},
{
"kind": "ui-modifier",
"line": 1256,
"line": 1252,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Stop response",
"surface": "apple",
@@ -38843,7 +38843,7 @@
},
{
"kind": "ui-modifier",
"line": 1281,
"line": 1277,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Send message",
"surface": "apple",
@@ -38851,7 +38851,7 @@
},
{
"kind": "ui-modifier",
"line": 1295,
"line": 1291,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Refresh",
"surface": "apple",
@@ -38859,7 +38859,7 @@
},
{
"kind": "conditional-branch",
"line": 1421,
"line": 1417,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Message…",
"surface": "apple",
@@ -40627,7 +40627,7 @@
},
{
"kind": "ui-localized-call",
"line": 1212,
"line": 1208,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
"source": "Remove attachments or wait for delivery to resolve before switching chats.",
"surface": "apple",
@@ -639,10 +639,6 @@ struct OpenClawChatComposer: View {
String(AttributedString(localized: "^[\(count) message](inflect: true)").characters)
}
private var messageSessionActionsDisabled: Bool {
!self.viewModel.canPerformMessageSessionAction
}
@ViewBuilder
private var attachmentPicker: some View {
#if os(macOS)
@@ -582,6 +582,7 @@ public struct OpenClawChatSessionBranch: Codable, Sendable, Equatable, Identifia
self.leafEntryId
}
// periphery:ignore - package tests construct branch fixtures; app consumers decode them.
public init(
leafEntryId: String,
headline: String,
@@ -600,6 +601,7 @@ public struct OpenClawChatSessionBranch: Codable, Sendable, Equatable, Identifia
public struct OpenClawChatSessionBranchesResponse: Codable, Sendable {
public let branches: [OpenClawChatSessionBranch]
// periphery:ignore - package tests construct branch fixtures; app consumers decode them.
public init(branches: [OpenClawChatSessionBranch]) {
self.branches = branches
}
@@ -393,6 +393,7 @@ extension OpenClawChatCommandOutbox {
false
}
// periphery:ignore - protocol-typed callers require this forwarding convenience overload.
public func markCommandRetriedIfPresent(
id: String,
expectation: OpenClawChatOutboxRetryExpectation,
@@ -1215,7 +1215,7 @@ extension OpenClawChatViewModel {
case .awaitingConfirmation:
.confirming
case .failed:
.failed(reason: command.lastError)
.failed(reason: OpenClawChatSQLiteTranscriptCache.outboxDisplayError(command.lastError))
}
}
@@ -176,12 +176,8 @@ public final class OpenClawChatViewModel {
@ObservationIgnored
var outboxBranchReconcileRetryTasks: [OpenClawChatOutboxScope: Task<Void, Never>] = [:]
@ObservationIgnored
var outboxBranchReconcileRetryDelaysMs: [UInt64] = [250, 1000, 4000, 16000, 30000]
@ObservationIgnored
var outboxBranchConnectionGeneration: UInt64 = 0
@ObservationIgnored
var hasEstablishedTransportHealth = false
@ObservationIgnored
var bootstrapOutboxBranchStateCapture: (
generation: UInt64,
session: SessionSnapshot,
@@ -254,6 +254,8 @@ public struct BoardWidget: Codable, Sendable {
public let tabid: String
public let title: String?
public let contentkind: AnyCodable
public let pluginkind: String?
public let props: [String: AnyCodable]?
public let presentation: AnyCodable?
public let heightmode: AnyCodable?
public let sizew: Int
@@ -277,6 +279,8 @@ public struct BoardWidget: Codable, Sendable {
tabid: String,
title: String? = nil,
contentkind: AnyCodable,
pluginkind: String? = nil,
props: [String: AnyCodable]? = nil,
presentation: AnyCodable? = nil,
heightmode: AnyCodable? = nil,
sizew: Int,
@@ -299,6 +303,8 @@ public struct BoardWidget: Codable, Sendable {
self.tabid = tabid
self.title = title
self.contentkind = contentkind
self.pluginkind = pluginkind
self.props = props
self.presentation = presentation
self.heightmode = heightmode
self.sizew = sizew
@@ -323,6 +329,8 @@ public struct BoardWidget: Codable, Sendable {
case tabid = "tabId"
case title
case contentkind = "contentKind"
case pluginkind = "pluginKind"
case props
case presentation
case heightmode = "heightMode"
case sizew = "sizeW"
@@ -637,6 +645,28 @@ public struct BoardWidgetMcpAppPutContent: Codable, Sendable {
}
}
public struct BoardWidgetPluginContent: Codable, Sendable {
public let kind: String
public let pluginkind: String
public let props: [String: AnyCodable]?
public init(
kind: String,
pluginkind: String,
props: [String: AnyCodable]? = nil)
{
self.kind = kind
self.pluginkind = pluginkind
self.props = props
}
private enum CodingKeys: String, CodingKey {
case kind
case pluginkind = "pluginKind"
case props
}
}
public struct BoardCanvasDocumentSource: Codable, Sendable {
public let kind: String
public let docid: String
@@ -1046,6 +1076,7 @@ public struct HelloOk: Codable, Sendable {
public let features: [String: AnyCodable]
public let snapshot: Snapshot
public let controluitabs: [[String: AnyCodable]]?
public let controluiwidgetkinds: [[String: AnyCodable]]?
public let pluginsurfaceurls: [String: AnyCodable]?
public let auth: [String: AnyCodable]
public let policy: [String: AnyCodable]
@@ -1057,6 +1088,7 @@ public struct HelloOk: Codable, Sendable {
features: [String: AnyCodable],
snapshot: Snapshot,
controluitabs: [[String: AnyCodable]]? = nil,
controluiwidgetkinds: [[String: AnyCodable]]? = nil,
pluginsurfaceurls: [String: AnyCodable]? = nil,
auth: [String: AnyCodable],
policy: [String: AnyCodable])
@@ -1067,6 +1099,7 @@ public struct HelloOk: Codable, Sendable {
self.features = features
self.snapshot = snapshot
self.controluitabs = controluitabs
self.controluiwidgetkinds = controluiwidgetkinds
self.pluginsurfaceurls = pluginsurfaceurls
self.auth = auth
self.policy = policy
@@ -1079,6 +1112,7 @@ public struct HelloOk: Codable, Sendable {
case features
case snapshot
case controluitabs = "controlUiTabs"
case controluiwidgetkinds = "controlUiWidgetKinds"
case pluginsurfaceurls = "pluginSurfaceUrls"
case auth
case policy
@@ -16007,6 +16041,7 @@ public enum BoardOp: Codable, Sendable {
public enum BoardWidgetContent: Codable, Sendable {
case html(BoardWidgetHtmlContent)
case mcpApp(BoardWidgetMcpAppContent)
case plugin(BoardWidgetPluginContent)
private enum CodingKeys: String, CodingKey {
case discriminator = "kind"
@@ -16018,6 +16053,7 @@ public enum BoardWidgetContent: Codable, Sendable {
switch discriminator {
case "html": self = try .html(BoardWidgetHtmlContent(from: decoder))
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppContent(from: decoder))
case "plugin": self = try .plugin(BoardWidgetPluginContent(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .discriminator,
@@ -16031,6 +16067,7 @@ public enum BoardWidgetContent: Codable, Sendable {
switch self {
case .html(let value): try value.encode(to: encoder)
case .mcpApp(let value): try value.encode(to: encoder)
case .plugin(let value): try value.encode(to: encoder)
}
}
}
@@ -16038,6 +16075,7 @@ public enum BoardWidgetContent: Codable, Sendable {
public enum BoardWidgetPutContent: Codable, Sendable {
case html(BoardWidgetHtmlContent)
case mcpApp(BoardWidgetMcpAppPutContent)
case plugin(BoardWidgetPluginContent)
case canvasDoc(BoardCanvasDocumentSource)
private enum CodingKeys: String, CodingKey {
@@ -16050,6 +16088,7 @@ public enum BoardWidgetPutContent: Codable, Sendable {
switch discriminator {
case "html": self = try .html(BoardWidgetHtmlContent(from: decoder))
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppPutContent(from: decoder))
case "plugin": self = try .plugin(BoardWidgetPluginContent(from: decoder))
case "canvas-doc": self = try .canvasDoc(BoardCanvasDocumentSource(from: decoder))
default:
throw DecodingError.dataCorruptedError(
@@ -16064,6 +16103,7 @@ public enum BoardWidgetPutContent: Codable, Sendable {
switch self {
case .html(let value): try value.encode(to: encoder)
case .mcpApp(let value): try value.encode(to: encoder)
case .plugin(let value): try value.encode(to: encoder)
case .canvasDoc(let value): try value.encode(to: encoder)
}
}
+10 -1
View File
@@ -671,7 +671,11 @@ const config = {
]),
[`${BUNDLED_PLUGIN_ROOT_DIR}/microsoft`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/memory-core`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/memory-lancedb`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/memory-lancedb`]: {
...bundledPluginWorkspace(),
// LanceDB declares Arrow as a peer; the plugin provides it for runtime table values.
ignoreDependencies: [...bundledPluginIgnoredRuntimeDependencies, "apache-arrow"],
},
[`${BUNDLED_PLUGIN_ROOT_DIR}/microsoft-foundry`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/migrate-claude`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/migrate-hermes`]: bundledPluginWorkspace(),
@@ -738,6 +742,11 @@ const config = {
"vault-secret-ref-resolver.js!",
]),
[`${BUNDLED_PLUGIN_ROOT_DIR}/voyage`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/whatsapp`]: {
...bundledPluginWorkspace(),
// Baileys loads its optional audio decoder at runtime for supported media.
ignoreDependencies: [...bundledPluginIgnoredRuntimeDependencies, "audio-decode"],
},
[`${BUNDLED_PLUGIN_ROOT_DIR}/xiaomi`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/xai`]: bundledPluginWorkspace(),
[`${BUNDLED_PLUGIN_ROOT_DIR}/llama-cpp`]: {
+12
View File
@@ -17,6 +17,18 @@ export default definePluginEntry({
description: "Dashboard workboard for agent-owned issues and sessions.",
register(api) {
const store = WorkboardStore.openSqlite();
api.session.controls.registerControlUiDescriptor({
surface: "widget",
id: "card",
label: "Workboard card",
requiredScopes: ["operator.write"],
});
api.session.controls.registerControlUiDescriptor({
surface: "widget",
id: "mini",
label: "Workboard summary",
requiredScopes: ["operator.read"],
});
registerWorkboardGatewayMethods({ api, store });
registerWorkboardCommand({ api, store });
api.registerService(createWorkboardChangeEventService(store));
@@ -128,6 +128,31 @@ describe("BoardSnapshotSchema", () => {
});
describe("BoardWidgetPutParamsSchema", () => {
it("accepts bounded plugin widget input shapes", () => {
const pluginWidget = {
sessionKey: "agent:main:main",
name: "work-item",
content: {
kind: "plugin",
pluginKind: "workboard:card",
props: { cardId: "card-123" },
},
};
expect(Value.Check(BoardWidgetPutParamsSchema, pluginWidget)).toBe(true);
expect(
Value.Check(BoardWidgetPutParamsSchema, {
...pluginWidget,
content: { ...pluginWidget.content, pluginKind: "missing-separator" },
}),
).toBe(false);
expect(
Value.Check(BoardWidgetPutParamsSchema, {
...pluginWidget,
content: { ...pluginWidget.content, props: ["not", "an", "object"] },
}),
).toBe(false);
});
it("accepts a gateway-resolved canvas document source", () => {
expect(
Value.Check(BoardWidgetPutParamsSchema, {
+16 -2
View File
@@ -7,6 +7,10 @@ export const BoardTabIdSchema = Type.String({ pattern: "^[a-z0-9-]{1,40}$" });
export const BoardWidgetNameSchema = Type.String({
pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
});
export const BoardWidgetPluginKindSchema = Type.String({
pattern: "^[a-z0-9][a-z0-9-]{0,63}:[a-z0-9][a-z0-9._-]{0,63}$",
});
export const BoardWidgetPluginPropsSchema = Type.Record(Type.String(), Type.Unknown());
export const BoardChatDockSchema = Type.Union([
Type.Literal("left"),
Type.Literal("right"),
@@ -60,7 +64,9 @@ export const BoardWidgetSchema = closedObject({
name: BoardWidgetNameSchema,
tabId: BoardTabIdSchema,
title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
contentKind: Type.Union([Type.Literal("html"), Type.Literal("mcp-app")]),
contentKind: Type.Union([Type.Literal("html"), Type.Literal("mcp-app"), Type.Literal("plugin")]),
pluginKind: Type.Optional(BoardWidgetPluginKindSchema),
props: Type.Optional(BoardWidgetPluginPropsSchema),
presentation: Type.Optional(BoardWidgetPresentationSchema),
heightMode: Type.Optional(BoardWidgetHeightModeSchema),
sizeW: Type.Integer({ minimum: 1, maximum: 12 }),
@@ -173,14 +179,21 @@ export const BoardWidgetMcpAppPutContentSchema = closedObject({
kind: Type.Literal("mcp-app"),
viewId: NonEmptyString,
});
export const BoardWidgetPluginContentSchema = closedObject({
kind: Type.Literal("plugin"),
pluginKind: BoardWidgetPluginKindSchema,
props: Type.Optional(BoardWidgetPluginPropsSchema),
});
export const BoardWidgetContentSchema = Type.Union([
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContentSchema,
BoardWidgetPluginContentSchema,
]);
export type BoardWidgetContent = Static<typeof BoardWidgetContentSchema>;
export type BoardWidgetMaterializedContent =
| Static<typeof BoardWidgetHtmlContentSchema>
| (Static<typeof BoardWidgetMcpAppContentSchema> & { interactive: boolean });
| (Static<typeof BoardWidgetMcpAppContentSchema> & { interactive: boolean })
| Static<typeof BoardWidgetPluginContentSchema>;
export const BoardCanvasDocumentSourceSchema = closedObject({
kind: Type.Literal("canvas-doc"),
@@ -191,6 +204,7 @@ export type BoardCanvasDocumentSource = Static<typeof BoardCanvasDocumentSourceS
export const BoardWidgetPutContentSchema = Type.Union([
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppPutContentSchema,
BoardWidgetPluginContentSchema,
BoardCanvasDocumentSourceSchema,
]);
export type BoardWidgetPutContent = Static<typeof BoardWidgetPutContentSchema>;
@@ -101,6 +101,16 @@ export const HelloOkSchema = closedObject({
}),
),
),
// Additive: active plugin widget kinds whose renderers ship in the trusted UI bundle.
controlUiWidgetKinds: Type.Optional(
Type.Array(
closedObject({
pluginId: NonEmptyString,
kind: NonEmptyString,
label: NonEmptyString,
}),
),
),
pluginSurfaceUrls: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
auth: closedObject({
deviceToken: Type.Optional(NonEmptyString),
@@ -23,6 +23,8 @@ export const PluginControlUiDescriptorSchema = closedObject({
Type.Literal("tool"),
Type.Literal("run"),
Type.Literal("settings"),
Type.Literal("tab"),
Type.Literal("widget"),
]),
label: NonEmptyString,
description: Type.Optional(Type.String()),
@@ -182,6 +182,7 @@ import {
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContentSchema,
BoardWidgetMcpAppPutContentSchema,
BoardWidgetPluginContentSchema,
BoardWidgetMoveOpSchema,
BoardWidgetPutContentSchema,
BoardWidgetPutParamsSchema,
@@ -628,6 +629,7 @@ export const ProtocolSchemas = {
BoardWidgetHtmlContent: BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContent: BoardWidgetMcpAppContentSchema,
BoardWidgetMcpAppPutContent: BoardWidgetMcpAppPutContentSchema,
BoardWidgetPluginContent: BoardWidgetPluginContentSchema,
BoardCanvasDocumentSource: BoardCanvasDocumentSourceSchema,
BoardWidgetContent: BoardWidgetContentSchema,
BoardWidgetPutContent: BoardWidgetPutContentSchema,
+42
View File
@@ -48,6 +48,7 @@ describe("dashboard tool", () => {
"tab_update",
"tab_delete",
"tabs_reorder",
"widget_put",
"widget_move",
"widget_resize",
"widget_remove",
@@ -58,6 +59,14 @@ describe("dashboard tool", () => {
},
});
expect(Value.Check(tool.parameters, { action: "widget_move", name: "status" })).toBe(true);
expect(
Value.Check(tool.parameters, {
action: "widget_put",
name: "work-item",
pluginKind: "workboard:card",
props: { cardId: "card-123" },
}),
).toBe(true);
expect(Value.Check(tool.parameters, { action: "unknown" })).toBe(false);
});
@@ -122,6 +131,39 @@ describe("dashboard tool", () => {
expect(harness.calls).toEqual([["board.update", { sessionKey: "agent:main:main", ops: [op] }]]);
});
it("creates a plugin widget through board.widget.put", async () => {
const harness = recorder();
const tool = createDashboardTool({
agentSessionKey: "agent:main:main",
callGateway: harness.callGateway,
});
await tool.execute("put", {
action: "widget_put",
name: "work-item",
title: "Work item",
pluginKind: "workboard:card",
props: { cardId: "card-123" },
tabId: "main",
size: "sm",
});
expect(harness.calls).toEqual([
[
"board.widget.put",
{
sessionKey: "agent:main:main",
name: "work-item",
title: "Work item",
content: {
kind: "plugin",
pluginKind: "workboard:card",
props: { cardId: "card-123" },
},
placement: { tabId: "main", size: "sm" },
},
],
]);
});
it.each([
["focus_tab", { tabId: "notes" }, { kind: "focus_tab", tabId: "notes" }],
["set_chat_dock", { dock: "left" }, { kind: "set_chat_dock", dock: "left" }],
+67 -1
View File
@@ -25,6 +25,7 @@ const DASHBOARD_ACTIONS = [
"tab_update",
"tab_delete",
"tabs_reorder",
"widget_put",
"widget_move",
"widget_resize",
"widget_remove",
@@ -34,6 +35,8 @@ const DASHBOARD_ACTIONS = [
const BOARD_TAB_ID_PATTERN = "^[a-z0-9-]{1,40}$";
const BOARD_TAB_ID_REGEX = /^[a-z0-9-]{1,40}$/;
const BOARD_WIDGET_NAME_PATTERN = "^[a-z0-9][a-z0-9._-]{0,63}$";
const BOARD_PLUGIN_KIND_PATTERN = "^[a-z0-9][a-z0-9-]{0,63}:[a-z0-9][a-z0-9._-]{0,63}$";
const BOARD_PLUGIN_KIND_REGEX = /^[a-z0-9][a-z0-9-]{0,63}:[a-z0-9][a-z0-9._-]{0,63}$/;
const DashboardToolSchema = Type.Object(
{
@@ -65,6 +68,18 @@ const DashboardToolSchema = Type.Object(
),
sizeW: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
sizeH: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
size: Type.Optional(Type.String({ enum: ["sm", "md", "lg", "xl", "full"] })),
pluginKind: Type.Optional(
Type.String({
pattern: BOARD_PLUGIN_KIND_PATTERN,
description: "Plugin widget kind, for example workboard:card or workboard:mini",
}),
),
props: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
description: "Plugin-owned JSON props (maximum 8KB encoded)",
}),
),
},
{ additionalProperties: false },
);
@@ -125,6 +140,25 @@ function readTabId(params: Record<string, unknown>): string {
return tabId;
}
function readOptionalTabId(params: Record<string, unknown>): string | undefined {
const tabId = readStringParam(params, "tabId");
if (tabId !== undefined && !BOARD_TAB_ID_REGEX.test(tabId)) {
throw new ToolInputError("tabId must be a lowercase slug up to 40 characters");
}
return tabId;
}
function readPluginProps(params: Record<string, unknown>): Record<string, unknown> | undefined {
const props = params.props;
if (props === undefined) {
return undefined;
}
if (!props || typeof props !== "object" || Array.isArray(props)) {
throw new ToolInputError("props must be an object");
}
return props as Record<string, unknown>;
}
function opForAction(action: string, params: Record<string, unknown>): BoardOp {
const name = () => readStringParam(params, "name", { required: true });
switch (action) {
@@ -213,7 +247,7 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo
label: "Dashboard",
name: "dashboard",
description:
"Read and arrange this session dashboard. Widgets use stable names. Sizes: sm=3x3, md=6x4, lg=8x6, xl=12x8, full=12x8 single-widget emphasis.",
"Read and arrange this session dashboard. Widgets use stable names. Create trusted plugin widgets with widget_put; examples: workboard:card props {cardId}, workboard:mini props {boardId, limit}. Sizes: sm=3x3, md=6x4, lg=8x6, xl=12x8, full=12x8 single-widget emphasis.",
parameters: DashboardToolSchema,
execute: async (_toolCallId, rawArgs) => {
const params = rawArgs as Record<string, unknown>;
@@ -246,6 +280,38 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo
delivered,
});
}
if (action === "widget_put") {
const pluginKind = readStringParam(params, "pluginKind", { required: true });
if (!BOARD_PLUGIN_KIND_REGEX.test(pluginKind)) {
throw new ToolInputError("pluginKind must use the <pluginId>:<name> format");
}
const title = readStringParam(params, "title");
const tabId = readOptionalTabId(params);
const size = readStringParam(params, "size");
const after = readStringParam(params, "after");
const props = readPluginProps(params);
return snapshotResult(
await gatewayCall<BoardSnapshot>("board.widget.put", {
sessionKey,
name: readStringParam(params, "name", { required: true }),
...(title !== undefined ? { title } : {}),
content: {
kind: "plugin",
pluginKind,
...(props !== undefined ? { props } : {}),
},
...(tabId || size || after
? {
placement: {
...(tabId ? { tabId } : {}),
...(size ? { size } : {}),
...(after ? { after } : {}),
},
}
: {}),
}),
);
}
return snapshotResult(
await gatewayCall<BoardSnapshot>("board.update", {
sessionKey,
+2
View File
@@ -44,6 +44,8 @@ function cloneWidget(widget: BoardWidget): BoardWidget {
contentKind: widget.contentKind,
...(widget.presentation !== undefined ? { presentation: widget.presentation } : {}),
...(widget.heightMode !== undefined ? { heightMode: widget.heightMode } : {}),
...(widget.pluginKind !== undefined ? { pluginKind: widget.pluginKind } : {}),
...(widget.props !== undefined ? { props: structuredClone(widget.props) } : {}),
sizeW: widget.sizeW,
sizeH: widget.sizeH,
position: widget.position,
+121
View File
@@ -178,6 +178,54 @@ describe.each([
expect(store.getSnapshot("agent:main:board").widgets[1]?.instanceId).toMatch(/^[a-f0-9]{32}$/u);
});
it("round-trips plugin kinds and props without serving document bytes", () => {
const store = createStore();
const put = store.putWidget({
sessionKey: "agent:main:board",
name: "work-item",
content: {
kind: "plugin",
pluginKind: "workboard:card",
props: { cardId: "card-123", compact: true },
},
});
expect(put.widgets[0]).toMatchObject({
name: "work-item",
contentKind: "plugin",
pluginKind: "workboard:card",
props: { cardId: "card-123", compact: true },
grantState: "none",
});
expect(put.widgets[0]).not.toHaveProperty("instanceId");
expect(store.getSnapshot("agent:main:board").widgets[0]).toEqual(put.widgets[0]);
expect(store.readWidgetHtml("agent:main:board", "work-item")).toBeUndefined();
expect(store.readWidgetMcpApp("agent:main:board", "work-item")).toBeUndefined();
});
it("rejects oversized plugin props and capability declarations", () => {
const store = createStore();
expect(() =>
store.putWidget({
sessionKey: "agent:main:board",
name: "too-large",
content: {
kind: "plugin",
pluginKind: "workboard:mini",
props: { value: "x".repeat(8 * 1024) },
},
}),
).toThrow("props exceed 8192 UTF-8 bytes");
expect(() =>
store.putWidget({
sessionKey: "agent:main:board",
name: "declared",
content: { kind: "plugin", pluginKind: "workboard:card" },
declared: { tools: ["workboard.cards.move"] },
}),
).toThrow("do not accept sandbox capability declarations");
});
it("preserves grants only for unchanged bytes with equal or narrower declarations", () => {
const store = createStore();
const first = store.putWidget({
@@ -531,6 +579,79 @@ describe("SqliteBoardStore persistence", () => {
).toEqual({ name: "idx_agent_board_widgets_tab_position" });
});
it("upgrades the unreleased v13 board constraint before storing plugin widgets", () => {
const stateDir = tempDirs.make("openclaw-board-plugin-kind-schema-");
const env = { OPENCLAW_STATE_DIR: stateDir };
const sessionKey = "agent:main:board";
seedSession(env, "main", sessionKey);
const store = new SqliteBoardStore({
resolveSession: () => ({ agentId: "main", sessionKey }),
env,
});
store.putWidget({
sessionKey,
name: "existing",
content: { kind: "html", html: "preserved" },
});
const opened = openOpenClawAgentDatabase({ agentId: "main", env });
const schema = opened.db
.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'board_widgets'")
.get() as { sql: string };
const legacySchema = schema.sql
.replace(
"content_kind IN ('html', 'mcp-app', 'plugin')",
"content_kind IN ('html', 'mcp-app')",
)
.replace(
/\s+OR\s+\(content_kind = 'plugin' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL\)/u,
"",
);
const legacyCreateSql = legacySchema.replace(
/^CREATE TABLE board_widgets/u,
"CREATE TABLE board_widgets_legacy",
);
opened.db.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
${legacyCreateSql};
INSERT INTO board_widgets_legacy SELECT * FROM board_widgets;
DROP TABLE board_widgets;
ALTER TABLE board_widgets_legacy RENAME TO board_widgets;
CREATE INDEX idx_agent_board_widgets_tab_position
ON board_widgets(session_key, tab_id, position);
COMMIT;
PRAGMA foreign_keys = ON;
`);
closeOpenClawAgentDatabasesForTest();
const upgradedStore = new SqliteBoardStore({
resolveSession: () => ({ agentId: "main", sessionKey }),
env,
});
expect(upgradedStore.getSnapshot(sessionKey).widgets).toEqual([
expect.objectContaining({ name: "existing", contentKind: "html" }),
]);
upgradedStore.putWidget({
sessionKey,
name: "plugin",
content: { kind: "plugin", pluginKind: "workboard:card", props: { cardId: "123" } },
});
expect(upgradedStore.readWidgetHtml(sessionKey, "existing")?.html).toBe("preserved");
expect(upgradedStore.getSnapshot(sessionKey).widgets).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "existing", contentKind: "html" }),
expect.objectContaining({
name: "plugin",
contentKind: "plugin",
pluginKind: "workboard:card",
props: { cardId: "123" },
}),
]),
);
});
it("does not create an unregistered agent database during widget byte lookup", () => {
const stateDir = tempDirs.make("openclaw-board-no-create-");
const store = new SqliteBoardStore({
+64 -20
View File
@@ -58,6 +58,7 @@ type StoredBoard = {
const BOARD_MAX_WIDGETS = 48;
const BOARD_MAX_WIDGET_HTML_BYTES = 256 * 1024;
const BOARD_MAX_WIDGET_PLUGIN_PROPS_BYTES = 8 * 1024;
function emptyBoardSnapshot(sessionKey: string): BoardSnapshot {
return { sessionKey, revision: 0, tabs: [], widgets: [] };
@@ -70,6 +71,7 @@ export function cloneBoardSnapshot(snapshot: BoardSnapshot): BoardSnapshot {
tabs: snapshot.tabs.map((tab) => ({ ...tab })),
widgets: snapshot.widgets.map((widget) => ({
...widget,
...(widget.props !== undefined ? { props: structuredClone(widget.props) } : {}),
...(widget.declaredSummary !== undefined
? { declaredSummary: [...widget.declaredSummary] }
: {}),
@@ -93,7 +95,7 @@ function createBoardWidgetDocument(
grantState: BoardWidgetHtmlDocument["grantState"],
declared: BoardWidgetDeclared | undefined,
instanceId: string,
): BoardWidgetDocument {
): BoardWidgetDocument | undefined {
if (content.kind === "html") {
return {
html: content.html,
@@ -104,6 +106,9 @@ function createBoardWidgetDocument(
...(declared ? { declared } : {}),
};
}
if (content.kind === "plugin") {
return undefined;
}
return {
descriptor: { ...content.descriptor },
revision,
@@ -124,7 +129,10 @@ export function createBoardDeclaredSummary(
return lines.length > 0 ? lines : undefined;
}
type BoardWidgetGrantScope = { kind: "html" } | { kind: "mcp-app"; serverName: string };
type BoardWidgetGrantScope =
| { kind: "html" }
| { kind: "mcp-app"; serverName: string }
| { kind: "plugin" };
function grantScopeMatches(
previous: BoardWidgetDocument | undefined,
@@ -138,7 +146,9 @@ function grantScopeMatches(
const next: BoardWidgetGrantScope =
content.kind === "html"
? { kind: "html" }
: { kind: "mcp-app", serverName: content.descriptor.serverName };
: content.kind === "mcp-app"
? { kind: "mcp-app", serverName: content.descriptor.serverName }
: { kind: "plugin" };
return (
prior === undefined ||
(prior.kind === "html" && next.kind === "html") ||
@@ -146,6 +156,25 @@ function grantScopeMatches(
);
}
function validatePluginContent(params: BoardWidgetMaterializedPutParams): void {
if (params.content.kind !== "plugin") {
return;
}
if (params.declared !== undefined) {
throw new BoardValidationError(
"invalid_operation",
"trusted plugin widgets do not accept sandbox capability declarations",
);
}
const propsBytes = Buffer.byteLength(JSON.stringify(params.content.props ?? {}), "utf8");
if (propsBytes > BOARD_MAX_WIDGET_PLUGIN_PROPS_BYTES) {
throw new BoardValidationError(
"invalid_operation",
`board plugin widget props exceed ${BOARD_MAX_WIDGET_PLUGIN_PROPS_BYTES} UTF-8 bytes`,
);
}
}
export function createBoardWidgetPutSnapshot(
prior: BoardSnapshot,
params: BoardWidgetMaterializedPutParams,
@@ -155,6 +184,7 @@ export function createBoardWidgetPutSnapshot(
instanceId: string;
},
): BoardSnapshot {
validatePluginContent(params);
if (
params.content.kind === "html" &&
Buffer.byteLength(params.content.html, "utf8") > BOARD_MAX_WIDGET_HTML_BYTES
@@ -181,7 +211,8 @@ export function createBoardWidgetPutSnapshot(
}
const size = BOARD_SIZE_PRESETS[(params.placement?.size ?? "md") as BoardSize];
const widgetRevision = (existing?.revision ?? 0) + 1;
const declared = normalizeBoardWidgetDeclared(params.declared);
const declared =
params.content.kind === "plugin" ? undefined : normalizeBoardWidgetDeclared(params.declared);
const declaredSummary = createBoardDeclaredSummary(declared);
const contentSha256 =
params.content.kind === "html"
@@ -217,18 +248,29 @@ export function createBoardWidgetPutSnapshot(
: existing?.heightMode !== undefined
? { heightMode: existing.heightMode }
: {}),
...(params.content.kind === "plugin"
? {
pluginKind: params.content.pluginKind,
...(params.content.props !== undefined
? { props: structuredClone(params.content.props) }
: {}),
}
: {}),
sizeW: params.placement?.size ? size.sizeW : (existing?.sizeW ?? size.sizeW),
sizeH: params.placement?.size ? size.sizeH : (existing?.sizeH ?? size.sizeH),
position: existing?.position ?? layout.widgets.length,
grantState: preservesGrant
? "granted"
: params.content.kind === "mcp-app" && !params.content.interactive
grantState:
params.content.kind === "plugin"
? "none"
: declaredSummary || params.content.kind === "mcp-app"
? "pending"
: "none",
: preservesGrant
? "granted"
: params.content.kind === "mcp-app" && !params.content.interactive
? "none"
: declaredSummary || params.content.kind === "mcp-app"
? "pending"
: "none",
revision: widgetRevision,
instanceId: context.instanceId,
...(params.content.kind !== "plugin" ? { instanceId: context.instanceId } : {}),
...(declaredSummary ? { declaredSummary } : {}),
...(declared ? { declared } : {}),
},
@@ -341,16 +383,18 @@ export class InMemoryBoardStore implements BoardStore {
(widget) => widget.name === canonicalParams.name,
)!.revision;
const widget = snapshot.widgets.find((candidate) => candidate.name === canonicalParams.name)!;
documents.set(
canonicalParams.name,
createBoardWidgetDocument(
canonicalParams.content,
widgetRevision,
widget.grantState,
declared,
instanceId,
),
const document = createBoardWidgetDocument(
canonicalParams.content,
widgetRevision,
widget.grantState,
declared,
instanceId,
);
if (document) {
documents.set(canonicalParams.name, document);
} else {
documents.delete(canonicalParams.name);
}
this.boards.set(canonicalParams.sessionKey, { snapshot, documents });
return cloneBoardSnapshot(snapshot);
}
+88
View File
@@ -1,9 +1,11 @@
import { createHash } from "node:crypto";
import type { Selectable } from "kysely";
import type {
BoardMcpAppDescriptor,
BoardTab,
BoardWidget,
BoardWidgetDeclared,
BoardWidgetMaterializedPutParams,
} from "../../packages/gateway-protocol/src/index.js";
import type {
BoardTabs as BoardTabRow,
@@ -28,6 +30,11 @@ type ParsedBoardManifest = {
mcpAppInstanceId?: string;
};
type ParsedPluginContent = {
pluginKind: string;
props?: Record<string, unknown>;
};
export function parseManifest(value: string): ParsedBoardManifest {
const parsed = JSON.parse(value) as {
netOrigins?: unknown;
@@ -103,6 +110,73 @@ export function serializeManifest(
});
}
export function createBoardWidgetContentFields(
params: BoardWidgetMaterializedPutParams,
// Effective frame options come from the materialized widget, not the raw put
// params: re-pins that omit them must keep the inherited persisted values.
frame: Pick<BoardWidget, "presentation" | "heightMode">,
revision: number,
grantState: BoardWidget["grantState"],
viewGeneration: string,
now: number,
) {
const manifest = serializeManifest(
params.declared,
grantState,
params.content.kind === "mcp-app"
? { interactive: params.content.interactive, instanceId: viewGeneration }
: undefined,
frame,
);
if (params.content.kind === "html") {
const sha256 = createHash("sha256").update(params.content.html).digest("hex");
return {
content_kind: "html",
html: Buffer.from(params.content.html, "utf8"),
descriptor_json: null,
sha256,
view_generation: viewGeneration,
revision,
manifest,
grant_state: grantState,
granted_sha: grantState === "granted" ? sha256 : null,
updated_at: now,
};
}
if (params.content.kind === "plugin") {
const descriptorJson = JSON.stringify({
pluginKind: params.content.pluginKind,
...(params.content.props !== undefined ? { props: params.content.props } : {}),
});
return {
content_kind: "plugin",
html: null,
descriptor_json: descriptorJson,
sha256: createHash("sha256").update(descriptorJson).digest("hex"),
view_generation: null,
revision,
manifest,
grant_state: "none",
granted_sha: null,
updated_at: now,
};
}
const descriptorJson = JSON.stringify(params.content.descriptor);
const sha256 = createHash("sha256").update(descriptorJson).digest("hex");
return {
content_kind: "mcp-app",
html: null,
descriptor_json: descriptorJson,
sha256,
view_generation: null,
revision,
manifest,
grant_state: grantState,
granted_sha: grantState === "granted" ? sha256 : null,
updated_at: now,
};
}
export function updateManifestHeightMode(
value: string,
heightMode: NonNullable<BoardWidget["heightMode"]>,
@@ -135,6 +209,10 @@ export function parseDescriptor(value: string): BoardMcpAppDescriptor {
return JSON.parse(value) as BoardMcpAppDescriptor;
}
export function parsePluginContent(value: string): ParsedPluginContent {
return JSON.parse(value) as ParsedPluginContent;
}
export function rowToTab(row: SelectedBoardTabRow): BoardTab {
return {
tabId: row.tab_id,
@@ -148,6 +226,10 @@ export function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
const manifest = parseManifest(row.manifest);
const declared = manifest.declared;
const declaredSummary = createBoardDeclaredSummary(declared);
const pluginContent =
row.content_kind === "plugin" && row.descriptor_json !== null
? parsePluginContent(row.descriptor_json)
: undefined;
const instanceId =
row.content_kind === "mcp-app" ? manifest.mcpAppInstanceId : row.view_generation;
return {
@@ -157,6 +239,12 @@ export function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
contentKind: row.content_kind as BoardWidget["contentKind"],
...(manifest.presentation ? { presentation: manifest.presentation } : {}),
...(manifest.heightMode ? { heightMode: manifest.heightMode } : {}),
...(pluginContent
? {
pluginKind: pluginContent.pluginKind,
...(pluginContent.props !== undefined ? { props: pluginContent.props } : {}),
}
: {}),
sizeW: row.size_w,
sizeH: row.size_h,
position: row.position,
+19 -59
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes } from "node:crypto";
import { randomBytes } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import type {
BoardOp,
@@ -11,7 +11,7 @@ import {
runSqliteDeferredTransactionSync,
runSqliteImmediateTransactionSync,
} from "../infra/sqlite-transaction.js";
import { OPENCLAW_AGENT_BOARD_SCHEMA_SQL } from "../state/openclaw-agent-board-schema.js";
import { ensureOpenClawAgentBoardSchemaInTransaction } from "../state/openclaw-agent-board-schema.js";
import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js";
import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js";
import {
@@ -32,9 +32,11 @@ import {
type BoardWidgetMcpAppDocument,
} from "./board-store.js";
import {
createBoardWidgetContentFields,
effectiveGrantState,
parseDescriptor,
parseManifest,
parsePluginContent,
rowToTab,
rowToWidget,
serializeManifest,
@@ -56,12 +58,13 @@ type StoredBoard = {
};
const ensuredBoardDatabases = new WeakSet<DatabaseSync>();
const presentBoardDatabases = new WeakSet<DatabaseSync>();
// Read-only connections cannot run the lazy DDL, and a pre-existing v13 DB has
// no board tables until the first write. Reads must treat that as "no boards",
// not "no such table".
function boardTablesPresent(database: Pick<OpenClawAgentDatabase, "db">): boolean {
if (ensuredBoardDatabases.has(database.db)) {
if (ensuredBoardDatabases.has(database.db) || presentBoardDatabases.has(database.db)) {
return true;
}
const row = database.db // sqlite-allow-raw: catalog probe before Kysely table access.
@@ -70,7 +73,7 @@ function boardTablesPresent(database: Pick<OpenClawAgentDatabase, "db">): boolea
if (!row) {
return false;
}
ensuredBoardDatabases.add(database.db);
presentBoardDatabases.add(database.db);
return true;
}
@@ -83,7 +86,7 @@ function ensureBoardSchema(database: OpenClawAgentDatabase): void {
}
runSqliteImmediateTransactionSync(
database.db,
() => database.db.exec(OPENCLAW_AGENT_BOARD_SCHEMA_SQL), // sqlite-allow-raw: one-time DDL bootstrap before Kysely access.
() => ensureOpenClawAgentBoardSchemaInTransaction(database.db),
{
databaseLabel: database.path,
operationLabel: "board.ensure-schema",
@@ -91,6 +94,7 @@ function ensureBoardSchema(database: OpenClawAgentDatabase): void {
);
// Additive-surface rule: fold this into the next natural schema bump, then delete this lazy ensure.
ensuredBoardDatabases.add(database.db);
presentBoardDatabases.add(database.db);
}
type SqliteBoardStoreOptions = {
@@ -280,55 +284,6 @@ function deleteRemovedTabs(
}
}
function contentFields(
params: BoardWidgetMaterializedPutParams,
// Effective frame options come from the materialized widget, not the raw put
// params: re-pins that omit them must keep the inherited persisted values.
frame: Pick<BoardWidget, "presentation" | "heightMode">,
revision: number,
grantState: BoardWidget["grantState"],
viewGeneration: string,
now: number,
) {
const manifest = serializeManifest(
params.declared,
grantState,
params.content.kind === "mcp-app"
? { interactive: params.content.interactive, instanceId: viewGeneration }
: undefined,
frame,
);
if (params.content.kind === "html") {
const sha256 = createHash("sha256").update(params.content.html).digest("hex");
return {
content_kind: "html",
html: Buffer.from(params.content.html, "utf8"),
descriptor_json: null,
sha256,
view_generation: viewGeneration,
revision,
manifest,
grant_state: grantState,
granted_sha: grantState === "granted" ? sha256 : null,
updated_at: now,
};
}
const descriptorJson = JSON.stringify(params.content.descriptor);
const sha256 = createHash("sha256").update(descriptorJson).digest("hex");
return {
content_kind: "mcp-app",
html: null,
descriptor_json: descriptorJson,
sha256,
view_generation: null,
revision,
manifest,
grant_state: grantState,
granted_sha: grantState === "granted" ? sha256 : null,
updated_at: now,
};
}
function hasSession(database: BoardDatabaseHandle, sessionKey: string): boolean {
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
return Boolean(
@@ -467,10 +422,15 @@ export class SqliteBoardStore implements BoardStore {
const grantScopeMatches = existing
? existing.content_kind === "html"
? canonicalParams.content.kind === "html"
: existing.descriptor_json !== null &&
canonicalParams.content.kind === "mcp-app" &&
parseDescriptor(existing.descriptor_json).serverName ===
canonicalParams.content.descriptor.serverName
: existing.content_kind === "mcp-app"
? existing.descriptor_json !== null &&
canonicalParams.content.kind === "mcp-app" &&
parseDescriptor(existing.descriptor_json).serverName ===
canonicalParams.content.descriptor.serverName
: existing.descriptor_json !== null &&
canonicalParams.content.kind === "plugin" &&
parsePluginContent(existing.descriptor_json).pluginKind ===
canonicalParams.content.pluginKind
: true;
const next = createBoardWidgetPutSnapshot(previous.snapshot, canonicalParams, {
grantScopeMatches,
@@ -481,7 +441,7 @@ export class SqliteBoardStore implements BoardStore {
const now = Date.now();
upsertTabs(transactionDatabase, previous, next);
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
const fields = contentFields(
const fields = createBoardWidgetContentFields(
canonicalParams,
{ presentation: widget.presentation, heightMode: widget.heightMode },
widget.revision,
@@ -6,6 +6,7 @@ import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
listControlUiPluginTabAuthGrants,
listControlUiPluginTabs,
listControlUiPluginWidgetKinds,
} from "./control-ui-plugin-tabs.js";
function tabDescriptor(
@@ -94,6 +95,35 @@ describe("listControlUiPluginTabs", () => {
expect(listControlUiPluginTabs([]).map((tab) => tab.id)).toEqual(["beta", "zed", "alpha"]);
});
it("projects scoped widget descriptors as namespaced kinds", () => {
activateDescriptors([
{
pluginId: "workboard",
descriptor: tabDescriptor({
id: "card",
surface: "widget",
label: "Workboard card",
requiredScopes: ["operator.read"],
}),
},
{
pluginId: "workboard",
descriptor: tabDescriptor({
id: "mini",
surface: "widget",
label: "Workboard summary",
requiredScopes: ["operator.read"],
}),
},
]);
expect(listControlUiPluginWidgetKinds([])).toEqual([]);
expect(listControlUiPluginWidgetKinds(["operator.read"])).toEqual([
{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" },
{ pluginId: "workboard", kind: "workboard:mini", label: "Workboard summary" },
]);
});
it("grants only same-plugin gateway routes with least-privilege scopes", () => {
activateDescriptors(
[
+35
View File
@@ -24,6 +24,12 @@ type ControlUiPluginTab = {
requiresGatewayAuth?: boolean;
};
type ControlUiPluginWidgetKind = {
pluginId: string;
kind: string;
label: string;
};
function findControlUiTabGatewayRoute(
registry: PluginRegistry,
tab: ControlUiPluginTab,
@@ -113,6 +119,35 @@ export function listControlUiPluginTabs(
});
}
/** Lists active plugins' trusted widget kinds visible to the presented scopes. */
export function listControlUiPluginWidgetKinds(
scopes: readonly string[],
): ControlUiPluginWidgetKind[] {
const entries = getActivePluginRegistry()?.controlUiDescriptors ?? [];
return entries
.flatMap((entry) => {
const descriptor = entry.descriptor;
if (descriptor.surface !== "widget") {
return [];
}
const visible = (descriptor.requiredScopes ?? []).every(
(scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed,
);
return visible
? [
{
pluginId: entry.pluginId,
kind: `${entry.pluginId}:${descriptor.id}`,
label: descriptor.label,
},
]
: [];
})
.toSorted(
(left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind),
);
}
/** Builds least-privilege grants only for visible tabs backed by same-plugin gateway routes. */
export function listControlUiPluginTabAuthGrants(
callerScopes: readonly string[],
@@ -13,7 +13,10 @@ import {
recordPairedNodeConnection,
} from "../../../infra/node-pairing.js";
import { resolveRuntimeServiceVersion } from "../../../version.js";
import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js";
import {
listControlUiPluginTabs,
listControlUiPluginWidgetKinds,
} from "../../control-ui-plugin-tabs.js";
import { ADMIN_SCOPE } from "../../method-scopes.js";
import { scheduleNodeConnectionNotification } from "../../node-connection-notifications.js";
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
@@ -77,6 +80,7 @@ export async function sendGatewayHello(
const controlUiTabs = listControlUiPluginTabs(helloOkAuthScopes, {
requireGatewayAuthGrant: resolvedAuth.mode !== "none",
});
const controlUiWidgetKinds = listControlUiPluginWidgetKinds(helloOkAuthScopes);
const helloOk = {
type: "hello-ok",
protocol: PROTOCOL_VERSION,
@@ -95,6 +99,7 @@ export async function sendGatewayHello(
},
snapshot,
...(controlUiTabs.length > 0 ? { controlUiTabs } : {}),
...(controlUiWidgetKinds.length > 0 ? { controlUiWidgetKinds } : {}),
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
auth: {
role,
+2 -2
View File
@@ -93,8 +93,8 @@ type PluginControlUiTabGroup = "control" | "agent";
export type PluginControlUiDescriptor = {
id: string;
/** "tab" adds a Control UI sidebar tab; other surfaces attach to existing views. */
surface: "session" | "tool" | "run" | "settings" | "tab";
/** "tab" adds a sidebar tab; "widget" advertises a trusted dashboard renderer. */
surface: "session" | "tool" | "run" | "settings" | "tab" | "widget";
label: string;
description?: string;
placement?: string;
+1
View File
@@ -35,6 +35,7 @@ const controlUiSurfaces = new Set<PluginControlUiDescriptor["surface"]>([
"run",
"settings",
"tab",
"widget",
]);
function normalizeHostHookString(value: unknown): string {
+28
View File
@@ -72,6 +72,34 @@ describe("plugin registry Control UI descriptors", () => {
]);
});
it("accepts trusted dashboard widget descriptors", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({ id: "workboard", name: "Workboard" }),
register(api) {
api.session.controls.registerControlUiDescriptor({
surface: "widget",
id: "card",
label: "Workboard card",
requiredScopes: ["operator.read"],
});
},
});
expect(registry.registry.controlUiDescriptors).toEqual([
expect.objectContaining({
pluginId: "workboard",
descriptor: expect.objectContaining({
id: "card",
surface: "widget",
label: "Workboard card",
}),
}),
]);
});
it("rejects protocol-relative tab paths that would iframe external content", () => {
for (const path of ["//attacker.example/panel", "/\\attacker.example/panel"]) {
const { config, registry } = createPluginRegistryFixture();
+94 -1
View File
@@ -1,7 +1,15 @@
import type { DatabaseSync } from "node:sqlite";
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js";
const BOARD_SCHEMA_START = "CREATE TABLE IF NOT EXISTS board_tabs (";
const BOARD_SCHEMA_END = "CREATE TABLE IF NOT EXISTS heartbeat_outcomes (";
const BOARD_WIDGETS_SCHEMA_START = "CREATE TABLE IF NOT EXISTS board_widgets (";
const BOARD_WIDGETS_SCHEMA_END = "CREATE INDEX IF NOT EXISTS idx_agent_board_widgets_tab_position";
const BOARD_WIDGETS_MIGRATION_TABLE = "board_widgets_plugin_kind_migration_new";
const PLUGIN_CONTENT_KIND_CLAUSE_PATTERN =
/content_kind\s+IN\s*\(\s*'html'\s*,\s*'mcp-app'\s*,\s*'plugin'\s*\)/iu;
const PLUGIN_PAYLOAD_BRANCH_PATTERN =
/\s+OR\s+\(content_kind\s*=\s*'plugin'\s+AND\s+html\s+IS\s+NULL\s+AND\s+descriptor_json\s+IS\s+NOT\s+NULL\s+AND\s+view_generation\s+IS\s+NULL\)/iu;
function splitBoardSchema(sql: string): { board: string; withoutBoard: string } {
const start = sql.indexOf(BOARD_SCHEMA_START);
@@ -17,5 +25,90 @@ function splitBoardSchema(sql: string): { board: string; withoutBoard: string }
const boardSchema = splitBoardSchema(OPENCLAW_AGENT_SCHEMA_SQL);
export const OPENCLAW_AGENT_BOARD_SCHEMA_SQL = boardSchema.board;
const OPENCLAW_AGENT_BOARD_SCHEMA_SQL = boardSchema.board;
export const OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL = boardSchema.withoutBoard;
function canonicalBoardWidgetsCreateSql(): string {
const start = OPENCLAW_AGENT_BOARD_SCHEMA_SQL.indexOf(BOARD_WIDGETS_SCHEMA_START);
const end = OPENCLAW_AGENT_BOARD_SCHEMA_SQL.indexOf(BOARD_WIDGETS_SCHEMA_END, start);
if (start === -1 || end === -1) {
throw new Error("OpenClaw agent board widget schema markers are missing.");
}
return OPENCLAW_AGENT_BOARD_SCHEMA_SQL.slice(start, end).trim();
}
function legacyBoardWidgetsCreateSql(): string {
const canonical = canonicalBoardWidgetsCreateSql();
const legacy = canonical
.replace(PLUGIN_CONTENT_KIND_CLAUSE_PATTERN, "content_kind IN ('html', 'mcp-app')")
.replace(PLUGIN_PAYLOAD_BRANCH_PATTERN, "");
if (legacy === canonical) {
throw new Error("OpenClaw agent board widget legacy schema derivation failed.");
}
return legacy;
}
function normalizeBoardWidgetsCreateSql(sql: string): string {
return sql
.replace(
/^CREATE TABLE(?: IF NOT EXISTS)?\s+(?:board_widgets|"board_widgets"|`board_widgets`|\[board_widgets\])\s*\(/iu,
"CREATE TABLE board_widgets (",
)
.replace(/\s+/gu, " ")
.replace(/;\s*$/u, "")
.trim();
}
/**
* Repairs the unreleased v13 board table shape without advancing the agent DB version.
* Delete this same-version bridge when the lazy board schema folds into the next natural bump.
*/
export function ensureOpenClawAgentBoardSchemaInTransaction(db: DatabaseSync): void {
if (!db.isTransaction) {
throw new Error("board schema ensure requires an active transaction");
}
db.exec(OPENCLAW_AGENT_BOARD_SCHEMA_SQL); // sqlite-allow-raw -- Canonical DDL bootstrap for the lazy board schema.
const row = db // sqlite-allow-raw -- Inspect the table DDL before the bounded same-version migration.
.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'board_widgets'")
.get() as { sql?: unknown } | undefined;
if (typeof row?.sql !== "string") {
throw new Error("OpenClaw agent board widget schema is missing after ensure.");
}
const normalizedSchema = normalizeBoardWidgetsCreateSql(row.sql);
if (normalizedSchema === normalizeBoardWidgetsCreateSql(canonicalBoardWidgetsCreateSql())) {
return;
}
if (normalizedSchema !== normalizeBoardWidgetsCreateSql(legacyBoardWidgetsCreateSql())) {
throw new Error(
"OpenClaw agent board widget schema has an unsupported content-kind constraint.",
);
}
const existingMigrationTable = db // sqlite-allow-raw -- Fail closed if an abandoned migration table exists.
.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get(BOARD_WIDGETS_MIGRATION_TABLE);
if (existingMigrationTable) {
throw new Error(
`OpenClaw agent board migration table already exists: ${BOARD_WIDGETS_MIGRATION_TABLE}`,
);
}
const migrationCreateSql = canonicalBoardWidgetsCreateSql().replace(
BOARD_WIDGETS_SCHEMA_START,
`CREATE TABLE ${BOARD_WIDGETS_MIGRATION_TABLE} (`,
);
db.exec(/* sqlite-allow-raw -- Rebuild one unreleased table constraint inside the caller's transaction. */ `
${migrationCreateSql}
INSERT INTO ${BOARD_WIDGETS_MIGRATION_TABLE} (
session_key, name, tab_id, title, content_kind, html, descriptor_json, sha256,
view_generation, revision, size_w, size_h, position, manifest, grant_state,
granted_sha, created_by, created_at, updated_at
)
SELECT
session_key, name, tab_id, title, content_kind, html, descriptor_json, sha256,
view_generation, revision, size_w, size_h, position, manifest, grant_state,
granted_sha, created_by, created_at, updated_at
FROM board_widgets;
DROP TABLE board_widgets;
ALTER TABLE ${BOARD_WIDGETS_MIGRATION_TABLE} RENAME TO board_widgets;
`);
db.exec(OPENCLAW_AGENT_BOARD_SCHEMA_SQL); // sqlite-allow-raw -- Restore the canonical board index after the rebuild.
}
+3 -2
View File
@@ -198,7 +198,7 @@ CREATE TABLE IF NOT EXISTS board_widgets (
name TEXT NOT NULL,
tab_id TEXT NOT NULL,
title TEXT,
content_kind TEXT NOT NULL CHECK (content_kind IN ('html', 'mcp-app')),
content_kind TEXT NOT NULL CHECK (content_kind IN ('html', 'mcp-app', 'plugin')),
html BLOB,
descriptor_json TEXT,
sha256 TEXT NOT NULL,
@@ -217,7 +217,8 @@ CREATE TABLE IF NOT EXISTS board_widgets (
FOREIGN KEY (session_key, tab_id) REFERENCES board_tabs(session_key, tab_id) ON DELETE CASCADE,
CHECK (
(content_kind = 'html' AND html IS NOT NULL AND descriptor_json IS NULL AND view_generation IS NOT NULL) OR
(content_kind = 'mcp-app' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL)
(content_kind = 'mcp-app' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL) OR
(content_kind = 'plugin' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL)
)
) STRICT;
+3 -2
View File
@@ -193,7 +193,7 @@ CREATE TABLE IF NOT EXISTS board_widgets (
name TEXT NOT NULL,
tab_id TEXT NOT NULL,
title TEXT,
content_kind TEXT NOT NULL CHECK (content_kind IN ('html', 'mcp-app')),
content_kind TEXT NOT NULL CHECK (content_kind IN ('html', 'mcp-app', 'plugin')),
html BLOB,
descriptor_json TEXT,
sha256 TEXT NOT NULL,
@@ -212,7 +212,8 @@ CREATE TABLE IF NOT EXISTS board_widgets (
FOREIGN KEY (session_key, tab_id) REFERENCES board_tabs(session_key, tab_id) ON DELETE CASCADE,
CHECK (
(content_kind = 'html' AND html IS NOT NULL AND descriptor_json IS NULL AND view_generation IS NOT NULL) OR
(content_kind = 'mcp-app' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL)
(content_kind = 'mcp-app' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL) OR
(content_kind = 'plugin' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL)
)
) STRICT;
+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,119 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ApplicationContext } from "../../app/context.ts";
import type { BoardViewWidget } from "../../lib/board/view-types.ts";
import { createApplicationContextProvider } from "../../test-helpers/application-context.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,
setHeightMode: noAction,
reportContentHeight: vi.fn(),
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));
});
it("retries a failed plugin renderer load for the same widget kind", 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 context = {
gateway: {
snapshot: {
connected: false,
hello: {
controlUiWidgetKinds: [
{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" },
],
},
},
subscribe: () => () => undefined,
subscribeEvents: () => () => undefined,
},
} as unknown as ApplicationContext;
const provider = createApplicationContextProvider(context);
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 = callbacks();
provider.append(cell);
document.body.append(provider);
await vi.waitFor(() =>
expect(cell.querySelector("openclaw-workboard-card-widget")).not.toBeNull(),
);
Reflect.set(cell, "pluginRenderer", null);
Reflect.set(cell, "pluginRendererError", "chunk unavailable");
cell.requestUpdate();
await cell.updateComplete;
const retry = cell.querySelector<HTMLButtonElement>(
'[data-test-id="board-widget-error"] button',
);
expect(retry?.textContent?.trim()).toBe("Retry");
retry?.click();
await vi.waitFor(() =>
expect(cell.querySelector("openclaw-workboard-card-widget")).not.toBeNull(),
);
expect(cell.querySelector('[data-test-id="board-widget-error"]')).toBeNull();
});
});
@@ -109,7 +109,27 @@ export function renderBoardWidgetRejected(options: {
`;
}
export function renderBoardWidgetError(error: unknown): TemplateResult {
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, onRetry?: () => void): TemplateResult {
const message = error instanceof Error ? error.message : String(error);
return html`
<div class="board-widget__error" role="alert" data-test-id="board-widget-error">
@@ -119,6 +139,11 @@ export function renderBoardWidgetError(error: unknown): TemplateResult {
<summary>${t("board.widget.errorShow")}</summary>
<code>${message}</code>
</details>
${onRetry
? html`<button class="btn btn--small" type="button" @click=${onRetry}>
${t("board.widget.retry")}
</button>`
: nothing}
</div>
`;
}
+92 -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,
@@ -72,6 +79,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(),
@@ -102,6 +114,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
this.frame.widgetChanged(previousWidget, this.widget);
}
this.appView.update(this.widget, this.callbacks);
this.syncPluginRenderer();
}
override updated(): void {
@@ -122,6 +135,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
override disconnectedCallback(): void {
this.resetPluginRenderer();
this.frame.disconnect();
this.appView.disconnect();
super.disconnectedCallback();
@@ -240,9 +254,81 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
return renderer({ sessions: this.sessions, sessionKey: this.sessionKey });
}
if (widget.contentKind === "plugin") {
if (this.pluginRendererError) {
return renderBoardWidgetError(this.pluginRendererError, () => this.retryPluginRenderer());
}
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 retryPluginRenderer(): void {
this.resetPluginRenderer();
this.requestUpdate();
}
private handleKeyDown(
event: KeyboardEvent,
widget: BoardViewWidget,
@@ -297,7 +383,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";
const presentation =
widget.contentKind === "html" ? (widget.presentation ?? "card") : undefined;
return html`
@@ -331,7 +418,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
+158
View File
@@ -22,6 +22,10 @@ const cardboardProofDir = path.resolve(
process.cwd(),
".artifacts/control-ui-e2e/workboard-cardboard",
);
const pluginWidgetsProofDir = path.resolve(
process.cwd(),
".artifacts/control-ui-e2e/workboard-plugin-widgets",
);
let browser: Browser;
let server: ControlUiE2eServer;
@@ -99,6 +103,39 @@ const pinnedMcpAppBoardSnapshot = {
},
],
};
const pluginWidgetBoardSnapshot = {
sessionKey,
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
widgets: [
{
name: "workboard-card",
tabId: "main",
title: "Priority card",
contentKind: "plugin",
pluginKind: "workboard:card",
props: { cardId: "card-widget-ready" },
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none",
revision: 1,
},
{
name: "workboard-summary",
tabId: "main",
title: "Platform summary",
contentKind: "plugin",
pluginKind: "workboard:mini",
props: { boardId: "platform", limit: 2 },
sizeW: 6,
sizeH: 4,
position: 1,
grantState: "none",
revision: 1,
},
],
};
async function showDashboard(page: Page): Promise<void> {
await page.addInitScript((key) => {
@@ -385,6 +422,127 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
await context.close();
});
it("renders and updates active Workboard plugin widgets", async () => {
const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1";
if (recordProof) {
await mkdir(pluginWidgetsProofDir, { recursive: true });
}
const context = await browser.newContext({
viewport: { height: 900, width: 1280 },
...(recordProof
? { recordVideo: { dir: pluginWidgetsProofDir, size: { height: 900, width: 1280 } } }
: {}),
});
const page = await context.newPage();
const readyCard = {
id: "card-widget-ready",
title: "Rebase plugin widget kinds",
status: "ready",
priority: "high",
labels: ["dashboard"],
position: 1,
createdAt: 1,
updatedAt: 2,
agentId: "main",
metadata: { automation: { boardId: "platform" } },
};
const runningCard = { ...readyCard, status: "running", updatedAt: 3 };
const gateway = await installMockGateway(page, {
sessionKey,
controlUiWidgetKinds: [
{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" },
{ pluginId: "workboard", kind: "workboard:mini", label: "Workboard summary" },
],
featureMethods: [
"board.get",
"chat.metadata",
"chat.startup",
"workboard.cards.list",
"workboard.cards.move",
],
methodResponses: {
"board.get": pluginWidgetBoardSnapshot,
"workboard.cards.list": {
cards: [
readyCard,
{
...readyCard,
id: "card-widget-running",
title: "Already running",
status: "running",
position: 2,
},
],
statuses: ["ready", "running", "done"],
},
"workboard.cards.move": { card: runningCard },
},
});
await showDashboard(page);
try {
await page.goto(`${server.baseUrl}chat`);
const cardWidget = page.locator('[data-test-id="workboard-card-widget"]');
const miniWidget = page.locator('[data-test-id="workboard-mini-widget"]');
await cardWidget.waitFor();
await miniWidget.waitFor();
await expect.poll(() => cardWidget.textContent()).toContain("Rebase plugin widget kinds");
await expect.poll(() => miniWidget.textContent()).toContain("Already running");
expect(await miniWidget.getByRole("link", { name: "Open board" }).getAttribute("href")).toBe(
"/workboard?board=platform",
);
if (recordProof) {
await page.screenshot({
path: path.join(pluginWidgetsProofDir, "01-plugin-widgets-ready.png"),
});
}
await cardWidget.getByRole("combobox").selectOption("running");
const moveRequest = await gateway.waitForRequest("workboard.cards.move");
expect(moveRequest.params).toEqual({
id: "card-widget-ready",
status: "running",
position: 3,
});
await expect.poll(() => cardWidget.textContent()).toContain("Running");
await gateway.setMethodResponse("workboard.cards.list", {
cards: [
runningCard,
{
...readyCard,
id: "card-widget-running",
title: "Already running",
status: "running",
position: 2,
},
],
statuses: ["ready", "running", "done"],
});
await gateway.emitGatewayEvent("plugin.workboard.changed", {
epoch: "plugin-widget-e2e",
revision: 2,
});
await expect
.poll(async () =>
(await miniWidget.locator('[title="Running"]').textContent())
?.replace(/\s+/gu, " ")
.trim(),
)
.toBe("2 Running");
if (recordProof) {
await page.screenshot({
path: path.join(pluginWidgetsProofDir, "02-plugin-widgets-running.png"),
});
}
} finally {
const video = page.video();
await context.close();
if (recordProof && video) {
await video.saveAs(path.join(pluginWidgetsProofDir, "workboard-plugin-widgets.webm"));
}
}
});
it("links a dispatched Workboard card and its live session dashboard in both directions", async () => {
const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1";
if (recordProof) {
+14
View File
@@ -2671,9 +2671,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;
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;
}
+112
View File
@@ -0,0 +1,112 @@
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`<div class="workboard-widget__state" role="alert">
<span>${this.error}</span>
<button class="btn btn--sm" type="button" @click=${() => this.retryLoad()}>
${t("common.retry")}
</button>
</div>`;
}
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;
}
}
+100
View File
@@ -0,0 +1,100 @@
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`<div class="workboard-widget__state" role="alert">
<span>${this.error}</span>
<button class="btn btn--sm" type="button" @click=${() => this.retryLoad()}>
${t("common.retry")}
</button>
</div>`;
}
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,205 @@
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.client = null;
this.loaded = false;
this.loadAttempted = false;
this.loading = false;
this.error = "";
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 retryLoad(): void {
void this.refresh(true);
}
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 && generation === this.refreshGeneration && 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?.();
}
}
+277
View File
@@ -0,0 +1,277 @@
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("retries a transient initial list failure without reconnecting", async () => {
const request = vi
.fn()
.mockRejectedValueOnce(new Error("temporary failure"))
.mockResolvedValueOnce({ cards, statuses: ["ready", "running", "done"] });
const element = document.createElement("openclaw-workboard-mini-widget");
element.widget = pluginWidget("workboard:mini", { boardId: "ops" });
element.sessionKey = "agent:main:test";
await mount(element, createContext(request), request);
expect(element.textContent).toContain("temporary failure");
const retry = element.querySelector("button");
expect(retry?.textContent?.trim()).toBe("Retry");
retry?.click();
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(element.textContent).toContain("Running card"));
});
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));
});
it("restarts loading after reconnecting while the previous request is pending", 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 element = document.createElement("openclaw-workboard-mini-widget");
element.widget = pluginWidget("workboard:mini", { boardId: "ops" });
const provider = createApplicationContextProvider(createContext(request));
provider.append(element);
document.body.append(provider);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
element.remove();
provider.append(element);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(element.textContent).toContain("Running card"));
firstList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
});
it("keeps a queued refresh owned by the current gateway generation", async () => {
const staleList = deferred<unknown>();
const currentList = deferred<unknown>();
const staleRequest = vi.fn(async () => await staleList.promise);
const currentRequest = vi.fn(async (method: string) => {
if (method !== "workboard.cards.list") {
throw new Error(`Unexpected method: ${method}`);
}
return currentRequest.mock.calls.length === 1
? await currentList.promise
: { cards, statuses: ["ready", "running", "done"] };
});
const currentEvents: {
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(staleRequest));
provider.append(element);
document.body.append(provider);
await vi.waitFor(() => expect(staleRequest).toHaveBeenCalledTimes(1));
provider.setContext(createContext(currentRequest, currentEvents));
await vi.waitFor(() => expect(currentRequest).toHaveBeenCalledTimes(1));
currentEvents.listener?.({
type: "event",
event: "plugin.workboard.changed",
payload: { epoch: "epoch-current", revision: 2 },
});
staleList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
await Promise.resolve();
currentList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
await vi.waitFor(() => expect(currentRequest).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(element.textContent).toContain("Running card"));
});
});
+145
View File
@@ -401,6 +401,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);
+7
View File
@@ -54,6 +54,11 @@ export type ControlUiMockGatewayScenario = {
label: string;
pluginId: string;
}>;
controlUiWidgetKinds?: Array<{
kind: string;
label: string;
pluginId: string;
}>;
featureCapabilities?: string[];
defaultAgentId?: string;
deferredMethods?: string[];
@@ -271,6 +276,7 @@ function normalizeScenario(
assistantName: scenario.assistantName?.trim() || "OpenClaw",
basePath,
controlUiTabs: scenario.controlUiTabs ?? [],
controlUiWidgetKinds: scenario.controlUiWidgetKinds ?? [],
featureCapabilities: scenario.featureCapabilities ?? [],
defaultAgentId,
deferredMethods: scenario.deferredMethods ?? [],
@@ -847,6 +853,7 @@ function installControlUiMockGateway(input: {
methods: scenario.featureMethods,
},
controlUiTabs: scenario.controlUiTabs,
controlUiWidgetKinds: scenario.controlUiWidgetKinds,
protocol: protocolVersion,
server: { connId: "control-ui-e2e", version: "e2e" },
snapshot: {