diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 3cc5a6d88732..d268a4ae4aaf 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 10a4d2626be8..98f2517e82b9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -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) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index e5cfdb48d758..7682a82b5f78 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -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 } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift index 4ba137f29c51..ddd61bde9fa6 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCacheContracts.swift @@ -393,6 +393,7 @@ extension OpenClawChatCommandOutbox { false } + // periphery:ignore - protocol-typed callers require this forwarding convenience overload. public func markCommandRetriedIfPresent( id: String, expectation: OpenClawChatOutboxRetryExpectation, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift index 9fc221b8cc4b..c694a315a170 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Outbox.swift @@ -1215,7 +1215,7 @@ extension OpenClawChatViewModel { case .awaitingConfirmation: .confirming case .failed: - .failed(reason: command.lastError) + .failed(reason: OpenClawChatSQLiteTranscriptCache.outboxDisplayError(command.lastError)) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 8d51880fcc64..0a4c9abe3181 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -176,12 +176,8 @@ public final class OpenClawChatViewModel { @ObservationIgnored var outboxBranchReconcileRetryTasks: [OpenClawChatOutboxScope: Task] = [:] @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, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index f81a91b28f7b..c41bc7ab3c4b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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) } } diff --git a/config/knip.config.ts b/config/knip.config.ts index 9b5b574b6da9..3c1964c83302 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -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`]: { diff --git a/extensions/workboard/index.ts b/extensions/workboard/index.ts index 2e4a25dbb29e..c22d1e027a4b 100644 --- a/extensions/workboard/index.ts +++ b/extensions/workboard/index.ts @@ -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)); diff --git a/packages/gateway-protocol/src/schema/board.test.ts b/packages/gateway-protocol/src/schema/board.test.ts index 98ab3af645be..f5bcc9562d29 100644 --- a/packages/gateway-protocol/src/schema/board.test.ts +++ b/packages/gateway-protocol/src/schema/board.test.ts @@ -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, { diff --git a/packages/gateway-protocol/src/schema/board.ts b/packages/gateway-protocol/src/schema/board.ts index e5a062228007..b6cddd86b19f 100644 --- a/packages/gateway-protocol/src/schema/board.ts +++ b/packages/gateway-protocol/src/schema/board.ts @@ -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; export type BoardWidgetMaterializedContent = | Static - | (Static & { interactive: boolean }); + | (Static & { interactive: boolean }) + | Static; export const BoardCanvasDocumentSourceSchema = closedObject({ kind: Type.Literal("canvas-doc"), @@ -191,6 +204,7 @@ export type BoardCanvasDocumentSource = Static; diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index 137fbfea40f5..41b294ab1554 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -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), diff --git a/packages/gateway-protocol/src/schema/plugins.ts b/packages/gateway-protocol/src/schema/plugins.ts index ff2d1d944dd5..0069d572879b 100644 --- a/packages/gateway-protocol/src/schema/plugins.ts +++ b/packages/gateway-protocol/src/schema/plugins.ts @@ -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()), diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 912d5449713a..7f514c7e7fc1 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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, diff --git a/src/agents/tools/dashboard-tool.test.ts b/src/agents/tools/dashboard-tool.test.ts index 46da83d5d541..2a734383adf2 100644 --- a/src/agents/tools/dashboard-tool.test.ts +++ b/src/agents/tools/dashboard-tool.test.ts @@ -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" }], diff --git a/src/agents/tools/dashboard-tool.ts b/src/agents/tools/dashboard-tool.ts index daf04e1d87a2..d6de5ec4e57b 100644 --- a/src/agents/tools/dashboard-tool.ts +++ b/src/agents/tools/dashboard-tool.ts @@ -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 { return tabId; } +function readOptionalTabId(params: Record): 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): Record | 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; +} + function opForAction(action: string, params: Record): 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; @@ -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 : 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("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("board.update", { sessionKey, diff --git a/src/boards/board-layout.ts b/src/boards/board-layout.ts index 6418c0360e14..92cad0c3b81a 100644 --- a/src/boards/board-layout.ts +++ b/src/boards/board-layout.ts @@ -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, diff --git a/src/boards/board-store.parity.test.ts b/src/boards/board-store.parity.test.ts index 3766c0e12a52..6a189b458e39 100644 --- a/src/boards/board-store.parity.test.ts +++ b/src/boards/board-store.parity.test.ts @@ -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({ diff --git a/src/boards/board-store.ts b/src/boards/board-store.ts index 1fd5d88d510b..41c8cd3c8ffe 100644 --- a/src/boards/board-store.ts +++ b/src/boards/board-store.ts @@ -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); } diff --git a/src/boards/sqlite-board-codec.ts b/src/boards/sqlite-board-codec.ts index 6fefc59c5c07..c3f819c67951 100644 --- a/src/boards/sqlite-board-codec.ts +++ b/src/boards/sqlite-board-codec.ts @@ -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; +}; + 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, + 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, @@ -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, diff --git a/src/boards/sqlite-board-store.ts b/src/boards/sqlite-board-store.ts index 72c44e6eb6df..2f15ac7e6ff9 100644 --- a/src/boards/sqlite-board-store.ts +++ b/src/boards/sqlite-board-store.ts @@ -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(); +const presentBoardDatabases = new WeakSet(); // 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): 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): 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, - 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(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(transactionDatabase.db); - const fields = contentFields( + const fields = createBoardWidgetContentFields( canonicalParams, { presentation: widget.presentation, heightMode: widget.heightMode }, widget.revision, diff --git a/src/gateway/control-ui-plugin-tabs.test.ts b/src/gateway/control-ui-plugin-tabs.test.ts index 5daf96718bb6..e65f1db3bc1c 100644 --- a/src/gateway/control-ui-plugin-tabs.test.ts +++ b/src/gateway/control-ui-plugin-tabs.test.ts @@ -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( [ diff --git a/src/gateway/control-ui-plugin-tabs.ts b/src/gateway/control-ui-plugin-tabs.ts index 807874ed2e96..523e9c5f6f96 100644 --- a/src/gateway/control-ui-plugin-tabs.ts +++ b/src/gateway/control-ui-plugin-tabs.ts @@ -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[], diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index bd4058cc644a..7f7af85ed648 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -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, diff --git a/src/plugins/host-hooks.ts b/src/plugins/host-hooks.ts index 3d334019dfff..3985086284ef 100644 --- a/src/plugins/host-hooks.ts +++ b/src/plugins/host-hooks.ts @@ -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; diff --git a/src/plugins/registry-registrars-host.ts b/src/plugins/registry-registrars-host.ts index 16b40d6295ea..6eefef67f8a4 100644 --- a/src/plugins/registry-registrars-host.ts +++ b/src/plugins/registry-registrars-host.ts @@ -35,6 +35,7 @@ const controlUiSurfaces = new Set([ "run", "settings", "tab", + "widget", ]); function normalizeHostHookString(value: unknown): string { diff --git a/src/plugins/registry.control-ui.test.ts b/src/plugins/registry.control-ui.test.ts index a330d6a7d867..e97bb2873cc4 100644 --- a/src/plugins/registry.control-ui.test.ts +++ b/src/plugins/registry.control-ui.test.ts @@ -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(); diff --git a/src/state/openclaw-agent-board-schema.ts b/src/state/openclaw-agent-board-schema.ts index 4ea041c8a1e1..876aef4ed310 100644 --- a/src/state/openclaw-agent-board-schema.ts +++ b/src/state/openclaw-agent-board-schema.ts @@ -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. +} diff --git a/src/state/openclaw-agent-schema.generated.ts b/src/state/openclaw-agent-schema.generated.ts index f8e67d23e106..254f47f65712 100644 --- a/src/state/openclaw-agent-schema.generated.ts +++ b/src/state/openclaw-agent-schema.generated.ts @@ -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; diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index ca041ae3ad8e..afdf06a78c27 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -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; diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 266fa26aa7a8..c8ef1d91a581 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -124,6 +124,7 @@ function isTrustedRetryEndpoint(url: string): boolean { } export type GatewayControlUiPluginTab = NonNullable[number]; +export type GatewayControlUiPluginWidgetKind = NonNullable[number]; export type GatewayHelloOk = Omit & { server?: Partial; features?: Partial; diff --git a/ui/src/components/board/board-widget-cell-plugin.test.ts b/ui/src/components/board/board-widget-cell-plugin.test.ts new file mode 100644 index 000000000000..3108c6883c7c --- /dev/null +++ b/ui/src/components/board/board-widget-cell-plugin.test.ts @@ -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( + '[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(); + }); +}); diff --git a/ui/src/components/board/board-widget-cell-render.ts b/ui/src/components/board/board-widget-cell-render.ts index fc3d7b84c361..1fc202bc50f8 100644 --- a/ui/src/components/board/board-widget-cell-render.ts +++ b/ui/src/components/board/board-widget-cell-render.ts @@ -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` +
+ ${t("board.widget.disabledPlugin", { pluginId: options.pluginId })} + +
+ `; +} + +export function renderBoardWidgetError(error: unknown, onRetry?: () => void): TemplateResult { const message = error instanceof Error ? error.message : String(error); return html` `; } diff --git a/ui/src/components/board/board-widget-cell.ts b/ui/src/components/board/board-widget-cell.ts index a304d8e58065..035701bf5410 100644 --- a/ui/src/components/board/board-widget-cell.ts +++ b/ui/src/components/board/board-widget-cell.ts @@ -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`

${t("board.widget.pluginLoading")}

` + : 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`${widget.contentKind === "mcp-app" ? t("board.widget.kindMcp") - : t("board.widget.kindHtml")}`} ${widget.contentKind === "builtin" ? nothing : renderBoardGrantedCapabilities(widget)} ${readOnly diff --git a/ui/src/e2e/session-dashboard.e2e.test.ts b/ui/src/e2e/session-dashboard.e2e.test.ts index 62aacd14b65c..bf31c8fa037b 100644 --- a/ui/src/e2e/session-dashboard.e2e.test.ts +++ b/ui/src/e2e/session-dashboard.e2e.test.ts @@ -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 { 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) { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 65767dc43625..58172b1f6d6e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/lib/board/widgets/index.test.ts b/ui/src/lib/board/widgets/index.test.ts new file mode 100644 index 000000000000..dd58c1236f2a --- /dev/null +++ b/ui/src/lib/board/widgets/index.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/board/widgets/index.ts b/ui/src/lib/board/widgets/index.ts index b9bc90bb3625..b186ff49bd50 100644 --- a/ui/src/lib/board/widgets/index.ts +++ b/ui/src/lib/board/widgets/index.ts @@ -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; +}; + +/** + * 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 = { + "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>(); + const BUILTIN_WIDGET_RENDERERS: Record = { 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 { + 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; +} diff --git a/ui/src/lib/board/widgets/workboard-card.ts b/ui/src/lib/board/widgets/workboard-card.ts new file mode 100644 index 000000000000..7f9622f541f8 --- /dev/null +++ b/ui/src/lib/board/widgets/workboard-card.ts @@ -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 { + 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``; + } + if (this.loading && !this.loaded) { + return html`

${t("workboard.widget.loading")}

`; + } + if (this.error) { + return html``; + } + const card = this.cards.find((candidate) => candidate.id === cardId); + if (!card) { + return html`

${t("workboard.widget.cardMissing")}

`; + } + 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` +
+
+ ${card.title} + + ${t(`workboard.status.${card.status}`)} + +
+
+
+
${t("workboard.fieldPriority")}
+
${priority}
+
+
+
${t("workboard.fieldAgent")}
+
${card.agentId ?? t("workboard.widget.unassigned")}
+
+
+ ${statuses.length > 1 + ? html` + + ` + : nothing} +
+ `; + } +} + +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` + +`; + +declare global { + interface HTMLElementTagNameMap { + "openclaw-workboard-card-widget": OpenClawWorkboardCardWidget; + } +} diff --git a/ui/src/lib/board/widgets/workboard-mini.ts b/ui/src/lib/board/widgets/workboard-mini.ts new file mode 100644 index 000000000000..e134bf6aebcf --- /dev/null +++ b/ui/src/lib/board/widgets/workboard-mini.ts @@ -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`

${t("workboard.widget.loading")}

`; + } + if (this.error) { + return html``; + } + 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` +
+
+ ${boardId} + ${t("workboard.widget.openBoard")} +
+
+ ${WORKBOARD_STATUSES.map( + (status) => html` + + ${cards.filter((card) => card.status === status).length} + ${t(`workboard.status.${status}`)} + + `, + )} +
+
+ ${topCards.length > 0 + ? topCards.map( + (card) => html` +
+ + ${t(`workboard.status.${card.status}`)} + + ${card.title} +
+ `, + ) + : html`

${t("workboard.widget.noActiveCards")}

`} +
+
+ `; + } +} + +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` + +`; + +declare global { + interface HTMLElementTagNameMap { + "openclaw-workboard-mini-widget": OpenClawWorkboardMiniWidget; + } +} diff --git a/ui/src/lib/board/widgets/workboard-widget.ts b/ui/src/lib/board/widgets/workboard-widget.ts new file mode 100644 index 000000000000..46e9d75a62dc --- /dev/null +++ b/ui/src/lib/board/widgets/workboard-widget.ts @@ -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 | 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 { + 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 { + 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?.(); + } +} diff --git a/ui/src/lib/board/widgets/workboard.test.ts b/ui/src/lib/board/widgets/workboard.test.ts new file mode 100644 index 000000000000..3ebe3208e33a --- /dev/null +++ b/ui/src/lib/board/widgets/workboard.test.ts @@ -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): 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, + events?: { listener?: Parameters[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[0], + ) => { + if (events) { + events.listener = listener; + } + return () => undefined; + }, + } as unknown as ApplicationContext["gateway"], + } as unknown as ApplicationContext; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +async function mount( + element: T, + context: ApplicationContext, + request: ReturnType, +): Promise { + 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 }).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(); + 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[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(); + 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(); + const currentList = deferred(); + 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[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")); + }); +}); diff --git a/ui/src/styles/board.css b/ui/src/styles/board.css index 060b97db5f90..6d45155c2be8 100644 --- a/ui/src/styles/board.css +++ b/ui/src/styles/board.css @@ -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); diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 4b011528b0a9..391444e0e16d 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -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: {