mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(dashboard): plugin widget kinds — native WorkBoard card and mini-board widgets (#112434)
* feat(board): add plugin widget kinds * feat(ui): render native Workboard widgets * fix(dashboard): compose plugin widgets with current main * chore: internalize widget-kind contribution types * fix(ui): retry plugin widget renderer loads * fix(ui): harden Workboard widget refresh lifecycle * fix(ci): clear plugin widget landing gates * fix(boards): migrate plugin widget storage * fix(db): migrate unreleased board widget constraint * fix(ui): retry failed Workboard widget loads * fix(ui): keep stale widget refresh cleanup inert * fix(ci): align plugin widget landing guards
This commit is contained in:
committed by
GitHub
parent
0baf0495ab
commit
0f066eec81
@@ -38771,7 +38771,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 692,
|
||||
"line": 688,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Add Image",
|
||||
"surface": "apple",
|
||||
@@ -38779,7 +38779,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 693,
|
||||
"line": 689,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Attachments",
|
||||
"surface": "apple",
|
||||
@@ -38787,7 +38787,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 886,
|
||||
"line": 882,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Start",
|
||||
"surface": "apple",
|
||||
@@ -38795,7 +38795,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 886,
|
||||
"line": 882,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Stop",
|
||||
"surface": "apple",
|
||||
@@ -38803,7 +38803,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 997,
|
||||
"line": 993,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Loading commands",
|
||||
"surface": "apple",
|
||||
@@ -38811,7 +38811,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1006,
|
||||
"line": 1002,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Commands unavailable",
|
||||
"surface": "apple",
|
||||
@@ -38819,7 +38819,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1015,
|
||||
"line": 1011,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Retry",
|
||||
"surface": "apple",
|
||||
@@ -38827,7 +38827,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 1024,
|
||||
"line": 1020,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "No matching commands",
|
||||
"surface": "apple",
|
||||
@@ -38835,7 +38835,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1256,
|
||||
"line": 1252,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Stop response",
|
||||
"surface": "apple",
|
||||
@@ -38843,7 +38843,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1281,
|
||||
"line": 1277,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Send message",
|
||||
"surface": "apple",
|
||||
@@ -38851,7 +38851,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 1295,
|
||||
"line": 1291,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
@@ -38859,7 +38859,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1421,
|
||||
"line": 1417,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
|
||||
"source": "Message…",
|
||||
"surface": "apple",
|
||||
@@ -40627,7 +40627,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 1212,
|
||||
"line": 1208,
|
||||
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift",
|
||||
"source": "Remove attachments or wait for delivery to resolve before switching chats.",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -639,10 +639,6 @@ struct OpenClawChatComposer: View {
|
||||
String(AttributedString(localized: "^[\(count) message](inflect: true)").characters)
|
||||
}
|
||||
|
||||
private var messageSessionActionsDisabled: Bool {
|
||||
!self.viewModel.canPerformMessageSessionAction
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var attachmentPicker: some View {
|
||||
#if os(macOS)
|
||||
|
||||
@@ -582,6 +582,7 @@ public struct OpenClawChatSessionBranch: Codable, Sendable, Equatable, Identifia
|
||||
self.leafEntryId
|
||||
}
|
||||
|
||||
// periphery:ignore - package tests construct branch fixtures; app consumers decode them.
|
||||
public init(
|
||||
leafEntryId: String,
|
||||
headline: String,
|
||||
@@ -600,6 +601,7 @@ public struct OpenClawChatSessionBranch: Codable, Sendable, Equatable, Identifia
|
||||
public struct OpenClawChatSessionBranchesResponse: Codable, Sendable {
|
||||
public let branches: [OpenClawChatSessionBranch]
|
||||
|
||||
// periphery:ignore - package tests construct branch fixtures; app consumers decode them.
|
||||
public init(branches: [OpenClawChatSessionBranch]) {
|
||||
self.branches = branches
|
||||
}
|
||||
|
||||
@@ -393,6 +393,7 @@ extension OpenClawChatCommandOutbox {
|
||||
false
|
||||
}
|
||||
|
||||
// periphery:ignore - protocol-typed callers require this forwarding convenience overload.
|
||||
public func markCommandRetriedIfPresent(
|
||||
id: String,
|
||||
expectation: OpenClawChatOutboxRetryExpectation,
|
||||
|
||||
@@ -1215,7 +1215,7 @@ extension OpenClawChatViewModel {
|
||||
case .awaitingConfirmation:
|
||||
.confirming
|
||||
case .failed:
|
||||
.failed(reason: command.lastError)
|
||||
.failed(reason: OpenClawChatSQLiteTranscriptCache.outboxDisplayError(command.lastError))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,12 +176,8 @@ public final class OpenClawChatViewModel {
|
||||
@ObservationIgnored
|
||||
var outboxBranchReconcileRetryTasks: [OpenClawChatOutboxScope: Task<Void, Never>] = [:]
|
||||
@ObservationIgnored
|
||||
var outboxBranchReconcileRetryDelaysMs: [UInt64] = [250, 1000, 4000, 16000, 30000]
|
||||
@ObservationIgnored
|
||||
var outboxBranchConnectionGeneration: UInt64 = 0
|
||||
@ObservationIgnored
|
||||
var hasEstablishedTransportHealth = false
|
||||
@ObservationIgnored
|
||||
var bootstrapOutboxBranchStateCapture: (
|
||||
generation: UInt64,
|
||||
session: SessionSnapshot,
|
||||
|
||||
@@ -254,6 +254,8 @@ public struct BoardWidget: Codable, Sendable {
|
||||
public let tabid: String
|
||||
public let title: String?
|
||||
public let contentkind: AnyCodable
|
||||
public let pluginkind: String?
|
||||
public let props: [String: AnyCodable]?
|
||||
public let presentation: AnyCodable?
|
||||
public let heightmode: AnyCodable?
|
||||
public let sizew: Int
|
||||
@@ -277,6 +279,8 @@ public struct BoardWidget: Codable, Sendable {
|
||||
tabid: String,
|
||||
title: String? = nil,
|
||||
contentkind: AnyCodable,
|
||||
pluginkind: String? = nil,
|
||||
props: [String: AnyCodable]? = nil,
|
||||
presentation: AnyCodable? = nil,
|
||||
heightmode: AnyCodable? = nil,
|
||||
sizew: Int,
|
||||
@@ -299,6 +303,8 @@ public struct BoardWidget: Codable, Sendable {
|
||||
self.tabid = tabid
|
||||
self.title = title
|
||||
self.contentkind = contentkind
|
||||
self.pluginkind = pluginkind
|
||||
self.props = props
|
||||
self.presentation = presentation
|
||||
self.heightmode = heightmode
|
||||
self.sizew = sizew
|
||||
@@ -323,6 +329,8 @@ public struct BoardWidget: Codable, Sendable {
|
||||
case tabid = "tabId"
|
||||
case title
|
||||
case contentkind = "contentKind"
|
||||
case pluginkind = "pluginKind"
|
||||
case props
|
||||
case presentation
|
||||
case heightmode = "heightMode"
|
||||
case sizew = "sizeW"
|
||||
@@ -637,6 +645,28 @@ public struct BoardWidgetMcpAppPutContent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetPluginContent: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let pluginkind: String
|
||||
public let props: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
kind: String,
|
||||
pluginkind: String,
|
||||
props: [String: AnyCodable]? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.pluginkind = pluginkind
|
||||
self.props = props
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case pluginkind = "pluginKind"
|
||||
case props
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardCanvasDocumentSource: Codable, Sendable {
|
||||
public let kind: String
|
||||
public let docid: String
|
||||
@@ -1046,6 +1076,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
public let features: [String: AnyCodable]
|
||||
public let snapshot: Snapshot
|
||||
public let controluitabs: [[String: AnyCodable]]?
|
||||
public let controluiwidgetkinds: [[String: AnyCodable]]?
|
||||
public let pluginsurfaceurls: [String: AnyCodable]?
|
||||
public let auth: [String: AnyCodable]
|
||||
public let policy: [String: AnyCodable]
|
||||
@@ -1057,6 +1088,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
features: [String: AnyCodable],
|
||||
snapshot: Snapshot,
|
||||
controluitabs: [[String: AnyCodable]]? = nil,
|
||||
controluiwidgetkinds: [[String: AnyCodable]]? = nil,
|
||||
pluginsurfaceurls: [String: AnyCodable]? = nil,
|
||||
auth: [String: AnyCodable],
|
||||
policy: [String: AnyCodable])
|
||||
@@ -1067,6 +1099,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
self.features = features
|
||||
self.snapshot = snapshot
|
||||
self.controluitabs = controluitabs
|
||||
self.controluiwidgetkinds = controluiwidgetkinds
|
||||
self.pluginsurfaceurls = pluginsurfaceurls
|
||||
self.auth = auth
|
||||
self.policy = policy
|
||||
@@ -1079,6 +1112,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
case features
|
||||
case snapshot
|
||||
case controluitabs = "controlUiTabs"
|
||||
case controluiwidgetkinds = "controlUiWidgetKinds"
|
||||
case pluginsurfaceurls = "pluginSurfaceUrls"
|
||||
case auth
|
||||
case policy
|
||||
@@ -16007,6 +16041,7 @@ public enum BoardOp: Codable, Sendable {
|
||||
public enum BoardWidgetContent: Codable, Sendable {
|
||||
case html(BoardWidgetHtmlContent)
|
||||
case mcpApp(BoardWidgetMcpAppContent)
|
||||
case plugin(BoardWidgetPluginContent)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "kind"
|
||||
@@ -16018,6 +16053,7 @@ public enum BoardWidgetContent: Codable, Sendable {
|
||||
switch discriminator {
|
||||
case "html": self = try .html(BoardWidgetHtmlContent(from: decoder))
|
||||
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppContent(from: decoder))
|
||||
case "plugin": self = try .plugin(BoardWidgetPluginContent(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
@@ -16031,6 +16067,7 @@ public enum BoardWidgetContent: Codable, Sendable {
|
||||
switch self {
|
||||
case .html(let value): try value.encode(to: encoder)
|
||||
case .mcpApp(let value): try value.encode(to: encoder)
|
||||
case .plugin(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16038,6 +16075,7 @@ public enum BoardWidgetContent: Codable, Sendable {
|
||||
public enum BoardWidgetPutContent: Codable, Sendable {
|
||||
case html(BoardWidgetHtmlContent)
|
||||
case mcpApp(BoardWidgetMcpAppPutContent)
|
||||
case plugin(BoardWidgetPluginContent)
|
||||
case canvasDoc(BoardCanvasDocumentSource)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -16050,6 +16088,7 @@ public enum BoardWidgetPutContent: Codable, Sendable {
|
||||
switch discriminator {
|
||||
case "html": self = try .html(BoardWidgetHtmlContent(from: decoder))
|
||||
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppPutContent(from: decoder))
|
||||
case "plugin": self = try .plugin(BoardWidgetPluginContent(from: decoder))
|
||||
case "canvas-doc": self = try .canvasDoc(BoardCanvasDocumentSource(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
@@ -16064,6 +16103,7 @@ public enum BoardWidgetPutContent: Codable, Sendable {
|
||||
switch self {
|
||||
case .html(let value): try value.encode(to: encoder)
|
||||
case .mcpApp(let value): try value.encode(to: encoder)
|
||||
case .plugin(let value): try value.encode(to: encoder)
|
||||
case .canvasDoc(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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`]: {
|
||||
|
||||
@@ -17,6 +17,18 @@ export default definePluginEntry({
|
||||
description: "Dashboard workboard for agent-owned issues and sessions.",
|
||||
register(api) {
|
||||
const store = WorkboardStore.openSqlite();
|
||||
api.session.controls.registerControlUiDescriptor({
|
||||
surface: "widget",
|
||||
id: "card",
|
||||
label: "Workboard card",
|
||||
requiredScopes: ["operator.write"],
|
||||
});
|
||||
api.session.controls.registerControlUiDescriptor({
|
||||
surface: "widget",
|
||||
id: "mini",
|
||||
label: "Workboard summary",
|
||||
requiredScopes: ["operator.read"],
|
||||
});
|
||||
registerWorkboardGatewayMethods({ api, store });
|
||||
registerWorkboardCommand({ api, store });
|
||||
api.registerService(createWorkboardChangeEventService(store));
|
||||
|
||||
@@ -128,6 +128,31 @@ describe("BoardSnapshotSchema", () => {
|
||||
});
|
||||
|
||||
describe("BoardWidgetPutParamsSchema", () => {
|
||||
it("accepts bounded plugin widget input shapes", () => {
|
||||
const pluginWidget = {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "work-item",
|
||||
content: {
|
||||
kind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "card-123" },
|
||||
},
|
||||
};
|
||||
expect(Value.Check(BoardWidgetPutParamsSchema, pluginWidget)).toBe(true);
|
||||
expect(
|
||||
Value.Check(BoardWidgetPutParamsSchema, {
|
||||
...pluginWidget,
|
||||
content: { ...pluginWidget.content, pluginKind: "missing-separator" },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(BoardWidgetPutParamsSchema, {
|
||||
...pluginWidget,
|
||||
content: { ...pluginWidget.content, props: ["not", "an", "object"] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a gateway-resolved canvas document source", () => {
|
||||
expect(
|
||||
Value.Check(BoardWidgetPutParamsSchema, {
|
||||
|
||||
@@ -7,6 +7,10 @@ export const BoardTabIdSchema = Type.String({ pattern: "^[a-z0-9-]{1,40}$" });
|
||||
export const BoardWidgetNameSchema = Type.String({
|
||||
pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
|
||||
});
|
||||
export const BoardWidgetPluginKindSchema = Type.String({
|
||||
pattern: "^[a-z0-9][a-z0-9-]{0,63}:[a-z0-9][a-z0-9._-]{0,63}$",
|
||||
});
|
||||
export const BoardWidgetPluginPropsSchema = Type.Record(Type.String(), Type.Unknown());
|
||||
export const BoardChatDockSchema = Type.Union([
|
||||
Type.Literal("left"),
|
||||
Type.Literal("right"),
|
||||
@@ -60,7 +64,9 @@ export const BoardWidgetSchema = closedObject({
|
||||
name: BoardWidgetNameSchema,
|
||||
tabId: BoardTabIdSchema,
|
||||
title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
|
||||
contentKind: Type.Union([Type.Literal("html"), Type.Literal("mcp-app")]),
|
||||
contentKind: Type.Union([Type.Literal("html"), Type.Literal("mcp-app"), Type.Literal("plugin")]),
|
||||
pluginKind: Type.Optional(BoardWidgetPluginKindSchema),
|
||||
props: Type.Optional(BoardWidgetPluginPropsSchema),
|
||||
presentation: Type.Optional(BoardWidgetPresentationSchema),
|
||||
heightMode: Type.Optional(BoardWidgetHeightModeSchema),
|
||||
sizeW: Type.Integer({ minimum: 1, maximum: 12 }),
|
||||
@@ -173,14 +179,21 @@ export const BoardWidgetMcpAppPutContentSchema = closedObject({
|
||||
kind: Type.Literal("mcp-app"),
|
||||
viewId: NonEmptyString,
|
||||
});
|
||||
export const BoardWidgetPluginContentSchema = closedObject({
|
||||
kind: Type.Literal("plugin"),
|
||||
pluginKind: BoardWidgetPluginKindSchema,
|
||||
props: Type.Optional(BoardWidgetPluginPropsSchema),
|
||||
});
|
||||
export const BoardWidgetContentSchema = Type.Union([
|
||||
BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContentSchema,
|
||||
BoardWidgetPluginContentSchema,
|
||||
]);
|
||||
export type BoardWidgetContent = Static<typeof BoardWidgetContentSchema>;
|
||||
export type BoardWidgetMaterializedContent =
|
||||
| Static<typeof BoardWidgetHtmlContentSchema>
|
||||
| (Static<typeof BoardWidgetMcpAppContentSchema> & { interactive: boolean });
|
||||
| (Static<typeof BoardWidgetMcpAppContentSchema> & { interactive: boolean })
|
||||
| Static<typeof BoardWidgetPluginContentSchema>;
|
||||
|
||||
export const BoardCanvasDocumentSourceSchema = closedObject({
|
||||
kind: Type.Literal("canvas-doc"),
|
||||
@@ -191,6 +204,7 @@ export type BoardCanvasDocumentSource = Static<typeof BoardCanvasDocumentSourceS
|
||||
export const BoardWidgetPutContentSchema = Type.Union([
|
||||
BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppPutContentSchema,
|
||||
BoardWidgetPluginContentSchema,
|
||||
BoardCanvasDocumentSourceSchema,
|
||||
]);
|
||||
export type BoardWidgetPutContent = Static<typeof BoardWidgetPutContentSchema>;
|
||||
|
||||
@@ -101,6 +101,16 @@ export const HelloOkSchema = closedObject({
|
||||
}),
|
||||
),
|
||||
),
|
||||
// Additive: active plugin widget kinds whose renderers ship in the trusted UI bundle.
|
||||
controlUiWidgetKinds: Type.Optional(
|
||||
Type.Array(
|
||||
closedObject({
|
||||
pluginId: NonEmptyString,
|
||||
kind: NonEmptyString,
|
||||
label: NonEmptyString,
|
||||
}),
|
||||
),
|
||||
),
|
||||
pluginSurfaceUrls: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
|
||||
auth: closedObject({
|
||||
deviceToken: Type.Optional(NonEmptyString),
|
||||
|
||||
@@ -23,6 +23,8 @@ export const PluginControlUiDescriptorSchema = closedObject({
|
||||
Type.Literal("tool"),
|
||||
Type.Literal("run"),
|
||||
Type.Literal("settings"),
|
||||
Type.Literal("tab"),
|
||||
Type.Literal("widget"),
|
||||
]),
|
||||
label: NonEmptyString,
|
||||
description: Type.Optional(Type.String()),
|
||||
|
||||
@@ -182,6 +182,7 @@ import {
|
||||
BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContentSchema,
|
||||
BoardWidgetMcpAppPutContentSchema,
|
||||
BoardWidgetPluginContentSchema,
|
||||
BoardWidgetMoveOpSchema,
|
||||
BoardWidgetPutContentSchema,
|
||||
BoardWidgetPutParamsSchema,
|
||||
@@ -628,6 +629,7 @@ export const ProtocolSchemas = {
|
||||
BoardWidgetHtmlContent: BoardWidgetHtmlContentSchema,
|
||||
BoardWidgetMcpAppContent: BoardWidgetMcpAppContentSchema,
|
||||
BoardWidgetMcpAppPutContent: BoardWidgetMcpAppPutContentSchema,
|
||||
BoardWidgetPluginContent: BoardWidgetPluginContentSchema,
|
||||
BoardCanvasDocumentSource: BoardCanvasDocumentSourceSchema,
|
||||
BoardWidgetContent: BoardWidgetContentSchema,
|
||||
BoardWidgetPutContent: BoardWidgetPutContentSchema,
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -25,6 +25,7 @@ const DASHBOARD_ACTIONS = [
|
||||
"tab_update",
|
||||
"tab_delete",
|
||||
"tabs_reorder",
|
||||
"widget_put",
|
||||
"widget_move",
|
||||
"widget_resize",
|
||||
"widget_remove",
|
||||
@@ -34,6 +35,8 @@ const DASHBOARD_ACTIONS = [
|
||||
const BOARD_TAB_ID_PATTERN = "^[a-z0-9-]{1,40}$";
|
||||
const BOARD_TAB_ID_REGEX = /^[a-z0-9-]{1,40}$/;
|
||||
const BOARD_WIDGET_NAME_PATTERN = "^[a-z0-9][a-z0-9._-]{0,63}$";
|
||||
const BOARD_PLUGIN_KIND_PATTERN = "^[a-z0-9][a-z0-9-]{0,63}:[a-z0-9][a-z0-9._-]{0,63}$";
|
||||
const BOARD_PLUGIN_KIND_REGEX = /^[a-z0-9][a-z0-9-]{0,63}:[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
const DashboardToolSchema = Type.Object(
|
||||
{
|
||||
@@ -65,6 +68,18 @@ const DashboardToolSchema = Type.Object(
|
||||
),
|
||||
sizeW: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })),
|
||||
sizeH: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
|
||||
size: Type.Optional(Type.String({ enum: ["sm", "md", "lg", "xl", "full"] })),
|
||||
pluginKind: Type.Optional(
|
||||
Type.String({
|
||||
pattern: BOARD_PLUGIN_KIND_PATTERN,
|
||||
description: "Plugin widget kind, for example workboard:card or workboard:mini",
|
||||
}),
|
||||
),
|
||||
props: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
description: "Plugin-owned JSON props (maximum 8KB encoded)",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -125,6 +140,25 @@ function readTabId(params: Record<string, unknown>): string {
|
||||
return tabId;
|
||||
}
|
||||
|
||||
function readOptionalTabId(params: Record<string, unknown>): string | undefined {
|
||||
const tabId = readStringParam(params, "tabId");
|
||||
if (tabId !== undefined && !BOARD_TAB_ID_REGEX.test(tabId)) {
|
||||
throw new ToolInputError("tabId must be a lowercase slug up to 40 characters");
|
||||
}
|
||||
return tabId;
|
||||
}
|
||||
|
||||
function readPluginProps(params: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const props = params.props;
|
||||
if (props === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!props || typeof props !== "object" || Array.isArray(props)) {
|
||||
throw new ToolInputError("props must be an object");
|
||||
}
|
||||
return props as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function opForAction(action: string, params: Record<string, unknown>): BoardOp {
|
||||
const name = () => readStringParam(params, "name", { required: true });
|
||||
switch (action) {
|
||||
@@ -213,7 +247,7 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo
|
||||
label: "Dashboard",
|
||||
name: "dashboard",
|
||||
description:
|
||||
"Read and arrange this session dashboard. Widgets use stable names. Sizes: sm=3x3, md=6x4, lg=8x6, xl=12x8, full=12x8 single-widget emphasis.",
|
||||
"Read and arrange this session dashboard. Widgets use stable names. Create trusted plugin widgets with widget_put; examples: workboard:card props {cardId}, workboard:mini props {boardId, limit}. Sizes: sm=3x3, md=6x4, lg=8x6, xl=12x8, full=12x8 single-widget emphasis.",
|
||||
parameters: DashboardToolSchema,
|
||||
execute: async (_toolCallId, rawArgs) => {
|
||||
const params = rawArgs as Record<string, unknown>;
|
||||
@@ -246,6 +280,38 @@ export function createDashboardTool(opts: DashboardToolOptions = {}): AnyAgentTo
|
||||
delivered,
|
||||
});
|
||||
}
|
||||
if (action === "widget_put") {
|
||||
const pluginKind = readStringParam(params, "pluginKind", { required: true });
|
||||
if (!BOARD_PLUGIN_KIND_REGEX.test(pluginKind)) {
|
||||
throw new ToolInputError("pluginKind must use the <pluginId>:<name> format");
|
||||
}
|
||||
const title = readStringParam(params, "title");
|
||||
const tabId = readOptionalTabId(params);
|
||||
const size = readStringParam(params, "size");
|
||||
const after = readStringParam(params, "after");
|
||||
const props = readPluginProps(params);
|
||||
return snapshotResult(
|
||||
await gatewayCall<BoardSnapshot>("board.widget.put", {
|
||||
sessionKey,
|
||||
name: readStringParam(params, "name", { required: true }),
|
||||
...(title !== undefined ? { title } : {}),
|
||||
content: {
|
||||
kind: "plugin",
|
||||
pluginKind,
|
||||
...(props !== undefined ? { props } : {}),
|
||||
},
|
||||
...(tabId || size || after
|
||||
? {
|
||||
placement: {
|
||||
...(tabId ? { tabId } : {}),
|
||||
...(size ? { size } : {}),
|
||||
...(after ? { after } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return snapshotResult(
|
||||
await gatewayCall<BoardSnapshot>("board.update", {
|
||||
sessionKey,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -178,6 +178,54 @@ describe.each([
|
||||
expect(store.getSnapshot("agent:main:board").widgets[1]?.instanceId).toMatch(/^[a-f0-9]{32}$/u);
|
||||
});
|
||||
|
||||
it("round-trips plugin kinds and props without serving document bytes", () => {
|
||||
const store = createStore();
|
||||
const put = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "work-item",
|
||||
content: {
|
||||
kind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "card-123", compact: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(put.widgets[0]).toMatchObject({
|
||||
name: "work-item",
|
||||
contentKind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "card-123", compact: true },
|
||||
grantState: "none",
|
||||
});
|
||||
expect(put.widgets[0]).not.toHaveProperty("instanceId");
|
||||
expect(store.getSnapshot("agent:main:board").widgets[0]).toEqual(put.widgets[0]);
|
||||
expect(store.readWidgetHtml("agent:main:board", "work-item")).toBeUndefined();
|
||||
expect(store.readWidgetMcpApp("agent:main:board", "work-item")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects oversized plugin props and capability declarations", () => {
|
||||
const store = createStore();
|
||||
expect(() =>
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "too-large",
|
||||
content: {
|
||||
kind: "plugin",
|
||||
pluginKind: "workboard:mini",
|
||||
props: { value: "x".repeat(8 * 1024) },
|
||||
},
|
||||
}),
|
||||
).toThrow("props exceed 8192 UTF-8 bytes");
|
||||
expect(() =>
|
||||
store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "declared",
|
||||
content: { kind: "plugin", pluginKind: "workboard:card" },
|
||||
declared: { tools: ["workboard.cards.move"] },
|
||||
}),
|
||||
).toThrow("do not accept sandbox capability declarations");
|
||||
});
|
||||
|
||||
it("preserves grants only for unchanged bytes with equal or narrower declarations", () => {
|
||||
const store = createStore();
|
||||
const first = store.putWidget({
|
||||
@@ -531,6 +579,79 @@ describe("SqliteBoardStore persistence", () => {
|
||||
).toEqual({ name: "idx_agent_board_widgets_tab_position" });
|
||||
});
|
||||
|
||||
it("upgrades the unreleased v13 board constraint before storing plugin widgets", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-plugin-kind-schema-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionKey = "agent:main:board";
|
||||
seedSession(env, "main", sessionKey);
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId: "main", sessionKey }),
|
||||
env,
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "existing",
|
||||
content: { kind: "html", html: "preserved" },
|
||||
});
|
||||
|
||||
const opened = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
const schema = opened.db
|
||||
.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'board_widgets'")
|
||||
.get() as { sql: string };
|
||||
const legacySchema = schema.sql
|
||||
.replace(
|
||||
"content_kind IN ('html', 'mcp-app', 'plugin')",
|
||||
"content_kind IN ('html', 'mcp-app')",
|
||||
)
|
||||
.replace(
|
||||
/\s+OR\s+\(content_kind = 'plugin' AND html IS NULL AND descriptor_json IS NOT NULL AND view_generation IS NULL\)/u,
|
||||
"",
|
||||
);
|
||||
const legacyCreateSql = legacySchema.replace(
|
||||
/^CREATE TABLE board_widgets/u,
|
||||
"CREATE TABLE board_widgets_legacy",
|
||||
);
|
||||
opened.db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN IMMEDIATE;
|
||||
${legacyCreateSql};
|
||||
INSERT INTO board_widgets_legacy SELECT * FROM board_widgets;
|
||||
DROP TABLE board_widgets;
|
||||
ALTER TABLE board_widgets_legacy RENAME TO board_widgets;
|
||||
CREATE INDEX idx_agent_board_widgets_tab_position
|
||||
ON board_widgets(session_key, tab_id, position);
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
|
||||
const upgradedStore = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId: "main", sessionKey }),
|
||||
env,
|
||||
});
|
||||
expect(upgradedStore.getSnapshot(sessionKey).widgets).toEqual([
|
||||
expect.objectContaining({ name: "existing", contentKind: "html" }),
|
||||
]);
|
||||
upgradedStore.putWidget({
|
||||
sessionKey,
|
||||
name: "plugin",
|
||||
content: { kind: "plugin", pluginKind: "workboard:card", props: { cardId: "123" } },
|
||||
});
|
||||
|
||||
expect(upgradedStore.readWidgetHtml(sessionKey, "existing")?.html).toBe("preserved");
|
||||
expect(upgradedStore.getSnapshot(sessionKey).widgets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "existing", contentKind: "html" }),
|
||||
expect.objectContaining({
|
||||
name: "plugin",
|
||||
contentKind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "123" },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create an unregistered agent database during widget byte lookup", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-no-create-");
|
||||
const store = new SqliteBoardStore({
|
||||
|
||||
+64
-20
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Selectable } from "kysely";
|
||||
import type {
|
||||
BoardMcpAppDescriptor,
|
||||
BoardTab,
|
||||
BoardWidget,
|
||||
BoardWidgetDeclared,
|
||||
BoardWidgetMaterializedPutParams,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import type {
|
||||
BoardTabs as BoardTabRow,
|
||||
@@ -28,6 +30,11 @@ type ParsedBoardManifest = {
|
||||
mcpAppInstanceId?: string;
|
||||
};
|
||||
|
||||
type ParsedPluginContent = {
|
||||
pluginKind: string;
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function parseManifest(value: string): ParsedBoardManifest {
|
||||
const parsed = JSON.parse(value) as {
|
||||
netOrigins?: unknown;
|
||||
@@ -103,6 +110,73 @@ export function serializeManifest(
|
||||
});
|
||||
}
|
||||
|
||||
export function createBoardWidgetContentFields(
|
||||
params: BoardWidgetMaterializedPutParams,
|
||||
// Effective frame options come from the materialized widget, not the raw put
|
||||
// params: re-pins that omit them must keep the inherited persisted values.
|
||||
frame: Pick<BoardWidget, "presentation" | "heightMode">,
|
||||
revision: number,
|
||||
grantState: BoardWidget["grantState"],
|
||||
viewGeneration: string,
|
||||
now: number,
|
||||
) {
|
||||
const manifest = serializeManifest(
|
||||
params.declared,
|
||||
grantState,
|
||||
params.content.kind === "mcp-app"
|
||||
? { interactive: params.content.interactive, instanceId: viewGeneration }
|
||||
: undefined,
|
||||
frame,
|
||||
);
|
||||
if (params.content.kind === "html") {
|
||||
const sha256 = createHash("sha256").update(params.content.html).digest("hex");
|
||||
return {
|
||||
content_kind: "html",
|
||||
html: Buffer.from(params.content.html, "utf8"),
|
||||
descriptor_json: null,
|
||||
sha256,
|
||||
view_generation: viewGeneration,
|
||||
revision,
|
||||
manifest,
|
||||
grant_state: grantState,
|
||||
granted_sha: grantState === "granted" ? sha256 : null,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
if (params.content.kind === "plugin") {
|
||||
const descriptorJson = JSON.stringify({
|
||||
pluginKind: params.content.pluginKind,
|
||||
...(params.content.props !== undefined ? { props: params.content.props } : {}),
|
||||
});
|
||||
return {
|
||||
content_kind: "plugin",
|
||||
html: null,
|
||||
descriptor_json: descriptorJson,
|
||||
sha256: createHash("sha256").update(descriptorJson).digest("hex"),
|
||||
view_generation: null,
|
||||
revision,
|
||||
manifest,
|
||||
grant_state: "none",
|
||||
granted_sha: null,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
const descriptorJson = JSON.stringify(params.content.descriptor);
|
||||
const sha256 = createHash("sha256").update(descriptorJson).digest("hex");
|
||||
return {
|
||||
content_kind: "mcp-app",
|
||||
html: null,
|
||||
descriptor_json: descriptorJson,
|
||||
sha256,
|
||||
view_generation: null,
|
||||
revision,
|
||||
manifest,
|
||||
grant_state: grantState,
|
||||
granted_sha: grantState === "granted" ? sha256 : null,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateManifestHeightMode(
|
||||
value: string,
|
||||
heightMode: NonNullable<BoardWidget["heightMode"]>,
|
||||
@@ -135,6 +209,10 @@ export function parseDescriptor(value: string): BoardMcpAppDescriptor {
|
||||
return JSON.parse(value) as BoardMcpAppDescriptor;
|
||||
}
|
||||
|
||||
export function parsePluginContent(value: string): ParsedPluginContent {
|
||||
return JSON.parse(value) as ParsedPluginContent;
|
||||
}
|
||||
|
||||
export function rowToTab(row: SelectedBoardTabRow): BoardTab {
|
||||
return {
|
||||
tabId: row.tab_id,
|
||||
@@ -148,6 +226,10 @@ export function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
|
||||
const manifest = parseManifest(row.manifest);
|
||||
const declared = manifest.declared;
|
||||
const declaredSummary = createBoardDeclaredSummary(declared);
|
||||
const pluginContent =
|
||||
row.content_kind === "plugin" && row.descriptor_json !== null
|
||||
? parsePluginContent(row.descriptor_json)
|
||||
: undefined;
|
||||
const instanceId =
|
||||
row.content_kind === "mcp-app" ? manifest.mcpAppInstanceId : row.view_generation;
|
||||
return {
|
||||
@@ -157,6 +239,12 @@ export function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
|
||||
contentKind: row.content_kind as BoardWidget["contentKind"],
|
||||
...(manifest.presentation ? { presentation: manifest.presentation } : {}),
|
||||
...(manifest.heightMode ? { heightMode: manifest.heightMode } : {}),
|
||||
...(pluginContent
|
||||
? {
|
||||
pluginKind: pluginContent.pluginKind,
|
||||
...(pluginContent.props !== undefined ? { props: pluginContent.props } : {}),
|
||||
}
|
||||
: {}),
|
||||
sizeW: row.size_w,
|
||||
sizeH: row.size_h,
|
||||
position: row.position,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type {
|
||||
BoardOp,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
runSqliteDeferredTransactionSync,
|
||||
runSqliteImmediateTransactionSync,
|
||||
} from "../infra/sqlite-transaction.js";
|
||||
import { OPENCLAW_AGENT_BOARD_SCHEMA_SQL } from "../state/openclaw-agent-board-schema.js";
|
||||
import { ensureOpenClawAgentBoardSchemaInTransaction } from "../state/openclaw-agent-board-schema.js";
|
||||
import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js";
|
||||
import {
|
||||
@@ -32,9 +32,11 @@ import {
|
||||
type BoardWidgetMcpAppDocument,
|
||||
} from "./board-store.js";
|
||||
import {
|
||||
createBoardWidgetContentFields,
|
||||
effectiveGrantState,
|
||||
parseDescriptor,
|
||||
parseManifest,
|
||||
parsePluginContent,
|
||||
rowToTab,
|
||||
rowToWidget,
|
||||
serializeManifest,
|
||||
@@ -56,12 +58,13 @@ type StoredBoard = {
|
||||
};
|
||||
|
||||
const ensuredBoardDatabases = new WeakSet<DatabaseSync>();
|
||||
const presentBoardDatabases = new WeakSet<DatabaseSync>();
|
||||
|
||||
// Read-only connections cannot run the lazy DDL, and a pre-existing v13 DB has
|
||||
// no board tables until the first write. Reads must treat that as "no boards",
|
||||
// not "no such table".
|
||||
function boardTablesPresent(database: Pick<OpenClawAgentDatabase, "db">): boolean {
|
||||
if (ensuredBoardDatabases.has(database.db)) {
|
||||
if (ensuredBoardDatabases.has(database.db) || presentBoardDatabases.has(database.db)) {
|
||||
return true;
|
||||
}
|
||||
const row = database.db // sqlite-allow-raw: catalog probe before Kysely table access.
|
||||
@@ -70,7 +73,7 @@ function boardTablesPresent(database: Pick<OpenClawAgentDatabase, "db">): boolea
|
||||
if (!row) {
|
||||
return false;
|
||||
}
|
||||
ensuredBoardDatabases.add(database.db);
|
||||
presentBoardDatabases.add(database.db);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -83,7 +86,7 @@ function ensureBoardSchema(database: OpenClawAgentDatabase): void {
|
||||
}
|
||||
runSqliteImmediateTransactionSync(
|
||||
database.db,
|
||||
() => database.db.exec(OPENCLAW_AGENT_BOARD_SCHEMA_SQL), // sqlite-allow-raw: one-time DDL bootstrap before Kysely access.
|
||||
() => ensureOpenClawAgentBoardSchemaInTransaction(database.db),
|
||||
{
|
||||
databaseLabel: database.path,
|
||||
operationLabel: "board.ensure-schema",
|
||||
@@ -91,6 +94,7 @@ function ensureBoardSchema(database: OpenClawAgentDatabase): void {
|
||||
);
|
||||
// Additive-surface rule: fold this into the next natural schema bump, then delete this lazy ensure.
|
||||
ensuredBoardDatabases.add(database.db);
|
||||
presentBoardDatabases.add(database.db);
|
||||
}
|
||||
|
||||
type SqliteBoardStoreOptions = {
|
||||
@@ -280,55 +284,6 @@ function deleteRemovedTabs(
|
||||
}
|
||||
}
|
||||
|
||||
function contentFields(
|
||||
params: BoardWidgetMaterializedPutParams,
|
||||
// Effective frame options come from the materialized widget, not the raw put
|
||||
// params: re-pins that omit them must keep the inherited persisted values.
|
||||
frame: Pick<BoardWidget, "presentation" | "heightMode">,
|
||||
revision: number,
|
||||
grantState: BoardWidget["grantState"],
|
||||
viewGeneration: string,
|
||||
now: number,
|
||||
) {
|
||||
const manifest = serializeManifest(
|
||||
params.declared,
|
||||
grantState,
|
||||
params.content.kind === "mcp-app"
|
||||
? { interactive: params.content.interactive, instanceId: viewGeneration }
|
||||
: undefined,
|
||||
frame,
|
||||
);
|
||||
if (params.content.kind === "html") {
|
||||
const sha256 = createHash("sha256").update(params.content.html).digest("hex");
|
||||
return {
|
||||
content_kind: "html",
|
||||
html: Buffer.from(params.content.html, "utf8"),
|
||||
descriptor_json: null,
|
||||
sha256,
|
||||
view_generation: viewGeneration,
|
||||
revision,
|
||||
manifest,
|
||||
grant_state: grantState,
|
||||
granted_sha: grantState === "granted" ? sha256 : null,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
const descriptorJson = JSON.stringify(params.content.descriptor);
|
||||
const sha256 = createHash("sha256").update(descriptorJson).digest("hex");
|
||||
return {
|
||||
content_kind: "mcp-app",
|
||||
html: null,
|
||||
descriptor_json: descriptorJson,
|
||||
sha256,
|
||||
view_generation: null,
|
||||
revision,
|
||||
manifest,
|
||||
grant_state: grantState,
|
||||
granted_sha: grantState === "granted" ? sha256 : null,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
function hasSession(database: BoardDatabaseHandle, sessionKey: string): boolean {
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
|
||||
return Boolean(
|
||||
@@ -467,10 +422,15 @@ export class SqliteBoardStore implements BoardStore {
|
||||
const grantScopeMatches = existing
|
||||
? existing.content_kind === "html"
|
||||
? canonicalParams.content.kind === "html"
|
||||
: existing.descriptor_json !== null &&
|
||||
canonicalParams.content.kind === "mcp-app" &&
|
||||
parseDescriptor(existing.descriptor_json).serverName ===
|
||||
canonicalParams.content.descriptor.serverName
|
||||
: existing.content_kind === "mcp-app"
|
||||
? existing.descriptor_json !== null &&
|
||||
canonicalParams.content.kind === "mcp-app" &&
|
||||
parseDescriptor(existing.descriptor_json).serverName ===
|
||||
canonicalParams.content.descriptor.serverName
|
||||
: existing.descriptor_json !== null &&
|
||||
canonicalParams.content.kind === "plugin" &&
|
||||
parsePluginContent(existing.descriptor_json).pluginKind ===
|
||||
canonicalParams.content.pluginKind
|
||||
: true;
|
||||
const next = createBoardWidgetPutSnapshot(previous.snapshot, canonicalParams, {
|
||||
grantScopeMatches,
|
||||
@@ -481,7 +441,7 @@ export class SqliteBoardStore implements BoardStore {
|
||||
const now = Date.now();
|
||||
upsertTabs(transactionDatabase, previous, next);
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
|
||||
const fields = contentFields(
|
||||
const fields = createBoardWidgetContentFields(
|
||||
canonicalParams,
|
||||
{ presentation: widget.presentation, heightMode: widget.heightMode },
|
||||
widget.revision,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
listControlUiPluginTabAuthGrants,
|
||||
listControlUiPluginTabs,
|
||||
listControlUiPluginWidgetKinds,
|
||||
} from "./control-ui-plugin-tabs.js";
|
||||
|
||||
function tabDescriptor(
|
||||
@@ -94,6 +95,35 @@ describe("listControlUiPluginTabs", () => {
|
||||
expect(listControlUiPluginTabs([]).map((tab) => tab.id)).toEqual(["beta", "zed", "alpha"]);
|
||||
});
|
||||
|
||||
it("projects scoped widget descriptors as namespaced kinds", () => {
|
||||
activateDescriptors([
|
||||
{
|
||||
pluginId: "workboard",
|
||||
descriptor: tabDescriptor({
|
||||
id: "card",
|
||||
surface: "widget",
|
||||
label: "Workboard card",
|
||||
requiredScopes: ["operator.read"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
pluginId: "workboard",
|
||||
descriptor: tabDescriptor({
|
||||
id: "mini",
|
||||
surface: "widget",
|
||||
label: "Workboard summary",
|
||||
requiredScopes: ["operator.read"],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(listControlUiPluginWidgetKinds([])).toEqual([]);
|
||||
expect(listControlUiPluginWidgetKinds(["operator.read"])).toEqual([
|
||||
{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" },
|
||||
{ pluginId: "workboard", kind: "workboard:mini", label: "Workboard summary" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("grants only same-plugin gateway routes with least-privilege scopes", () => {
|
||||
activateDescriptors(
|
||||
[
|
||||
|
||||
@@ -24,6 +24,12 @@ type ControlUiPluginTab = {
|
||||
requiresGatewayAuth?: boolean;
|
||||
};
|
||||
|
||||
type ControlUiPluginWidgetKind = {
|
||||
pluginId: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function findControlUiTabGatewayRoute(
|
||||
registry: PluginRegistry,
|
||||
tab: ControlUiPluginTab,
|
||||
@@ -113,6 +119,35 @@ export function listControlUiPluginTabs(
|
||||
});
|
||||
}
|
||||
|
||||
/** Lists active plugins' trusted widget kinds visible to the presented scopes. */
|
||||
export function listControlUiPluginWidgetKinds(
|
||||
scopes: readonly string[],
|
||||
): ControlUiPluginWidgetKind[] {
|
||||
const entries = getActivePluginRegistry()?.controlUiDescriptors ?? [];
|
||||
return entries
|
||||
.flatMap((entry) => {
|
||||
const descriptor = entry.descriptor;
|
||||
if (descriptor.surface !== "widget") {
|
||||
return [];
|
||||
}
|
||||
const visible = (descriptor.requiredScopes ?? []).every(
|
||||
(scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed,
|
||||
);
|
||||
return visible
|
||||
? [
|
||||
{
|
||||
pluginId: entry.pluginId,
|
||||
kind: `${entry.pluginId}:${descriptor.id}`,
|
||||
label: descriptor.label,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
.toSorted(
|
||||
(left, right) => left.label.localeCompare(right.label) || left.kind.localeCompare(right.kind),
|
||||
);
|
||||
}
|
||||
|
||||
/** Builds least-privilege grants only for visible tabs backed by same-plugin gateway routes. */
|
||||
export function listControlUiPluginTabAuthGrants(
|
||||
callerScopes: readonly string[],
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
recordPairedNodeConnection,
|
||||
} from "../../../infra/node-pairing.js";
|
||||
import { resolveRuntimeServiceVersion } from "../../../version.js";
|
||||
import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js";
|
||||
import {
|
||||
listControlUiPluginTabs,
|
||||
listControlUiPluginWidgetKinds,
|
||||
} from "../../control-ui-plugin-tabs.js";
|
||||
import { ADMIN_SCOPE } from "../../method-scopes.js";
|
||||
import { scheduleNodeConnectionNotification } from "../../node-connection-notifications.js";
|
||||
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||
@@ -77,6 +80,7 @@ export async function sendGatewayHello(
|
||||
const controlUiTabs = listControlUiPluginTabs(helloOkAuthScopes, {
|
||||
requireGatewayAuthGrant: resolvedAuth.mode !== "none",
|
||||
});
|
||||
const controlUiWidgetKinds = listControlUiPluginWidgetKinds(helloOkAuthScopes);
|
||||
const helloOk = {
|
||||
type: "hello-ok",
|
||||
protocol: PROTOCOL_VERSION,
|
||||
@@ -95,6 +99,7 @@ export async function sendGatewayHello(
|
||||
},
|
||||
snapshot,
|
||||
...(controlUiTabs.length > 0 ? { controlUiTabs } : {}),
|
||||
...(controlUiWidgetKinds.length > 0 ? { controlUiWidgetKinds } : {}),
|
||||
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
|
||||
auth: {
|
||||
role,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,7 @@ const controlUiSurfaces = new Set<PluginControlUiDescriptor["surface"]>([
|
||||
"run",
|
||||
"settings",
|
||||
"tab",
|
||||
"widget",
|
||||
]);
|
||||
|
||||
function normalizeHostHookString(value: unknown): string {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ function isTrustedRetryEndpoint(url: string): boolean {
|
||||
}
|
||||
|
||||
export type GatewayControlUiPluginTab = NonNullable<HelloOk["controlUiTabs"]>[number];
|
||||
export type GatewayControlUiPluginWidgetKind = NonNullable<HelloOk["controlUiWidgetKinds"]>[number];
|
||||
export type GatewayHelloOk = Omit<HelloOk, "server" | "features" | "snapshot" | "policy"> & {
|
||||
server?: Partial<HelloOk["server"]>;
|
||||
features?: Partial<HelloOk["features"]>;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { BoardViewWidget } from "../../lib/board/view-types.ts";
|
||||
import { createApplicationContextProvider } from "../../test-helpers/application-context.ts";
|
||||
import type { BoardWidgetCellCallbacks } from "./board-widget-cell.ts";
|
||||
import "./board-widget-cell.ts";
|
||||
|
||||
function callbacks(): BoardWidgetCellCallbacks {
|
||||
const noAction = vi.fn(async () => undefined);
|
||||
return {
|
||||
grant: noAction,
|
||||
movePointerDown: vi.fn(),
|
||||
resizePointerDown: vi.fn(),
|
||||
moveToTab: noAction,
|
||||
resizeTo: noAction,
|
||||
setHeightMode: noAction,
|
||||
reportContentHeight: vi.fn(),
|
||||
remove: noAction,
|
||||
nudge: noAction,
|
||||
focus: vi.fn(),
|
||||
focusChanged: vi.fn(),
|
||||
frameLoadFailed: noAction,
|
||||
widgetAppView: vi.fn(async () => ({ status: "stale" as const, error: "unused" })),
|
||||
refreshWidgetAppView: vi.fn(async () => ({ status: "stale" as const, error: "unused" })),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("plugin board widget cells", () => {
|
||||
it("renders a removable placeholder when the owning plugin is inactive", async () => {
|
||||
const widget: BoardViewWidget = {
|
||||
name: "work-item",
|
||||
tabId: "main",
|
||||
title: "Work item",
|
||||
contentKind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "card-123" },
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
};
|
||||
const cellCallbacks = callbacks();
|
||||
const cell = document.createElement("openclaw-board-widget-cell");
|
||||
cell.widget = widget;
|
||||
cell.rect = { name: widget.name, x: 0, y: 0, w: 6, h: 4 };
|
||||
cell.sessionKey = "agent:main:test";
|
||||
cell.callbacks = cellCallbacks;
|
||||
document.body.append(cell);
|
||||
await cell.updateComplete;
|
||||
|
||||
const placeholder = cell.querySelector('[data-test-id="board-disabled-plugin"]');
|
||||
expect(placeholder?.textContent).toContain("Widget from disabled plugin workboard");
|
||||
const removeButton = placeholder?.querySelector("button");
|
||||
expect(removeButton).not.toBeNull();
|
||||
removeButton?.click();
|
||||
await vi.waitFor(() => expect(cellCallbacks.remove).toHaveBeenCalledWith(widget));
|
||||
});
|
||||
|
||||
it("retries a failed plugin renderer load for the same widget kind", async () => {
|
||||
const widget: BoardViewWidget = {
|
||||
name: "work-item",
|
||||
tabId: "main",
|
||||
title: "Work item",
|
||||
contentKind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "card-123" },
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
};
|
||||
const context = {
|
||||
gateway: {
|
||||
snapshot: {
|
||||
connected: false,
|
||||
hello: {
|
||||
controlUiWidgetKinds: [
|
||||
{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" },
|
||||
],
|
||||
},
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
subscribeEvents: () => () => undefined,
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
const provider = createApplicationContextProvider(context);
|
||||
const cell = document.createElement("openclaw-board-widget-cell");
|
||||
cell.widget = widget;
|
||||
cell.rect = { name: widget.name, x: 0, y: 0, w: 6, h: 4 };
|
||||
cell.sessionKey = "agent:main:test";
|
||||
cell.callbacks = callbacks();
|
||||
provider.append(cell);
|
||||
document.body.append(provider);
|
||||
await vi.waitFor(() =>
|
||||
expect(cell.querySelector("openclaw-workboard-card-widget")).not.toBeNull(),
|
||||
);
|
||||
|
||||
Reflect.set(cell, "pluginRenderer", null);
|
||||
Reflect.set(cell, "pluginRendererError", "chunk unavailable");
|
||||
cell.requestUpdate();
|
||||
await cell.updateComplete;
|
||||
const retry = cell.querySelector<HTMLButtonElement>(
|
||||
'[data-test-id="board-widget-error"] button',
|
||||
);
|
||||
expect(retry?.textContent?.trim()).toBe("Retry");
|
||||
retry?.click();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(cell.querySelector("openclaw-workboard-card-widget")).not.toBeNull(),
|
||||
);
|
||||
expect(cell.querySelector('[data-test-id="board-widget-error"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -109,7 +109,27 @@ export function renderBoardWidgetRejected(options: {
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderBoardWidgetError(error: unknown): TemplateResult {
|
||||
export function renderBoardDisabledPlugin(options: {
|
||||
pluginId: string;
|
||||
disabled: boolean;
|
||||
onRemove: () => void;
|
||||
}): TemplateResult {
|
||||
return html`
|
||||
<div class="board-widget__disabled-plugin" data-test-id="board-disabled-plugin">
|
||||
<strong>${t("board.widget.disabledPlugin", { pluginId: options.pluginId })}</strong>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${options.disabled}
|
||||
@click=${options.onRemove}
|
||||
>
|
||||
${t("board.widget.remove")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderBoardWidgetError(error: unknown, onRetry?: () => void): TemplateResult {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return html`
|
||||
<div class="board-widget__error" role="alert" data-test-id="board-widget-error">
|
||||
@@ -119,6 +139,11 @@ export function renderBoardWidgetError(error: unknown): TemplateResult {
|
||||
<summary>${t("board.widget.errorShow")}</summary>
|
||||
<code>${message}</code>
|
||||
</details>
|
||||
${onRetry
|
||||
? html`<button class="btn btn--small" type="button" @click=${onRetry}>
|
||||
${t("board.widget.retry")}
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ import type {
|
||||
BoardViewWidget,
|
||||
BoardWidgetFrameUrl,
|
||||
} from "../../lib/board/view-types.ts";
|
||||
import { getBuiltinWidgetRenderer } from "../../lib/board/widgets/index.ts";
|
||||
import {
|
||||
getBuiltinWidgetRenderer,
|
||||
getPluginWidgetKindContribution,
|
||||
loadPluginWidgetRenderer,
|
||||
pluginIdForWidgetKind,
|
||||
type PluginBoardWidgetRenderer,
|
||||
} from "../../lib/board/widgets/index.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { renderBoardMcpAppContent } from "./board-mcp-app-content.ts";
|
||||
import { BoardMcpAppLifecycle } from "./board-mcp-app-lifecycle.ts";
|
||||
@@ -22,6 +28,7 @@ import { renderBoardGrantedCapabilities } from "./board-widget-capabilities.ts";
|
||||
import {
|
||||
BOARD_SIZE_PRESETS,
|
||||
closeBoardWidgetMenu,
|
||||
renderBoardDisabledPlugin,
|
||||
renderBoardWidgetActionError,
|
||||
renderBoardWidgetError,
|
||||
renderBoardWidgetMenu,
|
||||
@@ -72,6 +79,11 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
|
||||
@state() private actionError = "";
|
||||
@state() private actionPending = false;
|
||||
@state() private pluginRenderer: PluginBoardWidgetRenderer | null = null;
|
||||
@state() private pluginRendererError = "";
|
||||
@state() private pluginRendererLabel = "";
|
||||
private pluginRendererKind = "";
|
||||
private pluginRendererLoadToken: object | null = null;
|
||||
private readonly appView = new BoardMcpAppLifecycle({
|
||||
connected: () => this.isConnected,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
@@ -102,6 +114,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
this.frame.widgetChanged(previousWidget, this.widget);
|
||||
}
|
||||
this.appView.update(this.widget, this.callbacks);
|
||||
this.syncPluginRenderer();
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
@@ -122,6 +135,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.resetPluginRenderer();
|
||||
this.frame.disconnect();
|
||||
this.appView.disconnect();
|
||||
super.disconnectedCallback();
|
||||
@@ -240,9 +254,81 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
}
|
||||
return renderer({ sessions: this.sessions, sessionKey: this.sessionKey });
|
||||
}
|
||||
if (widget.contentKind === "plugin") {
|
||||
if (this.pluginRendererError) {
|
||||
return renderBoardWidgetError(this.pluginRendererError, () => this.retryPluginRenderer());
|
||||
}
|
||||
if (this.pluginRenderer) {
|
||||
return this.pluginRenderer({
|
||||
widget,
|
||||
sessionKey: this.sessionKey,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
});
|
||||
}
|
||||
const pluginId = pluginIdForWidgetKind(widget.pluginKind);
|
||||
const activeKinds = this.context?.gateway.snapshot.hello?.controlUiWidgetKinds ?? [];
|
||||
const contribution = getPluginWidgetKindContribution(widget.pluginKind, activeKinds);
|
||||
return contribution
|
||||
? html`<p class="board-widget__plugin-loading">${t("board.widget.pluginLoading")}</p>`
|
||||
: renderBoardDisabledPlugin({
|
||||
pluginId,
|
||||
disabled: this.busy || this.actionPending || !this.canMutate,
|
||||
onRemove: () => void this.runAction(() => callbacks.remove(widget)),
|
||||
});
|
||||
}
|
||||
return this.frame.render(widget);
|
||||
}
|
||||
|
||||
private syncPluginRenderer(): void {
|
||||
const widget = this.widget;
|
||||
const activeKinds = this.context?.gateway.snapshot.hello?.controlUiWidgetKinds ?? [];
|
||||
const contribution =
|
||||
widget?.contentKind === "plugin"
|
||||
? getPluginWidgetKindContribution(widget.pluginKind, activeKinds)
|
||||
: null;
|
||||
if (!contribution) {
|
||||
if (this.pluginRendererKind || this.pluginRenderer || this.pluginRendererError) {
|
||||
this.resetPluginRenderer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.pluginRendererKind === contribution.kind) {
|
||||
return;
|
||||
}
|
||||
const loadToken = {};
|
||||
this.pluginRendererKind = contribution.kind;
|
||||
this.pluginRendererLabel = contribution.label;
|
||||
this.pluginRenderer = null;
|
||||
this.pluginRendererError = "";
|
||||
this.pluginRendererLoadToken = loadToken;
|
||||
void loadPluginWidgetRenderer(contribution)
|
||||
.then((renderer) => {
|
||||
if (this.pluginRendererLoadToken === loadToken) {
|
||||
this.pluginRenderer = renderer;
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.pluginRendererLoadToken === loadToken) {
|
||||
this.pluginRendererError = error instanceof Error ? error.message : String(error);
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetPluginRenderer(): void {
|
||||
this.pluginRendererLoadToken = null;
|
||||
this.pluginRendererKind = "";
|
||||
this.pluginRendererLabel = "";
|
||||
this.pluginRenderer = null;
|
||||
this.pluginRendererError = "";
|
||||
}
|
||||
|
||||
private retryPluginRenderer(): void {
|
||||
this.resetPluginRenderer();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleKeyDown(
|
||||
event: KeyboardEvent,
|
||||
widget: BoardViewWidget,
|
||||
@@ -297,7 +383,8 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
this.actionError !== "" ||
|
||||
widget.grantState === "pending" ||
|
||||
widget.grantState === "rejected";
|
||||
const contentScrollable = bodyScrollable || widget.contentKind === "mcp-app";
|
||||
const contentScrollable =
|
||||
bodyScrollable || widget.contentKind === "mcp-app" || widget.contentKind === "plugin";
|
||||
const presentation =
|
||||
widget.contentKind === "html" ? (widget.presentation ?? "card") : undefined;
|
||||
return html`
|
||||
@@ -331,7 +418,9 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
: html`<span class="board-widget__kind"
|
||||
>${widget.contentKind === "mcp-app"
|
||||
? t("board.widget.kindMcp")
|
||||
: t("board.widget.kindHtml")}</span
|
||||
: widget.contentKind === "plugin"
|
||||
? this.pluginRendererLabel || t("board.widget.kindPlugin")
|
||||
: t("board.widget.kindHtml")}</span
|
||||
>`}
|
||||
${widget.contentKind === "builtin" ? nothing : renderBoardGrantedCapabilities(widget)}
|
||||
${readOnly
|
||||
|
||||
@@ -22,6 +22,10 @@ const cardboardProofDir = path.resolve(
|
||||
process.cwd(),
|
||||
".artifacts/control-ui-e2e/workboard-cardboard",
|
||||
);
|
||||
const pluginWidgetsProofDir = path.resolve(
|
||||
process.cwd(),
|
||||
".artifacts/control-ui-e2e/workboard-plugin-widgets",
|
||||
);
|
||||
|
||||
let browser: Browser;
|
||||
let server: ControlUiE2eServer;
|
||||
@@ -99,6 +103,39 @@ const pinnedMcpAppBoardSnapshot = {
|
||||
},
|
||||
],
|
||||
};
|
||||
const pluginWidgetBoardSnapshot = {
|
||||
sessionKey,
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
|
||||
widgets: [
|
||||
{
|
||||
name: "workboard-card",
|
||||
tabId: "main",
|
||||
title: "Priority card",
|
||||
contentKind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "card-widget-ready" },
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
},
|
||||
{
|
||||
name: "workboard-summary",
|
||||
tabId: "main",
|
||||
title: "Platform summary",
|
||||
contentKind: "plugin",
|
||||
pluginKind: "workboard:mini",
|
||||
props: { boardId: "platform", limit: 2 },
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 1,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function showDashboard(page: Page): Promise<void> {
|
||||
await page.addInitScript((key) => {
|
||||
@@ -385,6 +422,127 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it("renders and updates active Workboard plugin widgets", async () => {
|
||||
const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1";
|
||||
if (recordProof) {
|
||||
await mkdir(pluginWidgetsProofDir, { recursive: true });
|
||||
}
|
||||
const context = await browser.newContext({
|
||||
viewport: { height: 900, width: 1280 },
|
||||
...(recordProof
|
||||
? { recordVideo: { dir: pluginWidgetsProofDir, size: { height: 900, width: 1280 } } }
|
||||
: {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const readyCard = {
|
||||
id: "card-widget-ready",
|
||||
title: "Rebase plugin widget kinds",
|
||||
status: "ready",
|
||||
priority: "high",
|
||||
labels: ["dashboard"],
|
||||
position: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
agentId: "main",
|
||||
metadata: { automation: { boardId: "platform" } },
|
||||
};
|
||||
const runningCard = { ...readyCard, status: "running", updatedAt: 3 };
|
||||
const gateway = await installMockGateway(page, {
|
||||
sessionKey,
|
||||
controlUiWidgetKinds: [
|
||||
{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" },
|
||||
{ pluginId: "workboard", kind: "workboard:mini", label: "Workboard summary" },
|
||||
],
|
||||
featureMethods: [
|
||||
"board.get",
|
||||
"chat.metadata",
|
||||
"chat.startup",
|
||||
"workboard.cards.list",
|
||||
"workboard.cards.move",
|
||||
],
|
||||
methodResponses: {
|
||||
"board.get": pluginWidgetBoardSnapshot,
|
||||
"workboard.cards.list": {
|
||||
cards: [
|
||||
readyCard,
|
||||
{
|
||||
...readyCard,
|
||||
id: "card-widget-running",
|
||||
title: "Already running",
|
||||
status: "running",
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
statuses: ["ready", "running", "done"],
|
||||
},
|
||||
"workboard.cards.move": { card: runningCard },
|
||||
},
|
||||
});
|
||||
await showDashboard(page);
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
const cardWidget = page.locator('[data-test-id="workboard-card-widget"]');
|
||||
const miniWidget = page.locator('[data-test-id="workboard-mini-widget"]');
|
||||
await cardWidget.waitFor();
|
||||
await miniWidget.waitFor();
|
||||
await expect.poll(() => cardWidget.textContent()).toContain("Rebase plugin widget kinds");
|
||||
await expect.poll(() => miniWidget.textContent()).toContain("Already running");
|
||||
expect(await miniWidget.getByRole("link", { name: "Open board" }).getAttribute("href")).toBe(
|
||||
"/workboard?board=platform",
|
||||
);
|
||||
if (recordProof) {
|
||||
await page.screenshot({
|
||||
path: path.join(pluginWidgetsProofDir, "01-plugin-widgets-ready.png"),
|
||||
});
|
||||
}
|
||||
|
||||
await cardWidget.getByRole("combobox").selectOption("running");
|
||||
const moveRequest = await gateway.waitForRequest("workboard.cards.move");
|
||||
expect(moveRequest.params).toEqual({
|
||||
id: "card-widget-ready",
|
||||
status: "running",
|
||||
position: 3,
|
||||
});
|
||||
await expect.poll(() => cardWidget.textContent()).toContain("Running");
|
||||
await gateway.setMethodResponse("workboard.cards.list", {
|
||||
cards: [
|
||||
runningCard,
|
||||
{
|
||||
...readyCard,
|
||||
id: "card-widget-running",
|
||||
title: "Already running",
|
||||
status: "running",
|
||||
position: 2,
|
||||
},
|
||||
],
|
||||
statuses: ["ready", "running", "done"],
|
||||
});
|
||||
await gateway.emitGatewayEvent("plugin.workboard.changed", {
|
||||
epoch: "plugin-widget-e2e",
|
||||
revision: 2,
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await miniWidget.locator('[title="Running"]').textContent())
|
||||
?.replace(/\s+/gu, " ")
|
||||
.trim(),
|
||||
)
|
||||
.toBe("2 Running");
|
||||
if (recordProof) {
|
||||
await page.screenshot({
|
||||
path: path.join(pluginWidgetsProofDir, "02-plugin-widgets-running.png"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
const video = page.video();
|
||||
await context.close();
|
||||
if (recordProof && video) {
|
||||
await video.saveAs(path.join(pluginWidgetsProofDir, "workboard-plugin-widgets.webm"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("links a dispatched Workboard card and its live session dashboard in both directions", async () => {
|
||||
const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1";
|
||||
if (recordProof) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { GatewayControlUiPluginWidgetKind } from "../../../api/gateway.ts";
|
||||
import type { GatewaySessionRow } from "../../../api/types.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
import { renderSwarmWidget } from "./swarm.ts";
|
||||
|
||||
type BuiltinBoardWidgetRenderer = (context: {
|
||||
@@ -7,6 +10,38 @@ type BuiltinBoardWidgetRenderer = (context: {
|
||||
sessionKey: string;
|
||||
}) => TemplateResult;
|
||||
|
||||
export type PluginBoardWidgetRenderer = (props: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
requestUpdate: () => void;
|
||||
}) => TemplateResult;
|
||||
|
||||
type PluginWidgetKindContribution = {
|
||||
kind: string;
|
||||
label: string;
|
||||
loader: () => Promise<PluginBoardWidgetRenderer>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin renderers are trusted first-party Control UI code. They render in the
|
||||
* cell without an iframe or grants, receive only widget/session/update props,
|
||||
* and use the standard gateway client for RPCs owned by their plugin.
|
||||
*/
|
||||
const PLUGIN_WIDGET_KIND_CONTRIBUTIONS: Record<string, PluginWidgetKindContribution> = {
|
||||
"workboard:card": {
|
||||
kind: "workboard:card",
|
||||
label: t("workboard.widget.cardLabel"),
|
||||
loader: async () => (await import("./workboard-card.ts")).renderWorkboardCardWidget,
|
||||
},
|
||||
"workboard:mini": {
|
||||
kind: "workboard:mini",
|
||||
label: t("workboard.widget.summaryLabel"),
|
||||
loader: async () => (await import("./workboard-mini.ts")).renderWorkboardMiniWidget,
|
||||
},
|
||||
};
|
||||
|
||||
const pluginRendererPromises = new Map<string, Promise<PluginBoardWidgetRenderer>>();
|
||||
|
||||
const BUILTIN_WIDGET_RENDERERS: Record<string, BuiltinBoardWidgetRenderer> = {
|
||||
swarm: renderSwarmWidget,
|
||||
};
|
||||
@@ -16,3 +51,41 @@ export function getBuiltinWidgetRenderer(
|
||||
): BuiltinBoardWidgetRenderer | null {
|
||||
return name ? (BUILTIN_WIDGET_RENDERERS[name] ?? null) : null;
|
||||
}
|
||||
|
||||
export function pluginIdForWidgetKind(kind: string | undefined): string {
|
||||
return kind?.split(":", 1)[0]?.trim() || "unknown";
|
||||
}
|
||||
|
||||
export function getPluginWidgetKindContribution(
|
||||
kind: string | undefined,
|
||||
activeKinds: readonly GatewayControlUiPluginWidgetKind[],
|
||||
): PluginWidgetKindContribution | null {
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
const contribution = PLUGIN_WIDGET_KIND_CONTRIBUTIONS[kind];
|
||||
if (!contribution) {
|
||||
return null;
|
||||
}
|
||||
const pluginId = pluginIdForWidgetKind(kind);
|
||||
return activeKinds.some((entry) => entry.kind === kind && entry.pluginId === pluginId)
|
||||
? contribution
|
||||
: null;
|
||||
}
|
||||
|
||||
export function loadPluginWidgetRenderer(
|
||||
contribution: PluginWidgetKindContribution,
|
||||
): Promise<PluginBoardWidgetRenderer> {
|
||||
const existing = pluginRendererPromises.get(contribution.kind);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const loaded = contribution.loader();
|
||||
pluginRendererPromises.set(contribution.kind, loaded);
|
||||
void loaded.catch(() => {
|
||||
if (pluginRendererPromises.get(contribution.kind) === loaded) {
|
||||
pluginRendererPromises.delete(contribution.kind);
|
||||
}
|
||||
});
|
||||
return loaded;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import type { WorkboardStatus } from "../../workboard/types.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
import type { PluginBoardWidgetRenderer } from "./index.ts";
|
||||
import { WorkboardWidgetElement } from "./workboard-widget.ts";
|
||||
|
||||
class OpenClawWorkboardCardWidget extends WorkboardWidgetElement {
|
||||
private async handleStatusChange(event: Event): Promise<void> {
|
||||
const cardId = this.readStringProp("cardId");
|
||||
const card = this.cards.find((candidate) => candidate.id === cardId);
|
||||
const status = (event.currentTarget as HTMLSelectElement).value;
|
||||
if (!card || !this.statuses.includes(status as WorkboardStatus)) {
|
||||
return;
|
||||
}
|
||||
await this.moveCard(card, status as WorkboardStatus);
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const cardId = this.readStringProp("cardId");
|
||||
if (!cardId) {
|
||||
return html`<p class="workboard-widget__state" role="alert">
|
||||
${t("workboard.widget.cardIdRequired")}
|
||||
</p>`;
|
||||
}
|
||||
if (this.loading && !this.loaded) {
|
||||
return html`<p class="workboard-widget__state">${t("workboard.widget.loading")}</p>`;
|
||||
}
|
||||
if (this.error) {
|
||||
return html`<div class="workboard-widget__state" role="alert">
|
||||
<span>${this.error}</span>
|
||||
<button class="btn btn--sm" type="button" @click=${() => this.retryLoad()}>
|
||||
${t("common.retry")}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
const card = this.cards.find((candidate) => candidate.id === cardId);
|
||||
if (!card) {
|
||||
return html`<p class="workboard-widget__state">${t("workboard.widget.cardMissing")}</p>`;
|
||||
}
|
||||
const statuses = this.statuses.includes(card.status)
|
||||
? this.statuses
|
||||
: [card.status, ...this.statuses];
|
||||
const priority = card.priority.charAt(0).toUpperCase() + card.priority.slice(1);
|
||||
return html`
|
||||
<article class="workboard-widget-card" data-test-id="workboard-card-widget">
|
||||
<div class="workboard-widget-card__heading">
|
||||
<strong>${card.title}</strong>
|
||||
<span class=${`workboard-widget__status workboard-widget__status--${card.status}`}>
|
||||
${t(`workboard.status.${card.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="workboard-widget-card__meta">
|
||||
<div>
|
||||
<dt>${t("workboard.fieldPriority")}</dt>
|
||||
<dd>${priority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>${t("workboard.fieldAgent")}</dt>
|
||||
<dd>${card.agentId ?? t("workboard.widget.unassigned")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
${statuses.length > 1
|
||||
? html`
|
||||
<label class="workboard-widget-card__move">
|
||||
<span>${t("workboard.fieldStatus")}</span>
|
||||
<select
|
||||
aria-label=${`${t("workboard.fieldStatus")}: ${card.title}`}
|
||||
.value=${card.status}
|
||||
@change=${(event: Event) => void this.handleStatusChange(event)}
|
||||
>
|
||||
${statuses.map(
|
||||
(status) => html`
|
||||
<option value=${status} ?selected=${status === card.status}>
|
||||
${t(`workboard.status.${status}`)}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-workboard-card-widget")) {
|
||||
customElements.define("openclaw-workboard-card-widget", OpenClawWorkboardCardWidget);
|
||||
}
|
||||
|
||||
export const renderWorkboardCardWidget: PluginBoardWidgetRenderer = ({
|
||||
widget,
|
||||
sessionKey,
|
||||
requestUpdate,
|
||||
}: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
requestUpdate: () => void;
|
||||
}) => html`
|
||||
<openclaw-workboard-card-widget
|
||||
.widget=${widget}
|
||||
.sessionKey=${sessionKey}
|
||||
.hostRequestUpdate=${requestUpdate}
|
||||
></openclaw-workboard-card-widget>
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-workboard-card-widget": OpenClawWorkboardCardWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { pathForRoute } from "../../../app-route-paths.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { WORKBOARD_STATUSES, type WorkboardCard } from "../../workboard/types.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
import type { PluginBoardWidgetRenderer } from "./index.ts";
|
||||
import { WorkboardWidgetElement } from "./workboard-widget.ts";
|
||||
|
||||
function cardBoardId(card: WorkboardCard): string {
|
||||
return card.metadata?.automation?.boardId ?? "default";
|
||||
}
|
||||
|
||||
class OpenClawWorkboardMiniWidget extends WorkboardWidgetElement {
|
||||
override render(): TemplateResult {
|
||||
if (this.loading && !this.loaded) {
|
||||
return html`<p class="workboard-widget__state">${t("workboard.widget.loading")}</p>`;
|
||||
}
|
||||
if (this.error) {
|
||||
return html`<div class="workboard-widget__state" role="alert">
|
||||
<span>${this.error}</span>
|
||||
<button class="btn btn--sm" type="button" @click=${() => this.retryLoad()}>
|
||||
${t("common.retry")}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
const boardId = this.readStringProp("boardId") ?? "default";
|
||||
const limit = Math.min(10, this.readPositiveIntegerProp("limit", 5));
|
||||
const cards = this.cards.filter((card) => cardBoardId(card) === boardId);
|
||||
const topCards = cards
|
||||
.filter((card) => card.status === "ready" || card.status === "running")
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
Number(right.status === "running") - Number(left.status === "running") ||
|
||||
left.position - right.position ||
|
||||
left.title.localeCompare(right.title),
|
||||
)
|
||||
.slice(0, limit);
|
||||
const workboardPath = `${pathForRoute("workboard", this.context?.basePath ?? "")}?board=${encodeURIComponent(boardId)}`;
|
||||
return html`
|
||||
<section class="workboard-widget-mini" data-test-id="workboard-mini-widget">
|
||||
<header>
|
||||
<strong>${boardId}</strong>
|
||||
<a href=${workboardPath}>${t("workboard.widget.openBoard")}</a>
|
||||
</header>
|
||||
<div class="workboard-widget-mini__counts" aria-label=${t("workboard.widget.statusCounts")}>
|
||||
${WORKBOARD_STATUSES.map(
|
||||
(status) => html`
|
||||
<span title=${t(`workboard.status.${status}`)}>
|
||||
<b>${cards.filter((card) => card.status === status).length}</b>
|
||||
${t(`workboard.status.${status}`)}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div class="workboard-widget-mini__cards">
|
||||
${topCards.length > 0
|
||||
? topCards.map(
|
||||
(card) => html`
|
||||
<div class="workboard-widget-mini__card">
|
||||
<span
|
||||
class=${`workboard-widget__status workboard-widget__status--${card.status}`}
|
||||
>
|
||||
${t(`workboard.status.${card.status}`)}
|
||||
</span>
|
||||
<strong>${card.title}</strong>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
: html`<p class="workboard-widget__state">${t("workboard.widget.noActiveCards")}</p>`}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-workboard-mini-widget")) {
|
||||
customElements.define("openclaw-workboard-mini-widget", OpenClawWorkboardMiniWidget);
|
||||
}
|
||||
|
||||
export const renderWorkboardMiniWidget: PluginBoardWidgetRenderer = ({
|
||||
widget,
|
||||
sessionKey,
|
||||
requestUpdate,
|
||||
}: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
requestUpdate: () => void;
|
||||
}) => html`
|
||||
<openclaw-workboard-mini-widget
|
||||
.widget=${widget}
|
||||
.sessionKey=${sessionKey}
|
||||
.hostRequestUpdate=${requestUpdate}
|
||||
></openclaw-workboard-mini-widget>
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-workboard-mini-widget": OpenClawWorkboardMiniWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { property } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../../../api/gateway.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../../app/context.ts";
|
||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../../../lit/subscriptions-controller.ts";
|
||||
import { moveWorkboardCard } from "../../workboard/mutations.ts";
|
||||
import { normalizeCardsPayload } from "../../workboard/normalization.ts";
|
||||
import { getWorkboardState, type WorkboardHost } from "../../workboard/runtime.ts";
|
||||
import {
|
||||
WORKBOARD_CHANGED_EVENT,
|
||||
type WorkboardCard,
|
||||
type WorkboardStatus,
|
||||
} from "../../workboard/types.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
|
||||
export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: true })
|
||||
protected context?: ApplicationContext;
|
||||
|
||||
@property({ attribute: false }) widget?: BoardViewWidget;
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ attribute: false }) hostRequestUpdate?: () => void;
|
||||
|
||||
protected cards: WorkboardCard[] = [];
|
||||
protected statuses: readonly WorkboardStatus[] = [];
|
||||
protected loading = false;
|
||||
protected loaded = false;
|
||||
protected error = "";
|
||||
private loadAttempted = false;
|
||||
|
||||
private readonly workboardHost: WorkboardHost = {};
|
||||
private client: GatewayBrowserClient | null = null;
|
||||
private refreshGeneration = 0;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private refreshPending = false;
|
||||
private readonly subscriptions = new SubscriptionsController(this).effect(
|
||||
() => this.context?.gateway,
|
||||
(gateway) => {
|
||||
const sync = () => this.syncGateway(gateway.snapshot);
|
||||
sync();
|
||||
const unsubscribeSnapshot = gateway.subscribe(sync);
|
||||
const unsubscribeEvents = gateway.subscribeEvents((event) => {
|
||||
if (event.event === WORKBOARD_CHANGED_EVENT && gateway.snapshot.connected) {
|
||||
void this.refresh(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribeSnapshot();
|
||||
unsubscribeEvents();
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.syncGateway(this.context?.gateway.snapshot);
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
if (!this.loadAttempted && !this.loading) {
|
||||
void this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.refreshGeneration += 1;
|
||||
this.refreshPromise = null;
|
||||
this.refreshPending = false;
|
||||
this.client = null;
|
||||
this.loaded = false;
|
||||
this.loadAttempted = false;
|
||||
this.loading = false;
|
||||
this.error = "";
|
||||
this.subscriptions.clear();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected readStringProp(key: string): string | undefined {
|
||||
const value = this.widget?.props?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
protected readPositiveIntegerProp(key: string, fallback: number): number {
|
||||
const value = this.widget?.props?.[key];
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
protected retryLoad(): void {
|
||||
void this.refresh(true);
|
||||
}
|
||||
|
||||
protected async moveCard(card: WorkboardCard, status: WorkboardStatus): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client || card.status === status) {
|
||||
return;
|
||||
}
|
||||
const state = getWorkboardState(this.workboardHost);
|
||||
state.cards = [...this.cards];
|
||||
state.statuses = this.statuses;
|
||||
state.loaded = true;
|
||||
state.loadAttempted = true;
|
||||
state.mutationReadiness = "ready";
|
||||
const position =
|
||||
Math.max(
|
||||
-1,
|
||||
...state.cards
|
||||
.filter((candidate) => candidate.status === status)
|
||||
.map((candidate) => candidate.position),
|
||||
) + 1;
|
||||
await moveWorkboardCard({
|
||||
host: this.workboardHost,
|
||||
client,
|
||||
cardId: card.id,
|
||||
status,
|
||||
position,
|
||||
requestUpdate: () => this.syncFromHost(),
|
||||
});
|
||||
this.syncFromHost();
|
||||
}
|
||||
|
||||
private syncGateway(snapshot: ApplicationContext["gateway"]["snapshot"] | undefined): void {
|
||||
const nextClient = snapshot?.connected ? snapshot.client : null;
|
||||
if (this.client === nextClient) {
|
||||
return;
|
||||
}
|
||||
this.client = nextClient;
|
||||
this.refreshGeneration += 1;
|
||||
this.refreshPromise = null;
|
||||
this.refreshPending = false;
|
||||
this.loaded = false;
|
||||
this.loadAttempted = false;
|
||||
this.loading = false;
|
||||
this.error = "";
|
||||
this.requestRender();
|
||||
if (nextClient) {
|
||||
void this.refresh(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh(force = false): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client || (!force && this.loaded)) {
|
||||
return;
|
||||
}
|
||||
if (this.refreshPromise) {
|
||||
if (force) {
|
||||
this.refreshPending = true;
|
||||
}
|
||||
return await this.refreshPromise;
|
||||
}
|
||||
const generation = ++this.refreshGeneration;
|
||||
this.loadAttempted = true;
|
||||
this.loading = true;
|
||||
this.error = "";
|
||||
this.requestRender();
|
||||
const refresh = (async () => {
|
||||
try {
|
||||
const normalized = normalizeCardsPayload(await client.request("workboard.cards.list", {}));
|
||||
if (generation !== this.refreshGeneration || client !== this.client) {
|
||||
return;
|
||||
}
|
||||
this.cards = normalized.cards;
|
||||
this.statuses = normalized.statuses;
|
||||
this.loaded = true;
|
||||
} catch (error) {
|
||||
if (generation === this.refreshGeneration && client === this.client) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.refreshGeneration) {
|
||||
this.loading = false;
|
||||
this.requestRender();
|
||||
}
|
||||
}
|
||||
})();
|
||||
this.refreshPromise = refresh;
|
||||
try {
|
||||
await refresh;
|
||||
} finally {
|
||||
if (this.refreshPromise === refresh) {
|
||||
this.refreshPromise = null;
|
||||
const shouldRefreshAgain =
|
||||
this.refreshPending && generation === this.refreshGeneration && client === this.client;
|
||||
this.refreshPending = false;
|
||||
if (shouldRefreshAgain) {
|
||||
await this.refresh(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncFromHost(): void {
|
||||
const state = getWorkboardState(this.workboardHost);
|
||||
this.cards = [...state.cards];
|
||||
this.statuses = state.statuses;
|
||||
this.error = state.error ?? "";
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
private requestRender(): void {
|
||||
this.requestUpdate();
|
||||
this.hostRequestUpdate?.();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ApplicationContext } from "../../../app/context.ts";
|
||||
import { createApplicationContextProvider } from "../../../test-helpers/application-context.ts";
|
||||
import type { BoardViewWidget } from "../view-types.ts";
|
||||
import "./workboard-card.ts";
|
||||
import "./workboard-mini.ts";
|
||||
|
||||
const cards = [
|
||||
{
|
||||
id: "card-ready",
|
||||
title: "Ready card",
|
||||
status: "ready",
|
||||
priority: "high",
|
||||
labels: [],
|
||||
position: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "agent-a",
|
||||
metadata: { automation: { boardId: "ops" } },
|
||||
},
|
||||
{
|
||||
id: "card-running",
|
||||
title: "Running card",
|
||||
status: "running",
|
||||
priority: "normal",
|
||||
labels: [],
|
||||
position: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
metadata: { automation: { boardId: "ops" } },
|
||||
},
|
||||
{
|
||||
id: "card-done",
|
||||
title: "Done card",
|
||||
status: "done",
|
||||
priority: "low",
|
||||
labels: [],
|
||||
position: 2,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
metadata: { automation: { boardId: "ops" } },
|
||||
},
|
||||
] as const;
|
||||
|
||||
function pluginWidget(pluginKind: string, props: Record<string, unknown>): BoardViewWidget {
|
||||
return {
|
||||
name: pluginKind.replace(":", "-"),
|
||||
tabId: "main",
|
||||
contentKind: "plugin",
|
||||
pluginKind,
|
||||
props,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(
|
||||
request: ReturnType<typeof vi.fn>,
|
||||
events?: { listener?: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0] },
|
||||
): ApplicationContext {
|
||||
const subscribe = () => () => undefined;
|
||||
return {
|
||||
basePath: "/control",
|
||||
gateway: {
|
||||
snapshot: {
|
||||
client: { request } as never,
|
||||
connected: true,
|
||||
reconnecting: false,
|
||||
hello: null,
|
||||
assistantAgentId: null,
|
||||
sessionKey: "agent:main:test",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
},
|
||||
subscribe,
|
||||
subscribeEvents: (
|
||||
listener: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0],
|
||||
) => {
|
||||
if (events) {
|
||||
events.listener = listener;
|
||||
}
|
||||
return () => undefined;
|
||||
},
|
||||
} as unknown as ApplicationContext["gateway"],
|
||||
} as unknown as ApplicationContext;
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function mount<T extends HTMLElement>(
|
||||
element: T,
|
||||
context: ApplicationContext,
|
||||
request: ReturnType<typeof vi.fn>,
|
||||
): Promise<T> {
|
||||
const provider = createApplicationContextProvider(context);
|
||||
provider.append(element);
|
||||
document.body.append(provider);
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("workboard.cards.list", {}));
|
||||
await (element as T & { updateComplete: Promise<boolean> }).updateComplete;
|
||||
await vi.waitFor(() => expect(element.textContent).not.toContain("Loading Workboard"));
|
||||
return element;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workboard plugin widgets", () => {
|
||||
it("renders a card and moves it through the shared mutation helper", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "workboard.cards.list") {
|
||||
return { cards, statuses: ["ready", "running", "done"] };
|
||||
}
|
||||
if (method === "workboard.cards.move") {
|
||||
return { card: { ...cards[0], status: "running", position: 2 } };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
});
|
||||
const element = document.createElement("openclaw-workboard-card-widget");
|
||||
element.widget = pluginWidget("workboard:card", { cardId: "card-ready" });
|
||||
element.sessionKey = "agent:main:test";
|
||||
await mount(element, createContext(request), request);
|
||||
|
||||
expect(element.textContent).toContain("Ready card");
|
||||
expect(element.textContent).toContain("agent-a");
|
||||
const select = element.querySelector("select") as HTMLSelectElement;
|
||||
select.value = "running";
|
||||
select.dispatchEvent(new Event("change"));
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(request).toHaveBeenCalledWith("workboard.cards.move", {
|
||||
id: "card-ready",
|
||||
status: "running",
|
||||
position: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders per-status board counts and the top ready/running cards", async () => {
|
||||
const request = vi.fn(async () => ({ cards, statuses: ["ready", "running", "done"] }));
|
||||
const element = document.createElement("openclaw-workboard-mini-widget");
|
||||
element.widget = pluginWidget("workboard:mini", { boardId: "ops", limit: 2 });
|
||||
element.sessionKey = "agent:main:test";
|
||||
await mount(element, createContext(request), request);
|
||||
|
||||
const counts = [...element.querySelectorAll(".workboard-widget-mini__counts span")].map(
|
||||
(entry) => entry.textContent?.replace(/\s+/g, " ").trim(),
|
||||
);
|
||||
expect(counts).toContain("1 Ready");
|
||||
expect(counts).toContain("1 Running");
|
||||
expect(counts).toContain("1 Done");
|
||||
expect(element.textContent).toContain("Running card");
|
||||
expect(element.textContent).toContain("Ready card");
|
||||
expect(element.querySelector("a")?.getAttribute("href")).toBe("/control/workboard?board=ops");
|
||||
});
|
||||
|
||||
it("retries a transient initial list failure without reconnecting", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("temporary failure"))
|
||||
.mockResolvedValueOnce({ cards, statuses: ["ready", "running", "done"] });
|
||||
const element = document.createElement("openclaw-workboard-mini-widget");
|
||||
element.widget = pluginWidget("workboard:mini", { boardId: "ops" });
|
||||
element.sessionKey = "agent:main:test";
|
||||
await mount(element, createContext(request), request);
|
||||
|
||||
expect(element.textContent).toContain("temporary failure");
|
||||
const retry = element.querySelector("button");
|
||||
expect(retry?.textContent?.trim()).toBe("Retry");
|
||||
retry?.click();
|
||||
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(element.textContent).toContain("Running card"));
|
||||
});
|
||||
|
||||
it("queues a second refresh when a change arrives during an active list request", async () => {
|
||||
const firstList = deferred<unknown>();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "workboard.cards.list") {
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}
|
||||
return request.mock.calls.length === 1
|
||||
? await firstList.promise
|
||||
: { cards, statuses: ["ready", "running", "done"] };
|
||||
});
|
||||
const events: {
|
||||
listener?: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0];
|
||||
} = {};
|
||||
const element = document.createElement("openclaw-workboard-mini-widget");
|
||||
element.widget = pluginWidget("workboard:mini", { boardId: "ops" });
|
||||
const provider = createApplicationContextProvider(createContext(request, events));
|
||||
provider.append(element);
|
||||
document.body.append(provider);
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||
|
||||
events.listener?.({
|
||||
type: "event",
|
||||
event: "plugin.workboard.changed",
|
||||
payload: { epoch: "epoch-a", revision: 2 },
|
||||
});
|
||||
firstList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
|
||||
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("restarts loading after reconnecting while the previous request is pending", async () => {
|
||||
const firstList = deferred<unknown>();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "workboard.cards.list") {
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}
|
||||
return request.mock.calls.length === 1
|
||||
? await firstList.promise
|
||||
: { cards, statuses: ["ready", "running", "done"] };
|
||||
});
|
||||
const element = document.createElement("openclaw-workboard-mini-widget");
|
||||
element.widget = pluginWidget("workboard:mini", { boardId: "ops" });
|
||||
const provider = createApplicationContextProvider(createContext(request));
|
||||
provider.append(element);
|
||||
document.body.append(provider);
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||
|
||||
element.remove();
|
||||
provider.append(element);
|
||||
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(element.textContent).toContain("Running card"));
|
||||
firstList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
|
||||
});
|
||||
|
||||
it("keeps a queued refresh owned by the current gateway generation", async () => {
|
||||
const staleList = deferred<unknown>();
|
||||
const currentList = deferred<unknown>();
|
||||
const staleRequest = vi.fn(async () => await staleList.promise);
|
||||
const currentRequest = vi.fn(async (method: string) => {
|
||||
if (method !== "workboard.cards.list") {
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}
|
||||
return currentRequest.mock.calls.length === 1
|
||||
? await currentList.promise
|
||||
: { cards, statuses: ["ready", "running", "done"] };
|
||||
});
|
||||
const currentEvents: {
|
||||
listener?: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0];
|
||||
} = {};
|
||||
const element = document.createElement("openclaw-workboard-mini-widget");
|
||||
element.widget = pluginWidget("workboard:mini", { boardId: "ops" });
|
||||
const provider = createApplicationContextProvider(createContext(staleRequest));
|
||||
provider.append(element);
|
||||
document.body.append(provider);
|
||||
await vi.waitFor(() => expect(staleRequest).toHaveBeenCalledTimes(1));
|
||||
|
||||
provider.setContext(createContext(currentRequest, currentEvents));
|
||||
await vi.waitFor(() => expect(currentRequest).toHaveBeenCalledTimes(1));
|
||||
currentEvents.listener?.({
|
||||
type: "event",
|
||||
event: "plugin.workboard.changed",
|
||||
payload: { epoch: "epoch-current", revision: 2 },
|
||||
});
|
||||
staleList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
|
||||
await Promise.resolve();
|
||||
currentList.resolve({ cards: [], statuses: ["ready", "running", "done"] });
|
||||
|
||||
await vi.waitFor(() => expect(currentRequest).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(element.textContent).toContain("Running card"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user