feat(dashboard): stitch session dashboards end-to-end — live provider, show_widget pin, board commands (#111218)

* feat(dashboard): pin canvas widgets through board domain

* feat(control-ui): wire session dashboards end to end

* fix(control-ui): preserve direct reset confirmation

* fix(control-ui): localize dashboard default tab

* test(dashboard): satisfy changed typecheck gate

* fix(dashboard): satisfy changed lint gate

* fix(dashboard): harden pinned widget lifecycle

* fix(dashboard): use extracted mock normalizer

* test(dashboard): reconcile hardened board admission tests

* fix(dashboard): align grant and canvas protocol requests

* fix(dashboard): preserve refresh and pin invariants

* fix(boards): treat missing board tables as empty on read-only paths

* fix(boards): accept read-only handles in board table probe

* fix(control-ui): preserve virtual board widget types

* fix(dashboard): align view types with protocol

* fix(dashboard): keep resolved board view typed

* chore(canvas): drop test-only html read wrapper

* chore(release): defer dashboard note generation

* fix(protocol): align board widget put params type

* fix(control-ui): preserve queued reset approval

* fix(control-ui): gate dashboard pinning capability

* chore(test): isolate dashboard capability fixture

* fix(control-ui): refresh dashboard pin affordance
This commit is contained in:
Peter Steinberger
2026-07-19 12:36:54 -07:00
committed by GitHub
parent 431da14410
commit 06480d767c
55 changed files with 3052 additions and 394 deletions
@@ -546,6 +546,24 @@ public struct BoardWidgetMcpAppContent: Codable, Sendable {
}
}
public struct BoardCanvasDocumentSource: Codable, Sendable {
public let kind: String
public let docid: String
public init(
kind: String,
docid: String)
{
self.kind = kind
self.docid = docid
}
private enum CodingKeys: String, CodingKey {
case kind
case docid = "docId"
}
}
public struct BoardGetParams: Codable, Sendable {
public let sessionkey: String
@@ -582,7 +600,7 @@ public struct BoardWidgetPutParams: Codable, Sendable {
public let sessionkey: String
public let name: String
public let title: String?
public let content: BoardWidgetContent
public let content: BoardWidgetPutContent
public let placement: [String: AnyCodable]?
public let declared: [String: AnyCodable]?
@@ -590,7 +608,7 @@ public struct BoardWidgetPutParams: Codable, Sendable {
sessionkey: String,
name: String,
title: String? = nil,
content: BoardWidgetContent,
content: BoardWidgetPutContent,
placement: [String: AnyCodable]? = nil,
declared: [String: AnyCodable]? = nil)
{
@@ -15558,6 +15576,40 @@ public enum BoardWidgetContent: Codable, Sendable {
}
}
public enum BoardWidgetPutContent: Codable, Sendable {
case html(BoardWidgetHtmlContent)
case mcpApp(BoardWidgetMcpAppContent)
case canvasDoc(BoardCanvasDocumentSource)
private enum CodingKeys: String, CodingKey {
case discriminator = "kind"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let discriminator = try container.decode(String.self, forKey: .discriminator)
switch discriminator {
case "html": self = try .html(BoardWidgetHtmlContent(from: decoder))
case "mcp-app": self = try .mcpApp(BoardWidgetMcpAppContent(from: decoder))
case "canvas-doc": self = try .canvasDoc(BoardCanvasDocumentSource(from: decoder))
default:
throw DecodingError.dataCorruptedError(
forKey: .discriminator,
in: container,
debugDescription: "Unknown BoardWidgetPutContent discriminator value"
)
}
}
public func encode(to encoder: Encoder) throws {
switch self {
case .html(let value): try value.encode(to: encoder)
case .mcpApp(let value): try value.encode(to: encoder)
case .canvasDoc(let value): try value.encode(to: encoder)
}
}
}
public enum BoardCommand: Codable, Sendable {
case focusTab(BoardFocusTabCommand)
case setChatDock(BoardSetChatDockCommand)
+3
View File
@@ -358,6 +358,9 @@ const config = {
"src/boards/board-notices.ts": ["exports"],
"src/boards/board-store.ts": ["exports"],
"src/gateway/board-view-ticket.ts": ["exports"],
// GatewayBoardProvider and boardExists are constructed/asserted by the
// focused Control UI provider tests, not by a separate production module.
"ui/src/lib/board/provider.ts": ["exports"],
},
workspaces: {
".": {
+11 -1
View File
@@ -15,6 +15,8 @@ read_when:
When the agent calls `show_widget`, OpenClaw core wraps `widget_code` in a minimal HTML document, stores it as a Canvas document, and returns a preview handle. The Control UI renders that handle as a sandboxed iframe directly under the tool call, while native apps use an isolated web view. Both restore the widget after history reload.
In Control UI sessions, a Canvas widget can also be pinned to the session dashboard. Set `pin: true` in the tool call, or use **Pin to dashboard** on an existing transcript widget. Pinning reuses the exact hosted document; it does not fetch widget HTML through the browser.
For browser embedding, the wrapper document injects four small host bridges around the widget code:
- A size reporter posts the rendered content height to the embedding chat, which clamps it and fits the iframe (160 to 1200 pixels).
@@ -87,7 +89,15 @@ Both implementations use the same required fields:
Discord also accepts optional `button_label` text for the Activity launch button. The Canvas schema intentionally omits this Discord-only field.
The core result includes a Canvas preview handle, so the Control UI and supported native apps render the widget directly from the tool call and restore it after history reload. Discord returns the stored widget and posted-message identifiers.
The core Canvas tool accepts these optional dashboard placement fields:
- `pin`: also place the widget on the session dashboard.
- `name`: stable widget name; defaults to a slug of `title`.
- `tab`: destination tab slug.
- `size`: one of `sm`, `md`, `lg`, `xl`, or `full`.
- `after`: sibling widget name after which to place the widget.
The core result includes a Canvas preview handle, so the Control UI and supported native apps render the widget directly from the tool call and restore it after history reload. Pinned results also retain the board widget name so the Control UI does not offer a duplicate pin after transcript reload. Discord returns the stored widget and posted-message identifiers.
`discord_widget` remains registered as a deprecated alias for one release. New agent calls should use `show_widget`.
+2
View File
@@ -22,6 +22,7 @@ import {
BoardEventParamsSchema,
BoardGetParamsSchema,
BoardUpdateParamsSchema,
BoardWidgetContentSchema,
BoardWidgetGrantParamsSchema,
BoardWidgetPutParamsSchema,
AgentEventSchema,
@@ -668,6 +669,7 @@ export const validateAgentsListParams = lazyCompile(AgentsListParamsSchema);
export const validateWorktreesListParams = lazyCompile(WorktreesListParamsSchema);
export const validateBoardGetParams = lazyCompile(BoardGetParamsSchema);
export const validateBoardUpdateParams = lazyCompile(BoardUpdateParamsSchema);
export const validateBoardWidgetContent = lazyCompile(BoardWidgetContentSchema);
export const validateBoardWidgetPutParams = lazyCompile(BoardWidgetPutParamsSchema);
export const validateBoardWidgetGrantParams = lazyCompile(BoardWidgetGrantParamsSchema);
export const validateBoardEventParams = lazyCompile(BoardEventParamsSchema);
@@ -1,6 +1,10 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { BoardSnapshotSchema, BoardWidgetGrantParamsSchema } from "./board.js";
import {
BoardSnapshotSchema,
BoardWidgetGrantParamsSchema,
BoardWidgetPutParamsSchema,
} from "./board.js";
describe("BoardSnapshotSchema", () => {
it("accepts optional HTML widget view metadata", () => {
@@ -37,6 +41,40 @@ describe("BoardSnapshotSchema", () => {
}),
).toBe(false);
});
it("accepts declared grant summaries", () => {
const widget = {
name: "status",
tabId: "main",
contentKind: "mcp-app",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "pending",
declaredSummary: ["Network: api.example.com", "Tools: lookup"],
revision: 1,
};
const snapshot = {
sessionKey: "agent:main:main",
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
widgets: [widget],
};
expect(Value.Check(BoardSnapshotSchema, snapshot)).toBe(true);
});
});
describe("BoardWidgetPutParamsSchema", () => {
it("accepts a gateway-resolved canvas document source", () => {
expect(
Value.Check(BoardWidgetPutParamsSchema, {
sessionKey: "agent:main:main",
name: "status",
content: { kind: "canvas-doc", docId: "cv_status" },
}),
).toBe(true);
});
});
describe("BoardWidgetGrantParamsSchema", () => {
+18 -1
View File
@@ -138,11 +138,24 @@ export const BoardWidgetContentSchema = Type.Union([
]);
export type BoardWidgetContent = Static<typeof BoardWidgetContentSchema>;
export const BoardCanvasDocumentSourceSchema = closedObject({
kind: Type.Literal("canvas-doc"),
docId: NonEmptyString,
});
export type BoardCanvasDocumentSource = Static<typeof BoardCanvasDocumentSourceSchema>;
export const BoardWidgetPutContentSchema = Type.Union([
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContentSchema,
BoardCanvasDocumentSourceSchema,
]);
export type BoardWidgetPutContent = Static<typeof BoardWidgetPutContentSchema>;
export const BoardWidgetPutParamsSchema = closedObject({
sessionKey: NonEmptyString,
name: BoardWidgetNameSchema,
title: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })),
content: BoardWidgetContentSchema,
content: BoardWidgetPutContentSchema,
placement: Type.Optional(
closedObject({
tabId: Type.Optional(BoardTabIdSchema),
@@ -158,6 +171,10 @@ export const BoardWidgetPutParamsSchema = closedObject({
),
});
export type BoardWidgetPutParams = Static<typeof BoardWidgetPutParamsSchema>;
/** Materialized input accepted by the board store after gateway source resolution. */
export type BoardWidgetMaterializedPutParams = Omit<BoardWidgetPutParams, "content"> & {
content: BoardWidgetContent;
};
export const BoardWidgetGrantParamsSchema = closedObject({
sessionKey: NonEmptyString,
@@ -6,6 +6,7 @@ import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
export const GATEWAY_SERVER_CAPS = {
BOARD_WIDGET_PUT_CANVAS_DOC: "board-widget-put-canvas-doc",
CHAT_SEND_ROUTING_CONTRACT: "chat-send-routing-contract",
SYSTEM_AGENT_SETUP_MODEL_REF: "openclaw-setup-model-ref",
} as const;
@@ -152,6 +152,7 @@ import {
} from "./audit-activity.js";
import { AuditEventSchema, AuditListParamsSchema, AuditListResultSchema } from "./audit.js";
import {
BoardCanvasDocumentSourceSchema,
BoardChangedEventSchema,
BoardCommandEventSchema,
BoardCommandSchema,
@@ -173,6 +174,7 @@ import {
BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContentSchema,
BoardWidgetMoveOpSchema,
BoardWidgetPutContentSchema,
BoardWidgetPutParamsSchema,
BoardWidgetRemoveOpSchema,
BoardWidgetResizeOpSchema,
@@ -600,7 +602,9 @@ export const ProtocolSchemas = {
BoardMcpAppDescriptor: BoardMcpAppDescriptorSchema,
BoardWidgetHtmlContent: BoardWidgetHtmlContentSchema,
BoardWidgetMcpAppContent: BoardWidgetMcpAppContentSchema,
BoardCanvasDocumentSource: BoardCanvasDocumentSourceSchema,
BoardWidgetContent: BoardWidgetContentSchema,
BoardWidgetPutContent: BoardWidgetPutContentSchema,
BoardGetParams: BoardGetParamsSchema,
BoardUpdateParams: BoardUpdateParamsSchema,
BoardWidgetPutParams: BoardWidgetPutParamsSchema,
+1
View File
@@ -546,6 +546,7 @@ export function createOpenClawTools(
createShowWidgetTool({
sessionId: options?.sessionId,
agentId: sessionAgentId,
agentSessionKey: options?.runSessionKey ?? options?.agentSessionKey,
}),
]),
...collectPresentOpenClawTools([heartbeatTool]),
+4
View File
@@ -256,6 +256,10 @@ describe("SqliteBoardStore persistence", () => {
resolveSession: () => ({ agentId: "main", sessionKey }),
env,
});
// Reads before any write must see "no boards", not "no such table".
expect(store.getSnapshot(sessionKey)).toMatchObject({ revision: 0, tabs: [], widgets: [] });
expect(store.readWidgetHtml(sessionKey, "status")).toBeUndefined();
expect(store.listSessionsWithBoards()).toEqual([]);
expect(() =>
store.putWidget({
sessionKey,
+5 -5
View File
@@ -4,7 +4,7 @@ import type {
BoardOp,
BoardSnapshot,
BoardWidgetContent,
BoardWidgetPutParams,
BoardWidgetMaterializedPutParams,
} from "../../packages/gateway-protocol/src/index.js";
import {
applyBoardOps,
@@ -31,7 +31,7 @@ export type BoardWidgetDocument = BoardWidgetHtmlDocument | BoardWidgetMcpAppDoc
export interface BoardStore {
getSnapshot(sessionKey: string): BoardSnapshot;
applyOps(sessionKey: string, ops: readonly BoardOp[]): BoardSnapshot;
putWidget(params: BoardWidgetPutParams): BoardSnapshot;
putWidget(params: BoardWidgetMaterializedPutParams): BoardSnapshot;
grant(
sessionKey: string,
name: string,
@@ -86,7 +86,7 @@ function createBoardWidgetDocument(
}
export function createBoardDeclaredSummary(
declared: BoardWidgetPutParams["declared"],
declared: BoardWidgetMaterializedPutParams["declared"],
): string[] | undefined {
const lines = [
...(declared?.netOrigins ?? []).map((origin) => `Network access: ${origin}`),
@@ -97,7 +97,7 @@ export function createBoardDeclaredSummary(
export function createBoardWidgetPutSnapshot(
prior: BoardSnapshot,
params: BoardWidgetPutParams,
params: BoardWidgetMaterializedPutParams,
): BoardSnapshot {
if (
params.content.kind === "html" &&
@@ -226,7 +226,7 @@ export class InMemoryBoardStore implements BoardStore {
return cloneBoardSnapshot(next);
}
putWidget(params: BoardWidgetPutParams): BoardSnapshot {
putWidget(params: BoardWidgetMaterializedPutParams): BoardSnapshot {
const current = this.boards.get(params.sessionKey);
const prior = current?.snapshot ?? emptyBoardSnapshot(params.sessionKey);
const snapshot = createBoardWidgetPutSnapshot(prior, params);
+26 -6
View File
@@ -7,7 +7,7 @@ import type {
BoardSnapshot,
BoardTab,
BoardWidget,
BoardWidgetPutParams,
BoardWidgetMaterializedPutParams,
} from "../../packages/gateway-protocol/src/index.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
import {
@@ -54,6 +54,23 @@ type StoredBoard = {
const ensuredBoardDatabases = 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)) {
return true;
}
const row = database.db // sqlite-allow-raw: catalog probe before Kysely table access.
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'board_widgets'")
.get();
if (!row) {
return false;
}
ensuredBoardDatabases.add(database.db);
return true;
}
function ensureBoardSchema(database: OpenClawAgentDatabase): void {
if (ensuredBoardDatabases.has(database.db)) {
return;
@@ -82,7 +99,7 @@ type SqliteBoardStoreOptions = {
env?: NodeJS.ProcessEnv;
};
function parseManifest(value: string): BoardWidgetPutParams["declared"] {
function parseManifest(value: string): BoardWidgetMaterializedPutParams["declared"] {
const parsed = JSON.parse(value) as { netOrigins?: unknown; tools?: unknown };
const netOrigins = Array.isArray(parsed.netOrigins)
? parsed.netOrigins.filter((entry): entry is string => typeof entry === "string")
@@ -270,7 +287,7 @@ function deleteRemovedTabs(
}
function contentFields(
params: BoardWidgetPutParams,
params: BoardWidgetMaterializedPutParams,
revision: number,
grantState: BoardWidget["grantState"],
viewGeneration: string,
@@ -373,7 +390,7 @@ export class SqliteBoardStore implements BoardStore {
const resolved = this.resolve(sessionKey);
const result = withOpenClawAgentDatabaseReadOnly(
(database) =>
hasSession(database, resolved.sessionKey)
hasSession(database, resolved.sessionKey) && boardTablesPresent(database)
? readStoredBoard(database, resolved.sessionKey).snapshot
: undefined,
{
@@ -419,7 +436,7 @@ export class SqliteBoardStore implements BoardStore {
);
}
putWidget(params: BoardWidgetPutParams): BoardSnapshot {
putWidget(params: BoardWidgetMaterializedPutParams): BoardSnapshot {
const { database, resolved } = this.prepareWrite(params.sessionKey);
const canonicalParams = { ...params, sessionKey: resolved.sessionKey };
const viewGeneration = randomBytes(16).toString("hex");
@@ -523,7 +540,7 @@ export class SqliteBoardStore implements BoardStore {
const resolved = this.resolve(sessionKey);
const result = withOpenClawAgentDatabaseReadOnly(
(database) => {
if (!hasSession(database, resolved.sessionKey)) {
if (!hasSession(database, resolved.sessionKey) || !boardTablesPresent(database)) {
return undefined;
}
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
@@ -583,6 +600,9 @@ export class SqliteBoardStore implements BoardStore {
resolveOpenClawAgentSqlitePath({ agentId, env: this.options.env });
const result = withOpenClawAgentDatabaseReadOnly(
(database) => {
if (!boardTablesPresent(database)) {
return [];
}
const db = getNodeSqliteKysely<BoardDatabase>(database.db);
return executeSqliteQuerySync(
database.db,
+21
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createCanvasDocument,
readCanvasDocumentHtmlSource,
resolveCanvasDocumentsDir,
resolveCanvasHttpPathToLocalPath,
} from "./documents.js";
@@ -68,6 +69,26 @@ describe("canvas documents", () => {
expect(indexHtml).toContain("<style>.demo{color:red}</style>");
expect(document.title).toBe("Preview");
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).resolves.toEqual({
html: indexHtml,
});
});
it("reports the document sandbox policy alongside board source bytes", async () => {
const stateDir = await createTempDir();
const document = await createCanvasDocument(
{
kind: "html_bundle",
entrypoint: { type: "html", value: "<script>ready()</script>" },
cspSandbox: "scripts",
},
{ stateDir },
);
await expect(readCanvasDocumentHtmlSource(document.id, { stateDir })).resolves.toEqual({
html: "<script>ready()</script>",
cspSandbox: "scripts",
});
});
it("reuses a supplied stable id by replacing the prior materialized view", async () => {
+27
View File
@@ -116,6 +116,33 @@ export function resolveCanvasDocumentsDir(stateDir = resolveStateDir()): string
return path.resolve(stateDir, "canvas", "documents");
}
/** Reads the managed HTML entrypoint for a core Canvas document. */
export async function readCanvasDocumentHtmlSource(
documentId: string,
options?: { stateDir?: string },
): Promise<{ html: string; cspSandbox?: "scripts" }> {
const id = normalizeCanvasDocumentId(documentId);
const documentDir = resolveCanvasDocumentDir(id, options);
const manifest = JSON.parse(
await fs.readFile(path.join(documentDir, "manifest.json"), "utf8"),
) as Partial<CanvasDocumentManifest>;
if (manifest.id !== id || typeof manifest.localEntrypoint !== "string") {
throw new Error(`canvas document has no local entrypoint: ${id}`);
}
const entrypoint = normalizeLogicalPath(manifest.localEntrypoint);
if (!entrypoint.toLowerCase().endsWith(".html")) {
throw new Error(`canvas document entrypoint is not HTML: ${id}`);
}
const localPath = path.resolve(documentDir, entrypoint);
if (!localPath.startsWith(`${documentDir}${path.sep}`)) {
throw new Error(`canvas document entrypoint escapes its document: ${id}`);
}
return {
html: await fs.readFile(localPath, "utf8"),
...(manifest.cspSandbox === "scripts" ? { cspSandbox: "scripts" as const } : {}),
};
}
async function pruneCanvasDocumentsForScope(params: {
documentsDir: string;
retentionScope: string;
+188 -2
View File
@@ -4,6 +4,10 @@ import { access, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { InProcessGatewayCaller } from "../agents/tools/in-process-gateway.js";
import { InMemoryBoardStore } from "../boards/board-store.js";
import { createBoardHandlers } from "../gateway/server-methods/board.js";
import type { GatewayRequestContext, RespondFn } from "../gateway/server-methods/types.js";
import { resolveCanvasDocumentsDir } from "./documents.js";
import { createShowWidgetTool } from "./widget-tool.js";
import { buildWidgetDocument } from "./wrap.js";
@@ -32,15 +36,29 @@ async function executeWidget(params: {
sessionId?: string;
title?: string;
widgetCode: string;
agentSessionKey?: string;
callGateway?: InProcessGatewayCaller;
pin?: boolean;
name?: string;
tab?: string;
size?: "sm" | "md" | "lg" | "xl" | "full";
after?: string;
}) {
const tool = createShowWidgetTool({
stateDir: params.stateDir,
sessionId: params.sessionId ?? "widget-session",
agentId: "main",
agentSessionKey: params.agentSessionKey,
callGateway: params.callGateway,
});
const result = await tool.execute("widget-call", {
title: params.title ?? "Widget title",
widget_code: params.widgetCode,
...(params.pin !== undefined ? { pin: params.pin } : {}),
...(params.name ? { name: params.name } : {}),
...(params.tab ? { tab: params.tab } : {}),
...(params.size ? { size: params.size } : {}),
...(params.after ? { after: params.after } : {}),
});
const text = result.content.find((item) => item.type === "text")?.text;
if (!text) {
@@ -49,14 +67,22 @@ async function executeWidget(params: {
const parsed = JSON.parse(text) as {
kind?: string;
presentation?: { target?: string; title?: string; sandbox?: string };
view?: { id?: string; url?: string };
view?: { id?: string; url?: string; boardWidgetName?: string };
text?: string;
};
const viewId = parsed.view?.id;
const url = parsed.view?.url;
if (parsed.kind !== "canvas" || !viewId || !url) {
throw new Error("expected canvas preview handle");
}
return { viewId, url, sandbox: parsed.presentation?.sandbox, text };
return {
viewId,
url,
sandbox: parsed.presentation?.sandbox,
resultText: parsed.text,
boardWidgetName: parsed.view?.boardWidgetName,
text,
};
}
describe("show_widget", () => {
@@ -87,6 +113,20 @@ describe("show_widget", () => {
).rejects.toThrow(`widget_code exceeds maximum size (${WIDGET_CODE_MAX_CHARS} characters)`);
});
it("rejects pinning without a session before creating a Canvas document", async () => {
const stateDir = await createStateDir();
const tool = createShowWidgetTool({ stateDir, sessionId: "missing-agent-session" });
await expect(
tool.execute("pin", {
title: "Pinned",
widget_code: "<p>never materialized</p>",
pin: true,
}),
).rejects.toThrow("pin requires an agent session");
await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
});
it("wraps SVG widgets with the stable result and sandbox contracts", async () => {
const stateDir = await createStateDir();
const { viewId, url, sandbox, text } = await executeWidget({
@@ -125,6 +165,152 @@ describe("show_widget", () => {
expect(manifest.cspSandbox).toBe("scripts");
});
it("keeps unpinned behavior unchanged without a board call", async () => {
const stateDir = await createStateDir();
const callGateway = vi.fn();
const result = await executeWidget({
stateDir,
widgetCode: "<p>inline only</p>",
callGateway,
});
expect(result.resultText).toBe(`Widget hosted at ${result.url}`);
expect(callGateway).not.toHaveBeenCalled();
});
it("pins the exact wrapped bytes through the board domain and broadcasts", async () => {
const stateDir = await createStateDir();
const store = new InMemoryBoardStore();
const broadcast = vi.fn();
const handlers = createBoardHandlers(store);
const title = "Release Status ".repeat(8).trim();
const callGateway: InProcessGatewayCaller = async <T>(
method: string,
params: Record<string, unknown>,
): Promise<T> => {
let result: unknown;
let failure: Error | undefined;
const respond: RespondFn = (ok, payload, error) => {
if (ok) {
result = payload;
} else {
failure = new Error(error?.message ?? "board request failed");
}
};
await handlers[method]!({
req: { type: "req", id: "show-widget-pin", method, params },
params,
client: null,
isWebchatConnect: () => false,
respond,
context: { broadcast } as unknown as GatewayRequestContext,
});
if (failure) {
throw failure;
}
return result as T;
};
const result = await executeWidget({
stateDir,
agentSessionKey: "agent:main:pinned",
title,
widgetCode: "<p>ready</p>",
pin: true,
name: "release-status",
tab: "main",
size: "lg",
callGateway,
});
const canvasHtml = await readFile(
path.join(resolveCanvasDocumentDir(stateDir, result.viewId), "index.html"),
"utf8",
);
expect(store.readWidgetHtml("agent:main:pinned", "release-status")).toMatchObject({
html: canvasHtml,
revision: 1,
});
expect(store.getSnapshot("agent:main:pinned").widgets[0]?.title).toBe(
Array.from(title).slice(0, 80).join(""),
);
expect(result.resultText).toContain("pinned to dashboard tab main as release-status (lg)");
expect(result.boardWidgetName).toBe("release-status");
expect(broadcast).toHaveBeenCalledWith("board.changed", {
sessionKey: "agent:main:pinned",
revision: 1,
widget: "release-status",
});
});
it("keeps generated pin names distinct when titles cannot fit a plain slug", async () => {
const stateDir = await createStateDir();
const callGateway: InProcessGatewayCaller = async <T>(
_method: string,
params: Record<string, unknown>,
): Promise<T> => {
const request = params as { name: string; sessionKey: string };
return {
sessionKey: request.sessionKey,
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
widgets: [
{
name: request.name,
tabId: "main",
contentKind: "html",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none",
revision: 1,
},
],
} as T;
};
const unicodeA = await executeWidget({
stateDir,
agentSessionKey: "agent:main:pinned",
title: "状态",
widgetCode: "<p>one</p>",
pin: true,
callGateway,
});
const unicodeB = await executeWidget({
stateDir,
agentSessionKey: "agent:main:pinned",
title: "天气",
widgetCode: "<p>two</p>",
pin: true,
callGateway,
});
const longA = await executeWidget({
stateDir,
agentSessionKey: "agent:main:pinned",
title: `${"shared ".repeat(20)}alpha`,
widgetCode: "<p>three</p>",
pin: true,
callGateway,
});
const longB = await executeWidget({
stateDir,
agentSessionKey: "agent:main:pinned",
title: `${"shared ".repeat(20)}beta`,
widgetCode: "<p>four</p>",
pin: true,
callGateway,
});
expect(unicodeA.boardWidgetName).toMatch(/^widget-[a-f0-9]{8}$/u);
expect(unicodeB.boardWidgetName).toMatch(/^widget-[a-f0-9]{8}$/u);
expect(unicodeA.boardWidgetName).not.toBe(unicodeB.boardWidgetName);
expect(longA.boardWidgetName).not.toBe(longB.boardWidgetName);
expect(longA.boardWidgetName).toHaveLength(64);
expect(longB.boardWidgetName).toHaveLength(64);
});
it("keeps the host bridges ordered around HTML widget code", async () => {
const stateDir = await createStateDir();
const { viewId } = await executeWidget({
+102 -4
View File
@@ -1,7 +1,12 @@
/** Agent-facing inline chat widget tool. */
import { createHash } from "node:crypto";
import { Type } from "typebox";
import type { BoardSnapshot } from "../../packages/gateway-protocol/src/index.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "../agents/tools/common.js";
import {
callInProcessGatewayTool,
type InProcessGatewayCaller,
} from "../agents/tools/in-process-gateway.js";
import { assertWidgetHtmlSize, WidgetHtmlInputError } from "../plugin-sdk/widget-html.js";
import { createCanvasDocument } from "./documents.js";
import { buildWidgetDocument } from "./wrap.js";
@@ -13,14 +18,66 @@ const WIDGET_MAX_PER_SCOPE = 32;
const ShowWidgetToolSchema = Type.Object({
title: Type.String(),
widget_code: Type.String(),
name: Type.Optional(
Type.String({
pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
description: "Stable dashboard widget name when pinning",
}),
),
pin: Type.Optional(
Type.Boolean({ description: "Also pin this widget to the session dashboard" }),
),
tab: Type.Optional(
Type.String({ pattern: "^[a-z0-9-]{1,40}$", description: "Dashboard tab slug" }),
),
size: Type.Optional(
Type.Union(
[
Type.Literal("sm"),
Type.Literal("md"),
Type.Literal("lg"),
Type.Literal("xl"),
Type.Literal("full"),
],
{ description: "Dashboard size: sm, md, lg, xl, or full" },
),
),
after: Type.Optional(
Type.String({
pattern: "^[a-z0-9][a-z0-9._-]{0,63}$",
description: "Place after this dashboard widget name",
}),
),
});
type ShowWidgetToolOptions = {
sessionId?: string;
agentId?: string;
agentSessionKey?: string;
stateDir?: string;
callGateway?: InProcessGatewayCaller;
};
function slugWidgetName(title: string): string {
const slug = title
.normalize("NFKD")
.replace(/[\u0300-\u036f]/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "");
if (slug && slug.length <= 64) {
return slug;
}
const suffix = createHash("sha256").update(title).digest("hex").slice(0, 8);
const prefix = (slug || "widget").slice(0, 55).replace(/-+$/gu, "") || "widget";
return `${prefix}-${suffix}`;
}
function boardWidgetTitle(title: string): string | undefined {
const normalized = title.trim();
return normalized ? Array.from(normalized).slice(0, 80).join("") : undefined;
}
function resolveRetentionScope(options: ShowWidgetToolOptions): string {
const scope = options.sessionId
? `session:${options.sessionId}`
@@ -30,11 +87,12 @@ function resolveRetentionScope(options: ShowWidgetToolOptions): string {
/** Creates a self-contained widget hosted by OpenClaw core. */
export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAgentTool {
const gatewayCall = options.callGateway ?? callInProcessGatewayTool;
return {
label: "Show Widget",
name: "show_widget",
description:
'Show interactive self-contained HTML or SVG widget on the user\'s current surface. Inline everything; no external resources. Pre-themed: bare button, input, select, textarea, table, code, h1-h3 already styled — write minimal HTML. Helper classes: .card, .badge (.ok/.warn/.danger/.info), .metric, .muted, .row; button.primary = the one main action. Theme vars (auto light/dark, live host sync): --surface --card --elevated --text --text-strong --muted --border --border-strong --accent (links/focus/highlight) --accent-fill (primary bg) --accent-fg --ok --warn --danger --info (each with -subtle tint) --radius --font-body --font-mono. Colors ONLY via these vars — never hex/rgb/hsl, no own color palette; layout-only custom vars fine. Page background stays transparent. Pattern: <div class="card"><div class="muted">Uptime</div><div class="metric">18d</div></div> <span class="badge ok">connected</span>. Web chat: sendPrompt(text) sends text as the user\'s message — wire to buttons, suffix label with ↗; works only after a real click inside the widget (never call automatically; slash commands rejected).',
'Show interactive self-contained HTML or SVG widget on the user\'s current surface. Set pin=true to also place it on this session\'s dashboard; use name for a stable widget id, tab for a tab slug, size sm|md|lg|xl|full, and after for a sibling widget anchor. Inline everything; no external resources. Pre-themed: bare button, input, select, textarea, table, code, h1-h3 already styled — write minimal HTML. Helper classes: .card, .badge (.ok/.warn/.danger/.info), .metric, .muted, .row; button.primary = the one main action. Theme vars (auto light/dark, live host sync): --surface --card --elevated --text --text-strong --muted --border --border-strong --accent (links/focus/highlight) --accent-fill (primary bg) --accent-fg --ok --warn --danger --info (each with -subtle tint) --radius --font-body --font-mono. Colors ONLY via these vars — never hex/rgb/hsl, no own color palette; layout-only custom vars fine. Page background stays transparent. Pattern: <div class="card"><div class="muted">Uptime</div><div class="metric">18d</div></div> <span class="badge ok">connected</span>. Web chat: sendPrompt(text) sends text as the user\'s message — wire to buttons, suffix label with ↗; works only after a real click inside the widget (never call automatically; slash commands rejected).',
parameters: ShowWidgetToolSchema,
requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS,
execute: async (_toolCallId, args) => {
@@ -51,12 +109,18 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
inputName: "widget_code",
unit: "characters",
});
const shouldPin = params.pin === true;
const pinSessionKey = shouldPin ? options.agentSessionKey?.trim() : undefined;
if (shouldPin && !pinSessionKey) {
throw new WidgetHtmlInputError("pin requires an agent session");
}
const widgetCode = rawWidgetCode.trim();
const wrappedDocument = buildWidgetDocument(title, widgetCode);
const document = await createCanvasDocument(
{
kind: "html_bundle",
title,
entrypoint: { type: "html", value: buildWidgetDocument(title, widgetCode) },
entrypoint: { type: "html", value: wrappedDocument },
surface: "assistant_message",
retentionScope: resolveRetentionScope(options),
// Direct navigation must not run widget script as the Control UI origin.
@@ -67,11 +131,45 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
maxDocumentsPerScope: WIDGET_MAX_PER_SCOPE,
},
);
let pinnedText = "";
let pinnedWidgetName: string | undefined;
if (pinSessionKey) {
const sessionKey = pinSessionKey;
const name = readStringParam(params, "name") ?? slugWidgetName(title);
pinnedWidgetName = name;
const tab = readStringParam(params, "tab");
const size = readStringParam(params, "size");
const after = readStringParam(params, "after");
const pinnedTitle = boardWidgetTitle(title);
const snapshot = await gatewayCall<BoardSnapshot>("board.widget.put", {
sessionKey,
name,
...(pinnedTitle ? { title: pinnedTitle } : {}),
content: { kind: "html", html: wrappedDocument },
...(tab || size || after
? {
placement: {
...(tab ? { tabId: tab } : {}),
...(size ? { size } : {}),
...(after ? { after } : {}),
},
}
: {}),
});
const widget = snapshot.widgets.find((candidate) => candidate.name === name);
pinnedText = `; pinned to dashboard tab ${widget?.tabId ?? tab ?? "main"} as ${name}${
size ? ` (${size})` : ""
}`;
}
return jsonResult({
kind: "canvas",
presentation: { target: "assistant_message", title, sandbox: "scripts" },
view: { id: document.id, url: document.entryUrl },
text: `Widget hosted at ${document.entryUrl}`,
view: {
id: document.id,
url: document.entryUrl,
...(pinnedWidgetName ? { boardWidgetName: pinnedWidgetName } : {}),
},
text: `Widget hosted at ${document.entryUrl}${pinnedText}`,
});
},
};
+15
View File
@@ -7,6 +7,21 @@ import {
} from "./canvas-render.ts";
describe("extractCanvasFromText", () => {
it("preserves a valid pinned board widget identity", () => {
expect(
extractCanvasFromText(
JSON.stringify({
kind: "canvas",
view: {
id: "cv_status",
url: "/__openclaw__/canvas/documents/cv_status/index.html",
boardWidgetName: "release-status",
},
}),
),
).toMatchObject({ viewId: "cv_status", boardWidgetName: "release-status" });
});
it("extracts safe MCP App preview metadata from tool details", () => {
expect(
extractCanvasFromDetails({
+7
View File
@@ -29,6 +29,7 @@ type CanvasPreview = {
className?: string;
style?: string;
sandbox?: CanvasSandbox;
boardWidgetName?: string;
mcpApp?: McpAppPreviewDescriptor;
};
@@ -140,6 +141,11 @@ function coerceCanvasPreview(
const sandbox = normalizeSandbox(getRecordStringField(presentation, "sandbox"));
const viewUrl = getRecordStringField(view, "url") ?? getRecordStringField(view, "entryUrl");
const viewId = getRecordStringField(view, "id") ?? getRecordStringField(view, "docId");
const requestedBoardWidgetName = getRecordStringField(view, "boardWidgetName");
const boardWidgetName =
requestedBoardWidgetName && /^[a-z0-9][a-z0-9._-]{0,63}$/u.test(requestedBoardWidgetName)
? requestedBoardWidgetName
: undefined;
if (mcpAppViewId && viewId === mcpAppViewId) {
return {
kind: "canvas",
@@ -164,6 +170,7 @@ function coerceCanvasPreview(
...(className ? { className } : {}),
...(style ? { style } : {}),
...(sandbox ? { sandbox } : {}),
...(boardWidgetName ? { boardWidgetName } : {}),
...(mcpApp ? { mcpApp } : {}),
};
}
+16
View File
@@ -52,6 +52,12 @@ beforeAll(async () => {
name: "revisioned",
content: { kind: "html", html: "<p>one</p>" },
});
store.putWidget({
sessionKey: "agent:main:main",
name: "grantable",
content: { kind: "html", html: "<script>pending()</script>" },
declared: { netOrigins: ["https://example.com"] },
});
server = createServer((req, res) => {
const handled = handleBoardHttpRequest(req, res, {
store,
@@ -161,6 +167,16 @@ describe("board widget HTTP", () => {
readSpy.mockRestore();
});
it("withholds declared widget bytes until the operator grants them", async () => {
const ticket = ticketFor("grantable");
expect((await request("grantable", { ticket })).status).toBe(401);
store.grant("agent:main:main", "grantable", "granted", 1);
const response = await request("grantable", { ticket });
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe("<script>pending()</script>");
});
it("rejects a ticket after the widget revision changes", async () => {
const stale = ticketFor("revisioned");
store.putWidget({
+2 -1
View File
@@ -5,6 +5,7 @@ import { BOARD_HTTP_PATH_PREFIX, verifyBoardViewTicket } from "./board-view-tick
import { sendMethodNotAllowed } from "./http-common.js";
const BOARD_WIDGET_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const BOARD_WIDGET_CSP = "sandbox allow-scripts";
type BoardHttpOptions = {
store?: BoardStore;
@@ -78,7 +79,7 @@ export function handleBoardHttpRequest(
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Content-Security-Policy", "sandbox allow-scripts");
res.setHeader("Content-Security-Policy", BOARD_WIDGET_CSP);
res.setHeader("Cache-Control", "no-cache");
res.end(document.html);
return true;
+88 -5
View File
@@ -26,10 +26,10 @@ vi.mock("./sessions.runtime.js", () => ({
})),
}));
function createHarness() {
function createHarness(readCanvasHtml?: Parameters<typeof createBoardHandlers>[2]) {
const store = new InMemoryBoardStore();
const broadcast = vi.fn();
const handlers = createBoardHandlers(store);
const handlers = createBoardHandlers(store, undefined, readCanvasHtml);
const invoke = async (method: string, params: Record<string, unknown>) => {
const respond = vi.fn<RespondFn>();
await handlers[method]!({
@@ -114,6 +114,11 @@ describe("board gateway methods", () => {
},
},
});
await invoke("board.widget.put", {
sessionKey: "agent:main:main",
name: "plain",
content: { kind: "html", html: "<p>plain</p>" },
});
const pendingResponse = await invoke("board.get", { sessionKey: "agent:main:main" });
const pending = pendingResponse.mock.calls[0]?.[1] as BoardSnapshot;
@@ -142,8 +147,12 @@ describe("board gateway methods", () => {
const firstResponse = await invoke("board.get", { sessionKey: "agent:main:main" });
const first = firstResponse.mock.calls[0]?.[1] as BoardSnapshot;
const htmlFrameUrl = first.widgets.find((widget) => widget.name === "status")?.frameUrl;
expect(htmlFrameUrl).toMatch(
const plainFrameUrl = first.widgets.find((widget) => widget.name === "plain")?.frameUrl;
const statusFrameUrl = first.widgets.find((widget) => widget.name === "status")?.frameUrl;
expect(plainFrameUrl).toMatch(
/^\/__openclaw__\/board\/agent%3Amain%3Amain\/plain\/index\.html\?bt=v1\./u,
);
expect(statusFrameUrl).toMatch(
/^\/__openclaw__\/board\/agent%3Amain%3Amain\/status\/index\.html\?bt=v1\./u,
);
expect(first.widgets.find((widget) => widget.name === "status")?.declaredSummary).toEqual([
@@ -158,7 +167,10 @@ describe("board gateway methods", () => {
const secondResponse = await invoke("board.get", { sessionKey: "agent:main:main" });
const second = secondResponse.mock.calls[0]?.[1] as BoardSnapshot;
expect(second.widgets.find((widget) => widget.name === "status")?.frameUrl).not.toBe(
htmlFrameUrl,
statusFrameUrl,
);
expect(second.widgets.find((widget) => widget.name === "plain")?.frameUrl).not.toBe(
plainFrameUrl,
);
});
@@ -222,6 +234,77 @@ describe("board gateway methods", () => {
});
});
it("materializes canvas document sources before storing and broadcasting", async () => {
const readCanvasDocument = vi.fn(async () => ({
html: "<!doctype html><p>same wrapped bytes</p>",
cspSandbox: "scripts" as const,
}));
const { invoke, store, broadcast } = createHarness(readCanvasDocument);
const response = await invoke("board.widget.put", {
sessionKey: "session",
name: "canvas-widget",
title: "Canvas widget",
content: { kind: "canvas-doc", docId: "cv_123" },
});
expect(readCanvasDocument).toHaveBeenCalledWith("cv_123");
expect(store.readWidgetHtml("session", "canvas-widget")).toMatchObject({
html: "<!doctype html><p>same wrapped bytes</p>",
revision: 1,
});
expect(response).toHaveBeenCalledWith(
true,
expect.objectContaining({ widgets: [expect.objectContaining({ name: "canvas-widget" })] }),
);
expect(broadcast).toHaveBeenCalledWith("board.changed", {
sessionKey: "session",
revision: 1,
widget: "canvas-widget",
});
});
it("rejects Canvas sources whose strict sandbox forbids scripts", async () => {
const readCanvasDocument = vi.fn(async () => ({ html: "<script>unsafe()</script>" }));
const { invoke, store, broadcast } = createHarness(readCanvasDocument);
const response = await invoke("board.widget.put", {
sessionKey: "session",
name: "strict-canvas-widget",
content: { kind: "canvas-doc", docId: "cv_strict" },
});
expect(response).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "INVALID_REQUEST" }),
);
expect(store.getSnapshot("session").widgets).toEqual([]);
expect(broadcast).not.toHaveBeenCalled();
});
it("rejects a resolved canvas document above the board HTML limit", async () => {
const readCanvasDocument = vi.fn(async () => ({
html: "x".repeat(262_145),
cspSandbox: "scripts" as const,
}));
const { invoke, store, broadcast } = createHarness(readCanvasDocument);
const response = await invoke("board.widget.put", {
sessionKey: "session",
name: "oversized-canvas-widget",
content: { kind: "canvas-doc", docId: "cv_oversized" },
});
expect(response).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ code: "INVALID_REQUEST" }),
);
expect(store.getSnapshot("session").widgets).toEqual([]);
expect(broadcast).not.toHaveBeenCalled();
});
it("supports rejected grants and rejects grants from non-pending state", async () => {
const { invoke } = createHarness();
await invoke("board.widget.put", {
+25 -2
View File
@@ -5,21 +5,25 @@ import {
type BoardEventParams,
type BoardUpdateParams,
type BoardWidgetGrantParams,
type BoardWidgetMaterializedPutParams,
type BoardWidgetPutParams,
validateBoardEventParams,
validateBoardGetParams,
validateBoardUpdateParams,
validateBoardWidgetContent,
validateBoardWidgetGrantParams,
validateBoardWidgetPutParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { BoardValidationError } from "../../boards/board-layout.js";
import { appendBoardEventNotice, BoardEventPayloadError } from "../../boards/board-notices.js";
import type { BoardStore } from "../../boards/board-store.js";
import { readCanvasDocumentHtmlSource } from "../../canvas/documents.js";
import { boardStore } from "../board-store.js";
import { buildBoardWidgetFrameUrl, createBoardViewTicket } from "../board-view-ticket.js";
import type { GatewayRequestHandlers } from "./types.js";
type NoticeAppender = typeof appendBoardEventNotice;
type CanvasDocumentReader = typeof readCanvasDocumentHtmlSource;
function invalidParams(
method: string,
@@ -50,6 +54,7 @@ function respondBoardError(
export function createBoardHandlers(
store: BoardStore,
appendNotice: NoticeAppender = appendBoardEventNotice,
readCanvasDocument: CanvasDocumentReader = readCanvasDocumentHtmlSource,
): GatewayRequestHandlers {
return {
"board.get": ({ params, respond }) => {
@@ -99,13 +104,31 @@ export function createBoardHandlers(
respondBoardError(error, respond);
}
},
"board.widget.put": ({ params, respond, context }) => {
"board.widget.put": async ({ params, respond, context }) => {
if (!validateBoardWidgetPutParams(params)) {
invalidParams("board.widget.put", validateBoardWidgetPutParams.errors, respond);
return;
}
try {
const boardParams = params as BoardWidgetPutParams;
const requestParams = params as BoardWidgetPutParams;
let content: BoardWidgetMaterializedPutParams["content"];
if (requestParams.content.kind === "canvas-doc") {
const document = await readCanvasDocument(requestParams.content.docId);
if (document.cspSandbox !== "scripts") {
throw new BoardValidationError(
"invalid_operation",
`canvas document is not script-enabled: ${requestParams.content.docId}`,
);
}
content = { kind: "html", html: document.html };
} else {
content = requestParams.content;
}
if (!validateBoardWidgetContent(content)) {
invalidParams("board.widget.put content", validateBoardWidgetContent.errors, respond);
return;
}
const boardParams: BoardWidgetMaterializedPutParams = { ...requestParams, content };
const snapshot = store.putWidget(boardParams);
context.broadcast("board.changed", {
sessionKey: snapshot.sessionKey,
@@ -164,6 +164,9 @@ export function registerDefaultAuthTokenSuite(): void {
}
| undefined;
expect(payload?.type).toBe("hello-ok");
expect(payload?.features?.capabilities).toContain(
GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC,
);
expect(payload?.features?.capabilities).toContain(
GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT,
);
@@ -88,6 +88,7 @@ export async function sendGatewayHello(
methods: gatewayMethods,
events,
capabilities: [
GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC,
GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT,
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
],
@@ -1,13 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { BOARD_GRID_GAP, BOARD_GRID_ROW_HEIGHT } from "../../lib/board/grid.ts";
import type { BoardSnapshot } from "../../lib/board/view-types.ts";
import type { BoardViewSnapshot } from "../../lib/board/view-types.ts";
import "./board-view.ts";
type OpenClawBoardView = HTMLElementTagNameMap["openclaw-board-view"];
const hasBrowserLayout = !navigator.userAgent.toLowerCase().includes("jsdom");
const source: BoardSnapshot = {
const source: BoardViewSnapshot = {
sessionKey: "agent:main:browser-board",
revision: 1,
tabs: [
+129 -6
View File
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow } from "../../api/types.ts";
import type { BoardSnapshot, BoardViewCallbacks, BoardWidget } from "../../lib/board/view-types.ts";
import type { BoardSnapshot, BoardWidget } from "../../lib/board/types.ts";
import type {
BoardViewCallbacks,
BoardViewSnapshot,
BoardViewWidget,
} from "../../lib/board/view-types.ts";
import { applyBoardFixtureOps } from "../../test-helpers/board-fixture.ts";
import "./board-view.ts";
@@ -22,7 +27,7 @@ function boardWidget(overrides: Partial<BoardWidget> = {}): BoardWidget {
};
}
function snapshot(overrides: Partial<BoardSnapshot> = {}): BoardSnapshot {
function snapshot(overrides: Partial<BoardViewSnapshot> = {}): BoardViewSnapshot {
return {
sessionKey: "agent:main:test",
revision: 1,
@@ -82,7 +87,7 @@ async function settleCells(view: OpenClawBoardView): Promise<OpenClawBoardWidget
async function mount(
options: {
snapshot?: BoardSnapshot;
snapshot?: BoardViewSnapshot;
activeTabId?: string;
callbacks?: BoardViewCallbacks;
widgetFrameUrl?: (name: string, revision: number) => string;
@@ -102,6 +107,7 @@ async function mount(
afterEach(() => {
document.body.replaceChildren();
vi.unstubAllGlobals();
});
describe("openclaw-board-view", () => {
@@ -122,14 +128,19 @@ describe("openclaw-board-view", () => {
});
it("renders the native swarm card without a frame or persisted widget controls", async () => {
const swarm = boardWidget({
const swarm: BoardViewWidget = {
name: "builtin:swarm",
tabId: "builtin-swarm",
title: "Swarm progress",
contentKind: "builtin",
builtin: "swarm",
readOnly: true,
});
sizeW: 12,
sizeH: 4,
position: 0,
grantState: "granted",
revision: 1,
};
const source = snapshot({
sessionKey: "agent:main:parent",
tabs: [{ tabId: "builtin-swarm", title: "Swarm progress", position: 0, chatDock: "right" }],
@@ -157,6 +168,101 @@ describe("openclaw-board-view", () => {
expect(view.querySelector(".board-widget__resize-handle")).toBeNull();
});
it("requests a fresh frame ticket after iframe errors or 401 loads", async () => {
const frameLoadFailed = vi.fn(async () => undefined);
const fetchMock = vi.fn(async () => new Response("expired", { status: 401 }));
vi.stubGlobal("fetch", fetchMock);
const view = await mount({
callbacks: callbacks({ frameLoadFailed }),
snapshot: snapshot({ widgets: [boardWidget()] }),
widgetFrameUrl: () => "/__openclaw__/board/session/status/index.html?bt=expired",
});
const frame = view.querySelector("iframe");
frame?.dispatchEvent(new Event("error"));
await vi.waitFor(() => expect(frameLoadFailed).toHaveBeenCalledTimes(1));
frame?.dispatchEvent(new Event("load"));
await vi.waitFor(() => expect(frameLoadFailed).toHaveBeenCalledTimes(2));
expect(fetchMock).toHaveBeenCalledWith(
"/__openclaw__/board/session/status/index.html?bt=expired",
{ cache: "no-store" },
);
});
it("bounds repeated frame ticket refreshes after persistent 401 responses", async () => {
const frameLoadFailed = vi.fn(async () => undefined);
let frameStatus = 401;
let frameUrl = "/__openclaw__/board/session/status/index.html?bt=expired";
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(frameStatus === 401 ? "expired" : "ok", { status: frameStatus }),
),
);
const view = await mount({
callbacks: callbacks({ frameLoadFailed }),
snapshot: snapshot({ widgets: [boardWidget()] }),
widgetFrameUrl: () => frameUrl,
});
const frame = view.querySelector("iframe");
for (let attempt = 0; attempt < 4; attempt += 1) {
frame?.dispatchEvent(new Event("load"));
await Promise.resolve();
}
await vi.waitFor(() => expect(frameLoadFailed).toHaveBeenCalledTimes(3));
await vi.waitFor(() =>
expect(view.querySelector('[data-test-id="board-widget-error"]')?.textContent).toContain(
"repeated refresh attempts",
),
);
frameStatus = 200;
frameUrl = "/__openclaw__/board/session/status/index.html?bt=fresh";
view.snapshot = snapshot({ revision: 2, widgets: [boardWidget()] });
await settleCells(view);
const freshFrame = view.querySelector("iframe");
expect(freshFrame?.getAttribute("src")).toBe(frameUrl);
freshFrame?.dispatchEvent(new Event("load"));
await vi.waitFor(() => expect(view.querySelector("iframe")).toBe(freshFrame));
expect(frameLoadFailed).toHaveBeenCalledTimes(3);
});
it("ignores authorization results from a superseded widget frame", async () => {
const frameLoadFailed = vi.fn(async () => undefined);
let resolveProbe: ((response: Response) => void) | undefined;
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveProbe = resolve;
}),
),
);
const view = await mount({
callbacks: callbacks({ frameLoadFailed }),
snapshot: snapshot({ widgets: [boardWidget()] }),
widgetFrameUrl: (_name, revision) =>
`/__openclaw__/board/session/status/index.html?revision=${revision}`,
});
const frame = view.querySelector("iframe");
frame?.dispatchEvent(new Event("load"));
view.snapshot = snapshot({
revision: 2,
widgets: [boardWidget({ revision: 2 })],
});
await settleCells(view);
expect(frame?.getAttribute("src")).toContain("revision=2");
resolveProbe?.(new Response("expired", { status: 401 }));
await Promise.resolve();
await Promise.resolve();
expect(frameLoadFailed).not.toHaveBeenCalled();
});
it("preserves each widget cell and iframe identity when order changes", async () => {
const view = await mount();
const before = [...view.querySelectorAll("openclaw-board-widget-cell")].find(
@@ -304,6 +410,23 @@ describe("openclaw-board-view", () => {
await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "rejected"));
});
it("renders declared approval details instead of the generic copy", async () => {
const source = snapshot({
widgets: [
boardWidget({
grantState: "pending",
declaredSummary: ["Network: api.example.com", "Tools: lookup"],
}),
],
});
const view = await mount({ snapshot: source });
const pending = view.querySelector('[data-test-id="board-pending"]');
expect(pending?.querySelectorAll(".board-widget__grant-summary li")).toHaveLength(2);
expect(pending?.textContent).toContain("Network: api.example.com");
expect(pending?.textContent).not.toContain("This widget requested additional access.");
});
it("serializes pending approval decisions while the callback is in flight", async () => {
let finishGrant: (() => void) | undefined;
const grant = vi.fn(
@@ -533,7 +656,7 @@ describe("openclaw-board-view", () => {
});
it("applies mock moves as insertion and shifts sibling positions", () => {
const moved = applyBoardFixtureOps(snapshot(), [
const moved = applyBoardFixtureOps(snapshot() as BoardSnapshot, [
{ kind: "widget_move", name: "beta", position: 0 },
]);
expect(
+14 -12
View File
@@ -15,13 +15,12 @@ import {
type BoardGridDirection,
type BoardGridItem,
} from "../../lib/board/grid.ts";
import type { BoardOp, BoardTab } from "../../lib/board/types.ts";
import type {
BoardGrantDecision,
BoardOp,
BoardSnapshot,
BoardTab,
BoardViewCallbacks,
BoardWidget,
BoardViewSnapshot,
BoardViewWidget,
BoardWidgetFrameUrl,
} from "../../lib/board/view-types.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
@@ -43,13 +42,13 @@ type BoardPointerGesture = {
items: BoardGridItem[];
};
function orderedTabs(snapshot: BoardSnapshot): BoardTab[] {
function orderedTabs(snapshot: BoardViewSnapshot): BoardTab[] {
return snapshot.tabs.toSorted(
(left, right) => left.position - right.position || left.tabId.localeCompare(right.tabId),
);
}
function orderedWidgets(snapshot: BoardSnapshot, tabId: string): BoardWidget[] {
function orderedWidgets(snapshot: BoardViewSnapshot, tabId: string): BoardViewWidget[] {
return snapshot.widgets
.filter((widget) => widget.tabId === tabId)
.toSorted(
@@ -57,7 +56,7 @@ function orderedWidgets(snapshot: BoardSnapshot, tabId: string): BoardWidget[] {
);
}
function itemsForWidgets(widgets: readonly BoardWidget[]): BoardGridItem[] {
function itemsForWidgets(widgets: readonly BoardViewWidget[]): BoardGridItem[] {
return widgets.map((widget) => ({
name: widget.name,
w: widget.sizeW,
@@ -67,7 +66,7 @@ function itemsForWidgets(widgets: readonly BoardWidget[]): BoardGridItem[] {
}
class OpenClawBoardView extends OpenClawLightDomElement {
@property({ attribute: false }) snapshot?: BoardSnapshot;
@property({ attribute: false }) snapshot?: BoardViewSnapshot;
@property({ attribute: false }) activeTabId = "";
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
@property({ attribute: false }) callbacks?: BoardViewCallbacks;
@@ -205,11 +204,14 @@ class OpenClawBoardView extends OpenClawLightDomElement {
focusChanged: (name) => {
this.focusName = name;
},
frameLoadFailed: async (name) => {
await this.callbacks?.frameLoadFailed?.(name);
},
};
private beginGesture(
mode: BoardPointerGesture["mode"],
widget: BoardWidget,
widget: BoardViewWidget,
event: PointerEvent,
): void {
if (event.button !== 0 || this.gesture || this.mutationPending) {
@@ -381,7 +383,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
this.hoverTabId = "";
}
private async nudgeWidget(widget: BoardWidget, direction: BoardGridDirection): Promise<void> {
private async nudgeWidget(widget: BoardViewWidget, direction: BoardGridDirection): Promise<void> {
const snapshot = this.snapshot;
if (!snapshot) {
return;
@@ -397,7 +399,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
);
}
private focusWidget(widget: BoardWidget, direction: BoardGridDirection): void {
private focusWidget(widget: BoardViewWidget, direction: BoardGridDirection): void {
const snapshot = this.snapshot;
if (!snapshot) {
return;
@@ -515,7 +517,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
}
private renderGrid(
widgets: readonly BoardWidget[],
widgets: readonly BoardViewWidget[],
tabs: readonly BoardTab[],
sessionKey: string,
): TemplateResult {
+123 -21
View File
@@ -4,10 +4,10 @@ import type { GatewaySessionRow } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import type { BoardGridDirection, BoardGridRect } from "../../lib/board/grid.ts";
import { toCssPlacement } from "../../lib/board/grid.ts";
import type { BoardTab } from "../../lib/board/types.ts";
import type {
BoardGrantDecision,
BoardTab,
BoardWidget,
BoardViewWidget,
BoardWidgetFrameUrl,
} from "../../lib/board/view-types.ts";
import { getBuiltinWidgetRenderer } from "../../lib/board/widgets/index.ts";
@@ -20,21 +20,23 @@ const BOARD_SIZE_PRESETS = {
lg: { w: 8, h: 6 },
xl: { w: 12, h: 8 },
} as const;
const MAX_FRAME_REFRESH_ATTEMPTS = 3;
export type BoardWidgetCellCallbacks = {
grant: (name: string, decision: BoardGrantDecision) => Promise<void>;
movePointerDown: (widget: BoardWidget, event: PointerEvent) => void;
resizePointerDown: (widget: BoardWidget, event: PointerEvent) => void;
moveToTab: (widget: BoardWidget, tabId: string) => Promise<void>;
resizeTo: (widget: BoardWidget, w: number, h: number) => Promise<void>;
remove: (widget: BoardWidget) => Promise<void>;
nudge: (widget: BoardWidget, direction: BoardGridDirection) => Promise<void>;
focus: (widget: BoardWidget, direction: BoardGridDirection) => void;
movePointerDown: (widget: BoardViewWidget, event: PointerEvent) => void;
resizePointerDown: (widget: BoardViewWidget, event: PointerEvent) => void;
moveToTab: (widget: BoardViewWidget, tabId: string) => Promise<void>;
resizeTo: (widget: BoardViewWidget, w: number, h: number) => Promise<void>;
remove: (widget: BoardViewWidget) => Promise<void>;
nudge: (widget: BoardViewWidget, direction: BoardGridDirection) => Promise<void>;
focus: (widget: BoardViewWidget, direction: BoardGridDirection) => void;
focusChanged: (name: string) => void;
frameLoadFailed: (name: string) => Promise<void>;
};
class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@property({ attribute: false }) widget?: BoardWidget;
@property({ attribute: false }) widget?: BoardViewWidget;
@property({ attribute: false }) rect?: BoardGridRect;
@property({ attribute: false }) tabs: readonly BoardTab[] = [];
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
@@ -49,14 +51,39 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@state() private actionError = "";
@state() private actionPending = false;
@state() private frameError = "";
private frameFailureKey = "";
private frameRefreshAttempts = 0;
private frameProbeGeneration = 0;
private lastFrameUrl = "";
override willUpdate(changed: PropertyValues<this>): void {
const previousWidget = changed.get("widget");
if (previousWidget && previousWidget !== this.widget) {
this.actionError = "";
if (
previousWidget.name !== this.widget?.name ||
previousWidget.revision !== this.widget?.revision
) {
this.resetFrameFailures();
} else if (this.widget && this.frameError) {
const nextFrameUrl = this.widgetFrameUrl?.(this.widget.name, this.widget.revision) ?? "";
if (nextFrameUrl && nextFrameUrl !== this.lastFrameUrl) {
// A newly minted ticket gets one authorization probe, but keeps the
// existing remint budget until that probe proves the frame healthy.
this.frameError = "";
}
}
}
}
private resetFrameFailures(): void {
this.frameProbeGeneration += 1;
this.frameFailureKey = "";
this.frameRefreshAttempts = 0;
this.frameError = "";
}
private closeMenu(): void {
const menu = this.querySelector<HTMLElement & { open: boolean }>(".board-widget__menu");
if (menu) {
@@ -82,7 +109,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
private handleMenuSelect(
event: CustomEvent<{ item: { value?: string } }>,
widget: BoardWidget,
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): void {
const value = event.detail.item.value;
@@ -103,7 +130,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
}
private renderMenu(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
private renderMenu(widget: BoardViewWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
const otherTabs = this.tabs.filter((tab) => tab.tabId !== widget.tabId);
return html`
<wa-dropdown
@@ -159,12 +186,19 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
`;
}
private renderPending(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
private renderPending(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): TemplateResult {
return html`
<div class="board-widget__grant board-widget__grant--pending" data-test-id="board-pending">
<div class="board-widget__grant-mark" aria-hidden="true">!</div>
<strong>${t("board.widget.needsApproval")}</strong>
<span>${t("board.widget.needsApprovalDetail")}</span>
${widget.declaredSummary?.length
? html`<ul class="board-widget__grant-summary">
${widget.declaredSummary.map((summary) => html`<li>${summary}</li>`)}
</ul>`
: html`<span>${t("board.widget.needsApprovalDetail")}</span>`}
<div class="board-widget__grant-actions">
<button
class="btn btn--small btn--primary"
@@ -190,7 +224,10 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
`;
}
private renderRejected(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
private renderRejected(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): TemplateResult {
return html`
<div class="board-widget__grant board-widget__grant--rejected" data-test-id="board-rejected">
<strong>${t("board.widget.rejected")}</strong>
@@ -207,11 +244,71 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
`;
}
private renderFrame(widget: BoardWidget): TemplateResult {
private refreshFailedFrame(widget: BoardViewWidget, callbacks: BoardWidgetCellCallbacks): void {
this.frameProbeGeneration += 1;
const failureKey = `${widget.name}:${widget.revision}`;
if (this.frameFailureKey !== failureKey) {
this.resetFrameFailures();
this.frameFailureKey = failureKey;
}
if (this.frameRefreshAttempts >= MAX_FRAME_REFRESH_ATTEMPTS) {
this.frameError = t("board.widget.frameAuthorizationFailed");
return;
}
this.frameRefreshAttempts += 1;
void callbacks.frameLoadFailed(widget.name).catch((error: unknown) => {
this.frameError = error instanceof Error ? error.message : String(error);
});
}
private verifyFrameAuthorization(
event: Event,
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): void {
const frame = event.currentTarget;
const src = frame instanceof HTMLIFrameElement ? (frame.getAttribute("src") ?? "") : "";
if (!src.startsWith("/__openclaw__/board/")) {
return;
}
const probeGeneration = this.frameProbeGeneration + 1;
this.frameProbeGeneration = probeGeneration;
const isCurrentProbe = () =>
frame instanceof HTMLIFrameElement &&
frame.isConnected &&
frame.getAttribute("src") === src &&
this.frameProbeGeneration === probeGeneration &&
this.widget?.name === widget.name &&
this.widget.revision === widget.revision;
// View tickets are reusable HMAC bindings until expiry. Iframe load events
// hide HTTP status, so a credentialed probe is the only 401 signal.
void fetch(src, { cache: "no-store" })
.then((response) => {
if (!isCurrentProbe()) {
return;
}
if (response.status === 401) {
this.refreshFailedFrame(widget, callbacks);
} else if (response.ok) {
this.resetFrameFailures();
}
})
.catch(() => {
if (isCurrentProbe()) {
this.refreshFailedFrame(widget, callbacks);
}
});
}
private renderFrame(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): TemplateResult {
if (!this.widgetFrameUrl) {
throw new Error(t("board.widget.frameResolverMissing"));
}
const src = this.widgetFrameUrl(widget.name, widget.revision);
this.lastFrameUrl = src;
return html`
<iframe
class="board-widget__frame"
@@ -220,11 +317,13 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
loading="lazy"
title=${widget.title || widget.name}
src=${src}
@error=${() => this.refreshFailedFrame(widget, callbacks)}
@load=${(event: Event) => this.verifyFrameAuthorization(event, widget, callbacks)}
></iframe>
`;
}
private renderBody(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
private renderBody(widget: BoardViewWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
if (widget.grantState === "pending") {
return this.renderPending(widget, callbacks);
}
@@ -238,7 +337,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
return renderer({ sessions: this.sessions, sessionKey: this.sessionKey });
}
return this.renderFrame(widget);
return this.renderFrame(widget, callbacks);
}
private renderError(error: unknown): TemplateResult {
@@ -274,7 +373,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
private handleKeyDown(
event: KeyboardEvent,
widget: BoardWidget,
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): void {
if (event.target !== event.currentTarget || widget.readOnly) {
@@ -309,9 +408,12 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
return nothing;
}
let body: TemplateResult;
let bodyErrored = false;
let bodyErrored: boolean;
try {
body = this.renderBody(widget, callbacks);
body = this.frameError
? this.renderError(this.frameError)
: this.renderBody(widget, callbacks);
bodyErrored = Boolean(this.frameError);
} catch (error) {
body = this.renderError(error);
bodyErrored = true;
+188
View File
@@ -0,0 +1,188 @@
// Control UI E2E covers the real session-dashboard provider and transcript bridge.
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { GATEWAY_SERVER_CAPS } from "../../../packages/gateway-protocol/src/index.js";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
let browser: Browser;
let server: ControlUiE2eServer;
const sessionKey = "agent:main:dashboard";
const boardSnapshot = {
sessionKey,
revision: 1,
tabs: [
{ tabId: "main", title: "Main", position: 0, chatDock: "right" },
{ tabId: "research", title: "Research", position: 1, chatDock: "right" },
],
widgets: [
{
name: "status",
tabId: "main",
title: "Status",
contentKind: "html",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "pending",
revision: 1,
frameUrl: "about:blank#status",
},
{
name: "sources",
tabId: "research",
title: "Sources",
contentKind: "html",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "pending",
revision: 1,
frameUrl: "about:blank#sources",
},
],
};
const pinnedBoardSnapshot = {
...boardSnapshot,
revision: 2,
widgets: [
...boardSnapshot.widgets,
{
name: "canvas-cv_release",
tabId: "main",
title: "Release status",
contentKind: "html",
sizeW: 6,
sizeH: 4,
position: 1,
grantState: "pending",
revision: 1,
frameUrl: "about:blank#canvas-cv_release",
},
],
};
describeControlUiE2e("Control UI session dashboard stitch", () => {
beforeAll(async () => {
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("pins Canvas HTML, follows board commands, and persists dock resizing", async () => {
const context = await browser.newContext({ viewport: { height: 900, width: 1280 } });
const page = await context.newPage();
const gateway = await installMockGateway(page, {
sessionKey,
featureCapabilities: [GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC],
featureMethods: [
"board.get",
"board.update",
"board.widget.grant",
"board.widget.put",
"chat.metadata",
"chat.startup",
],
historyMessages: [
{
role: "assistant",
content: [
{
type: "canvas",
preview: {
kind: "canvas",
surface: "assistant_message",
render: "url",
title: "Release status",
viewId: "cv_release",
url: "/__openclaw__/canvas/documents/cv_release/index.html",
preferredHeight: 240,
sandbox: "scripts",
},
},
],
timestamp: 1,
},
],
methodResponses: {
"board.get": boardSnapshot,
"board.widget.put": pinnedBoardSnapshot,
},
});
await page.addInitScript((key) => {
const settingsKey = "openclaw.control.settings.v1:ws://127.0.0.1:18789";
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
string,
unknown
>;
settings.boardSessionViews = {
[key]: { face: "dashboard", activeTabId: "main" },
};
localStorage.setItem(settingsKey, JSON.stringify(settings));
}, sessionKey);
await page.goto(`${server.baseUrl}chat`);
await expect
.poll(async () => (await gateway.getRequests("board.get")).length, { timeout: 30_000 })
.toBeGreaterThan(0);
await page.locator('wa-radio[value="dashboard"]').waitFor();
await page.locator(".board-session-surface").waitFor();
const preview = page.locator('.chat-tool-card__preview[data-kind="canvas"]');
await preview.hover();
await preview.getByRole("button", { name: "Pin to dashboard" }).click();
await expect.poll(async () => (await gateway.getRequests("board.widget.put")).length).toBe(1);
expect((await gateway.getRequests("board.widget.put"))[0]?.params).toEqual({
sessionKey,
name: "canvas-cv_release",
title: "Release status",
content: { kind: "canvas-doc", docId: "cv_release" },
});
await expect
.poll(() => preview.getByRole("button", { name: "Pinned" }).isDisabled())
.toBe(true);
await gateway.setMethodResponse("board.get", pinnedBoardSnapshot);
await gateway.emitGatewayEvent("board.command", {
sessionKey,
command: { kind: "focus_tab", tabId: "research" },
});
const researchTab = page.locator('[data-board-tab-id="research"]');
await expect.poll(() => researchTab.getAttribute("active")).not.toBeNull();
const divider = page.locator(".board-session-surface__divider");
const dock = page.locator(".board-session-surface__chat");
await divider.focus();
await page.keyboard.press("End");
await expect.poll(() => dock.getAttribute("style")).not.toBe("width: 420px");
const persistedStyle = await dock.getAttribute("style");
expect(persistedStyle).toMatch(/^width: \d+(?:\.\d+)?px$/u);
await page.reload();
await page.locator(".board-session-surface__chat").waitFor();
expect(await page.locator(".board-session-surface__chat").getAttribute("style")).toBe(
persistedStyle,
);
await expect
.poll(() =>
page.locator('.chat-tool-card__preview[data-kind="canvas"] [data-pin-widget]').isDisabled(),
)
.toBe(true);
await context.close();
});
});
+5
View File
@@ -2611,6 +2611,7 @@ export const en: TranslationMap = {
rejected: "Access rejected",
rejectedDetail: "This widget stays inactive until it is removed or replaced.",
frameResolverMissing: "Widget content is unavailable.",
frameAuthorizationFailed: "Widget authorization failed after repeated refresh attempts.",
errorTitle: "This widget could not load",
errorDetail: "The problem is contained to this card.",
actionErrorTitle: "Widget change failed",
@@ -3637,6 +3638,7 @@ export const en: TranslationMap = {
dockHidden: "Hide chat",
resizeDock: "Resize chat dock",
reopenChat: "Show chat",
defaultTab: "Main",
mockPlaceholder: "Board view seam · {tabs} tabs · {widgets} widgets",
mockOverview: "Overview",
mockResearch: "Research",
@@ -4039,6 +4041,9 @@ export const en: TranslationMap = {
downloadFile: "Download file",
widgetExportRerender: "This widget needs to be re-rendered to export as an image.",
widgetExportFailed: "Widget export failed. Try again.",
pinToDashboard: "Pin to dashboard",
pinToDashboardPending: "Pinning…",
pinnedToDashboard: "Pinned",
fileChanges: "File changes",
attemptedChanges: "Attempted changes",
failed: "failed",
+16
View File
@@ -0,0 +1,16 @@
import type { BoardFace } from "./settings.ts";
import type { BoardTab } from "./types.ts";
export function resolveBoardChatLayoutWidth(params: {
paneWidth: number;
hasBoard: boolean;
face: BoardFace;
dock: BoardTab["chatDock"];
dockWidth: number;
}): number {
return params.hasBoard &&
params.face === "dashboard" &&
(params.dock === "left" || params.dock === "right")
? Math.min(params.paneWidth, params.dockWidth)
: params.paneWidth;
}
+158
View File
@@ -0,0 +1,158 @@
import type { BoardOp, BoardSnapshot } from "@openclaw/gateway-protocol";
export function normalizeMockBoardSnapshot(snapshot: BoardSnapshot): BoardSnapshot {
const tabs = snapshot.tabs
.toSorted((left, right) => left.position - right.position)
.map((tab, position) => Object.assign({}, tab, { position }));
const tabPositions = new Map(tabs.map((tab) => [tab.tabId, tab.position]));
const nextWidgetPosition = new Map<string, number>();
const widgets = snapshot.widgets
.toSorted((left, right) => {
const tabDelta =
(tabPositions.get(left.tabId) ?? Number.MAX_SAFE_INTEGER) -
(tabPositions.get(right.tabId) ?? Number.MAX_SAFE_INTEGER);
return tabDelta || left.position - right.position;
})
.map((widget) => {
const position = nextWidgetPosition.get(widget.tabId) ?? 0;
nextWidgetPosition.set(widget.tabId, position + 1);
return Object.assign({}, widget, { position });
});
return { ...snapshot, tabs, widgets };
}
export function applyMockBoardOp(snapshot: BoardSnapshot, op: BoardOp): BoardSnapshot {
switch (op.kind) {
case "tab_create":
if (snapshot.tabs.some((tab) => tab.tabId === op.tabId)) {
return snapshot;
}
return {
...snapshot,
tabs: [
...snapshot.tabs,
{
tabId: op.tabId,
title: op.title,
position: snapshot.tabs.length,
chatDock: op.chatDock ?? "right",
},
],
};
case "tab_update": {
const orderedTabs = snapshot.tabs.toSorted((left, right) => left.position - right.position);
const tabIndex = orderedTabs.findIndex((tab) => tab.tabId === op.tabId);
if (tabIndex < 0) {
return snapshot;
}
const [tab] = orderedTabs.splice(tabIndex, 1);
const updated = {
...tab!,
...(op.title !== undefined ? { title: op.title } : {}),
...(op.chatDock !== undefined ? { chatDock: op.chatDock } : {}),
};
const position = Math.max(
0,
Math.min(
op.position === undefined ? tabIndex : Math.trunc(op.position),
orderedTabs.length,
),
);
orderedTabs.splice(position, 0, updated);
return {
...snapshot,
tabs: orderedTabs.map((candidate, nextPosition) =>
Object.assign({}, candidate, { position: nextPosition }),
),
};
}
case "tab_delete": {
const remainingTabs = snapshot.tabs.filter((tab) => tab.tabId !== op.tabId);
if (remainingTabs.length === 0 && snapshot.widgets.length > 0) {
return snapshot;
}
const firstTabId = remainingTabs[0]?.tabId;
return {
...snapshot,
tabs: remainingTabs,
widgets: snapshot.widgets.map((widget) =>
widget.tabId === op.tabId && firstTabId
? { ...widget, tabId: firstTabId, position: Number.MAX_SAFE_INTEGER }
: widget,
),
};
}
case "tabs_reorder": {
const requestedTabIds = new Set(op.tabIds);
if (
op.tabIds.length !== snapshot.tabs.length ||
requestedTabIds.size !== snapshot.tabs.length ||
snapshot.tabs.some((tab) => !requestedTabIds.has(tab.tabId))
) {
return snapshot;
}
return {
...snapshot,
tabs: op.tabIds.flatMap((tabId, position) => {
const tab = snapshot.tabs.find((candidate) => candidate.tabId === tabId);
return tab ? [{ ...tab, position }] : [];
}),
};
}
case "widget_move": {
const moving = snapshot.widgets.find((widget) => widget.name === op.name);
const anchor = op.after
? snapshot.widgets.find((widget) => widget.name === op.after)
: undefined;
if (!moving || (op.after && (!anchor || anchor.name === moving.name))) {
return snapshot;
}
const targetTabId = op.tabId ?? moving.tabId;
if (
(op.position !== undefined && op.after !== undefined) ||
!snapshot.tabs.some((tab) => tab.tabId === targetTabId) ||
(anchor && anchor.tabId !== targetTabId)
) {
return snapshot;
}
const remaining = snapshot.widgets.filter((widget) => widget.name !== moving.name);
const targetWidgets = remaining
.filter((widget) => widget.tabId === targetTabId)
.toSorted((left, right) => left.position - right.position);
const anchorIndex = anchor
? targetWidgets.findIndex((widget) => widget.name === anchor.name)
: -1;
const insertionIndex = anchor
? anchorIndex + 1
: Math.max(0, Math.min(op.position ?? targetWidgets.length, targetWidgets.length));
targetWidgets.splice(insertionIndex, 0, { ...moving, tabId: targetTabId });
return {
...snapshot,
widgets: snapshot.tabs.flatMap((tab) =>
(tab.tabId === targetTabId
? targetWidgets
: remaining
.filter((widget) => widget.tabId === tab.tabId)
.toSorted((left, right) => left.position - right.position)
).map((widget, position) => Object.assign({}, widget, { position })),
),
};
}
case "widget_resize":
return {
...snapshot,
widgets: snapshot.widgets.map((widget) =>
widget.name === op.name
? {
...widget,
sizeW: Math.min(12, Math.max(1, Math.trunc(op.sizeW))),
sizeH: Math.min(20, Math.max(1, Math.trunc(op.sizeH))),
}
: widget,
),
};
case "widget_remove":
return { ...snapshot, widgets: snapshot.widgets.filter((widget) => widget.name !== op.name) };
}
return snapshot;
}
+728
View File
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
boardExists,
boardProviderForSession,
canvasWidgetNameForDocument,
GatewayBoardProvider,
type BoardCommandEvent,
type BoardProvider,
} from "./provider.ts";
@@ -21,10 +23,25 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("board providers", () => {
it("keeps generated canvas widget names distinct after normalization and truncation", () => {
const longPrefix = "x".repeat(80);
const names = [
canvasWidgetNameForDocument("Foo"),
canvasWidgetNameForDocument("foo"),
canvasWidgetNameForDocument(`${longPrefix}-one`),
canvasWidgetNameForDocument(`${longPrefix}-two`),
];
expect(new Set(names).size).toBe(names.length);
expect(names.every((name) => name.length <= 64)).toBe(true);
expect(names.every((name) => /^[a-z0-9][a-z0-9._-]*$/u.test(name))).toBe(true);
});
it("keeps the null provider chat-only", () => {
mockLocation.search = "";
const provider = boardProviderForSession("agent:main:plain");
@@ -38,6 +55,35 @@ describe("board providers", () => {
});
});
it("keeps older gateways without board methods on the null provider", () => {
mockLocation.search = "";
const provider = boardProviderForSession("agent:main:legacy", {} as never, false);
expect(provider.canPinWidgets).toBe(false);
expect(boardExists(provider.snapshot$.value)).toBe(false);
});
it("updates pin capability independently from board availability", () => {
mockLocation.search = "";
const client = {
request: vi.fn(),
addEventListener: vi.fn(() => () => {}),
};
const provider = boardProviderForSession(
"agent:main:pin-capability",
client as never,
true,
false,
false,
);
expect(provider.canPinWidgets).toBe(false);
expect(
boardProviderForSession("agent:main:pin-capability", client as never, true, false, true),
).toBe(provider);
expect(provider.canPinWidgets).toBe(true);
});
it("provides two mock tabs with mixed widget sizes", () => {
const snapshot = mockBoardProvider("agent:main:main").snapshot$.value;
@@ -150,4 +196,686 @@ describe("board providers", () => {
expect(boardExists(boardProviderForSession("agent:work:primary").snapshot$.value)).toBe(true);
});
it("refetches changed boards while reloading only the named widget frame", async () => {
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
const snapshots = [
{
sessionKey: "agent:main:live",
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "alpha",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none" as const,
revision: 1,
frameUrl: "/alpha-old",
},
{
name: "beta",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 1,
grantState: "none" as const,
revision: 1,
frameUrl: "/beta-old",
},
],
},
{
sessionKey: "agent:main:live",
revision: 2,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "alpha",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none" as const,
revision: 2,
frameUrl: "/alpha-new",
},
{
name: "beta",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 1,
grantState: "none" as const,
revision: 1,
frameUrl: "/beta-reminted-but-preserved",
},
],
},
{
sessionKey: "agent:main:live",
revision: 2,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "alpha",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none" as const,
revision: 2,
frameUrl: "/alpha-reminted",
},
{
name: "beta",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 1,
grantState: "none" as const,
revision: 1,
frameUrl: "/beta-reminted-again",
},
],
},
];
const request = vi.fn(async (method: string) => {
expect(method).toBe("board.get");
return snapshots.shift();
});
const provider = new GatewayBoardProvider("agent:main:live", {
request: request as never,
addEventListener: (next) => {
listener = next as typeof listener;
return () => {};
},
});
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
listener?.({
event: "board.changed",
payload: { sessionKey: "agent:main:live", revision: 2, widget: "alpha" },
});
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(2));
expect(provider.widgetFrameUrl("alpha", 2)).toBe("/alpha-new");
expect(provider.widgetFrameUrl("beta", 1)).toBe("/beta-old");
await provider.refreshWidgetFrame("alpha");
expect(provider.widgetFrameUrl("alpha", 2)).toBe("/alpha-reminted");
expect(provider.widgetFrameUrl("beta", 1)).toBe("/beta-old");
});
it("does not publish an activation snapshot older than a completed mutation", async () => {
let resolveActivation: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
const stale = {
sessionKey: "agent:main:stale",
revision: 1,
tabs: [],
widgets: [],
};
const current = {
sessionKey: "agent:main:stale",
revision: 2,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [],
};
const request = vi.fn((method: string) => {
if (method === "board.get") {
return new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveActivation = resolve;
});
}
return Promise.resolve(current);
});
const provider = new GatewayBoardProvider("agent:main:stale", {
request: request as never,
addEventListener: () => () => {},
});
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("board.get", expect.anything()));
await provider.pinWidget({ docId: "cv-current" });
expect(provider.snapshot$.value.revision).toBe(2);
resolveActivation?.(stale);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
expect(provider.snapshot$.value).toEqual(current);
});
it("preserves a newer deletion reset while an older refresh is in flight", async () => {
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
let resolveIntermediate: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
const populated = {
sessionKey: "agent:main:deleted-board",
revision: 5,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [],
};
const intermediate = { ...populated, revision: 6 };
const deleted = {
sessionKey: "agent:main:deleted-board",
revision: 0,
tabs: [],
widgets: [],
};
const request = vi
.fn()
.mockResolvedValueOnce(populated)
.mockImplementationOnce(
() =>
new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveIntermediate = resolve;
}),
)
.mockResolvedValueOnce(deleted);
const provider = new GatewayBoardProvider("agent:main:deleted-board", {
request: request as never,
addEventListener: (next) => {
listener = next as typeof listener;
return () => {};
},
});
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(populated));
listener?.({
event: "board.changed",
payload: { sessionKey: "agent:main:deleted-board", revision: 6 },
});
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
listener?.({
event: "board.changed",
payload: { sessionKey: "agent:main:deleted-board", revision: 7 },
});
resolveIntermediate?.(intermediate);
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(deleted));
expect(request).toHaveBeenCalledTimes(3);
});
it("accepts a recreated board after a higher-revision deletion event", async () => {
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
let resolveStale: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
const populated = {
sessionKey: "agent:main:recreated-board",
revision: 5,
tabs: [{ tabId: "old", title: "Old", position: 0, chatDock: "right" as const }],
widgets: [],
};
const deleted = {
sessionKey: "agent:main:recreated-board",
revision: 0,
tabs: [],
widgets: [],
};
const recreated = {
sessionKey: "agent:main:recreated-board",
revision: 1,
tabs: [{ tabId: "new", title: "New", position: 0, chatDock: "left" as const }],
widgets: [],
};
const request = vi
.fn()
.mockResolvedValueOnce(populated)
.mockImplementationOnce(
() =>
new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveStale = resolve;
}),
)
.mockResolvedValueOnce(recreated);
const provider = new GatewayBoardProvider("agent:main:recreated-board", {
request: request as never,
addEventListener: (next) => {
listener = next as typeof listener;
return () => {};
},
});
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(populated));
listener?.({
event: "board.changed",
payload: { sessionKey: "agent:main:recreated-board", revision: 6 },
});
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
listener?.({
event: "board.changed",
payload: { sessionKey: "agent:main:recreated-board", revision: 1 },
});
resolveStale?.(deleted);
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(recreated));
expect(request).toHaveBeenCalledTimes(3);
});
it("ignores a stale empty response from an overlapping mutation", async () => {
let resolveOlder: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
let resolveNewer: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
const populated = {
sessionKey: "agent:main:mutation-race",
revision: 5,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [],
};
const newer = { ...populated, revision: 6 };
const deleted = {
sessionKey: "agent:main:mutation-race",
revision: 0,
tabs: [],
widgets: [],
};
const request = vi
.fn()
.mockResolvedValueOnce(populated)
.mockImplementationOnce(
() =>
new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveOlder = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveNewer = resolve;
}),
);
const provider = new GatewayBoardProvider("agent:main:mutation-race", {
request: request as never,
addEventListener: () => () => {},
});
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(populated));
const olderMutation = provider.applyOps([{ kind: "tab_delete", tabId: "main" }]);
const newerMutation = provider.applyOps([
{ kind: "tab_update", tabId: "main", chatDock: "left" },
]);
resolveNewer?.(newer);
await newerMutation;
resolveOlder?.(deleted);
await olderMutation;
expect(provider.snapshot$.value).toEqual(newer);
});
it("does not resurrect a board from a refresh started before deletion", async () => {
let resolveRefresh: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
let resolveDelete: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
const populated = {
sessionKey: "agent:main:refresh-reset-race",
revision: 5,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [],
};
const staleRefresh = { ...populated, revision: 6 };
const deleted = {
sessionKey: "agent:main:refresh-reset-race",
revision: 0,
tabs: [],
widgets: [],
};
let getCount = 0;
const request = vi.fn((method: string) => {
if (method === "board.get") {
getCount += 1;
if (getCount === 1) {
return Promise.resolve(populated);
}
if (getCount === 2) {
return new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveRefresh = resolve;
});
}
return Promise.resolve(deleted);
}
return new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveDelete = resolve;
});
});
const provider = new GatewayBoardProvider("agent:main:refresh-reset-race", {
request: request as never,
addEventListener: () => () => {},
});
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(populated));
const refresh = provider.activate();
await vi.waitFor(() => expect(getCount).toBe(2));
const deleteMutation = provider.applyOps([{ kind: "tab_delete", tabId: "main" }]);
resolveDelete?.(deleted);
await deleteMutation;
resolveRefresh?.(staleRefresh);
await refresh;
expect(provider.snapshot$.value).toEqual(deleted);
expect(getCount).toBe(3);
});
it("preserves a widget ticket refresh across a superseding mutation", async () => {
let resolveRefresh: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
const initial = {
sessionKey: "agent:main:ticket-race",
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "alpha",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none" as const,
revision: 1,
frameUrl: "/old-ticket",
},
],
};
const mutation = {
...initial,
revision: 2,
widgets: [{ ...initial.widgets[0]!, frameUrl: "/mutation-ticket" }],
};
const reminted = {
...mutation,
widgets: [{ ...initial.widgets[0]!, frameUrl: "/reminted-ticket" }],
};
let getCount = 0;
const request = vi.fn((method: string) => {
if (method !== "board.get") {
return Promise.resolve(mutation);
}
getCount += 1;
if (getCount === 1) {
return Promise.resolve(initial);
}
if (getCount === 2) {
return new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveRefresh = resolve;
});
}
return Promise.resolve(reminted);
});
const provider = new GatewayBoardProvider("agent:main:ticket-race", {
request: request as never,
addEventListener: () => () => {},
});
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(initial));
const refresh = provider.refreshWidgetFrame("alpha");
await vi.waitFor(() => expect(getCount).toBe(2));
await provider.applyOps([{ kind: "tab_update", tabId: "main", chatDock: "left" }]);
resolveRefresh?.({
...mutation,
widgets: [{ ...initial.widgets[0]!, frameUrl: "/superseded-ticket" }],
});
await refresh;
expect(getCount).toBe(3);
expect(provider.widgetFrameUrl("alpha", 1)).toBe("/reminted-ticket");
});
it("discards mutation responses from a replaced gateway client", async () => {
let resolveMutation: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
const oldClient = {
request: vi.fn(
() =>
new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveMutation = resolve;
}),
) as never,
addEventListener: () => () => {},
};
const current = {
sessionKey: "agent:main:replacement",
revision: 3,
tabs: [],
widgets: [],
};
const newClient = {
request: vi.fn(async () => current) as never,
addEventListener: () => () => {},
};
const provider = new GatewayBoardProvider("agent:main:replacement", oldClient, false);
const mutation = provider.applyOps([]);
provider.attachClient(newClient, true);
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(current));
resolveMutation?.({ ...current, revision: 4 });
await mutation;
expect(provider.snapshot$.value).toEqual(current);
});
it("clears an old gateway snapshot before accepting a lower revision", async () => {
const oldSnapshot = {
sessionKey: "agent:main:gateway-swap",
revision: 5,
tabs: [{ tabId: "main", title: "Old", position: 0, chatDock: "right" as const }],
widgets: [],
};
const newSnapshot = {
sessionKey: "agent:main:gateway-swap",
revision: 1,
tabs: [{ tabId: "main", title: "New", position: 0, chatDock: "left" as const }],
widgets: [],
};
const oldClient = {
request: vi.fn(async () => oldSnapshot) as never,
addEventListener: () => () => {},
};
let resolveNewSnapshot: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
const newClient = {
request: vi.fn(
() =>
new Promise<BoardProvider["snapshot$"]["value"]>((resolve) => {
resolveNewSnapshot = resolve;
}),
) as never,
addEventListener: () => () => {},
};
const provider = new GatewayBoardProvider("agent:main:gateway-swap", oldClient);
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(oldSnapshot));
provider.attachClient(newClient, true);
expect(provider.snapshot$.value).toEqual({
sessionKey: "agent:main:gateway-swap",
revision: 0,
tabs: [],
widgets: [],
});
resolveNewSnapshot?.(newSnapshot);
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(newSnapshot));
});
it("retries a transient activation failure", async () => {
vi.useFakeTimers();
const snapshot = {
sessionKey: "agent:main:retry",
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [],
};
const request = vi
.fn()
.mockRejectedValueOnce(new Error("temporarily unavailable"))
.mockResolvedValue(snapshot);
const provider = new GatewayBoardProvider("agent:main:retry", {
request: request as never,
addEventListener: () => () => {},
});
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
expect(request).toHaveBeenCalledTimes(2);
expect(provider.snapshot$.value).toEqual(snapshot);
});
it("reactivates the same gateway client after reconnect", async () => {
const snapshot = {
sessionKey: "agent:main:reconnect",
revision: 1,
tabs: [],
widgets: [],
};
const request = vi.fn(async () => snapshot);
const client = {
request: request as never,
addEventListener: () => () => {},
};
const provider = new GatewayBoardProvider("agent:main:reconnect", client, false);
expect(request).not.toHaveBeenCalled();
provider.attachClient(client, true);
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
expect(provider.snapshot$.value).toEqual(snapshot);
});
it("wakes a pending refresh backoff when the gateway reconnects", async () => {
vi.useFakeTimers();
const snapshot = {
sessionKey: "agent:main:reconnect-backoff",
revision: 1,
tabs: [],
widgets: [],
};
const request = vi
.fn()
.mockRejectedValueOnce(new Error("temporarily unavailable"))
.mockResolvedValue(snapshot);
const client = {
request: request as never,
addEventListener: () => () => {},
};
const provider = new GatewayBoardProvider("agent:main:reconnect-backoff", client);
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledOnce();
provider.attachClient(client, false);
provider.attachClient(client, true);
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(2);
expect(provider.snapshot$.value).toEqual(snapshot);
});
it("retries a transient board.changed refresh failure", async () => {
vi.useFakeTimers();
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
const initial = {
sessionKey: "agent:main:event-retry",
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [],
};
const changed = { ...initial, revision: 2 };
const request = vi
.fn()
.mockResolvedValueOnce(initial)
.mockRejectedValueOnce(new Error("temporarily unavailable"))
.mockResolvedValue(changed);
const provider = new GatewayBoardProvider("agent:main:event-retry", {
request: request as never,
addEventListener: (next) => {
listener = next as typeof listener;
return () => {};
},
});
await vi.advanceTimersByTimeAsync(0);
listener?.({
event: "board.changed",
payload: { sessionKey: "agent:main:event-retry", revision: 2 },
});
await vi.advanceTimersByTimeAsync(0);
expect(provider.snapshot$.value.revision).toBe(1);
await vi.advanceTimersByTimeAsync(1_000);
expect(request).toHaveBeenCalledTimes(3);
expect(provider.snapshot$.value.revision).toBe(2);
});
it("passes mutations through and surfaces board commands", async () => {
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
const empty = { sessionKey: "agent:main:live", revision: 0, tabs: [], widgets: [] };
const pinned = {
sessionKey: "agent:main:live",
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
widgets: [
{
name: "canvas-cv-1",
tabId: "main",
contentKind: "html" as const,
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none" as const,
revision: 1,
frameUrl: "/frame",
},
],
};
let getCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "board.get") {
getCount += 1;
return getCount === 1 ? empty : pinned;
}
return pinned;
});
const provider = new GatewayBoardProvider("agent:main:live", {
request: request as never,
addEventListener: (next) => {
listener = next as typeof listener;
return () => {};
},
});
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("board.get", expect.anything()));
const command = vi.fn();
provider.events.subscribe(command);
await provider.applyOps([{ kind: "tab_update", tabId: "main", chatDock: "left" }]);
await provider.grant("canvas-cv-1", "granted");
const longTitle = "Pinned ".repeat(20).trim();
await provider.pinWidget({ docId: "cv-1", title: longTitle });
listener?.({
event: "board.command",
payload: {
sessionKey: "agent:main:live",
command: { kind: "focus_tab", tabId: "main" },
},
});
expect(request).toHaveBeenCalledWith("board.update", {
sessionKey: "agent:main:live",
ops: [{ kind: "tab_update", tabId: "main", chatDock: "left" }],
});
expect(request).toHaveBeenCalledWith("board.widget.grant", {
sessionKey: "agent:main:live",
name: "canvas-cv-1",
decision: "granted",
revision: 1,
});
expect(request).toHaveBeenCalledWith("board.widget.put", {
sessionKey: "agent:main:live",
name: "canvas-cv-1",
title: Array.from(longTitle).slice(0, 80).join(""),
content: { kind: "canvas-doc", docId: "cv-1" },
});
expect(request.mock.calls.filter(([method]) => method === "board.get")).toHaveLength(1);
expect(provider.snapshot$.value).toEqual(pinned);
expect(command).toHaveBeenCalledWith({
sessionKey: "agent:main:live",
command: { kind: "focus_tab", tabId: "main" },
});
});
});
+398 -172
View File
@@ -1,17 +1,29 @@
import type {
BoardChangedEvent,
BoardCommand,
BoardCommandEvent,
BoardOp,
BoardSnapshot,
} from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { t } from "../../i18n/index.ts";
import {
buildAgentMainSessionKey,
normalizeSessionKeyForUiComparison,
} from "../sessions/session-key.ts";
import type { BoardOp, BoardSnapshot, BoardTab } from "./types.ts";
import { applyMockBoardOp, normalizeMockBoardSnapshot } from "./mock-ops.ts";
export type { BoardCommandEvent };
export type { BoardViewCallbacks } from "./view-types.ts";
type BoardCommand =
| { kind: "focus_tab"; tabId: string }
| { kind: "set_chat_dock"; dock: BoardTab["chatDock"] };
type BoardGatewayClient = Pick<GatewayBrowserClient, "request" | "addEventListener">;
export type BoardCommandEvent = {
sessionKey: string;
command: BoardCommand;
type BoardPinWidgetInput = {
docId: string;
title?: string;
name?: string;
tabId?: string;
size?: "sm" | "md" | "lg" | "xl" | "full";
after?: string;
};
type BoardSnapshotSignal = {
@@ -24,18 +36,33 @@ type BoardEventStream = {
};
export type BoardProvider = {
readonly canPinWidgets: boolean;
readonly snapshot$: BoardSnapshotSignal;
applyOps(ops: BoardOp[]): Promise<void>;
grant(name: string, decision: "granted" | "rejected"): Promise<void>;
pinWidget(input: BoardPinWidgetInput): Promise<void>;
widgetFrameUrl(name: string, revision: number): string;
refreshWidgetFrame(name: string): Promise<void>;
readonly events: BoardEventStream;
};
export type BoardViewCallbacks = {
applyOps(ops: BoardOp[]): Promise<void>;
grant(name: string, decision: "granted" | "rejected"): Promise<void>;
selectTab(tabId: string): void;
pinRequest?: never;
};
function hashDocumentId(value: string): string {
let hash = 0xcbf29ce484222325n;
for (const byte of new TextEncoder().encode(value)) {
hash ^= BigInt(byte);
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
}
return hash.toString(16).padStart(16, "0");
}
export function canvasWidgetNameForDocument(docId: string): string {
const name = `canvas-${docId.toLowerCase().replace(/[^a-z0-9._-]/gu, "-")}`;
if (name === `canvas-${docId}` && name.length <= 64) {
return name;
}
const prefix = name.slice(0, 47).replace(/[._-]+$/gu, "") || "canvas-widget";
return `${prefix}-${hashDocumentId(docId)}`;
}
class ValueSignal<T> {
private readonly listeners = new Set<() => void>();
@@ -74,6 +101,11 @@ function emptySnapshot(sessionKey: string): BoardSnapshot {
return { sessionKey, revision: 0, tabs: [], widgets: [] };
}
function boardWidgetTitle(title: string | undefined): string | undefined {
const normalized = title?.trim() ?? "";
return normalized ? Array.from(normalized).slice(0, 80).join("") : undefined;
}
function mockSnapshot(sessionKey: string): BoardSnapshot {
return {
sessionKey,
@@ -130,6 +162,7 @@ export function boardExists(snapshot: BoardSnapshot): boolean {
}
class NullProvider implements BoardProvider {
readonly canPinWidgets = false;
readonly snapshot$: BoardSnapshotSignal;
readonly events: BoardEventStream = new EventStream<BoardCommandEvent>();
@@ -140,9 +173,20 @@ class NullProvider implements BoardProvider {
async applyOps(_ops: BoardOp[]): Promise<void> {}
async grant(_name: string, _decision: "granted" | "rejected"): Promise<void> {}
async pinWidget(_input: BoardPinWidgetInput): Promise<void> {
throw new Error("Session dashboard unavailable");
}
widgetFrameUrl(_name: string, _revision: number): string {
return "";
}
async refreshWidgetFrame(_name: string): Promise<void> {}
}
class MockBoardProvider implements BoardProvider {
readonly canPinWidgets = true;
readonly snapshot$: BoardSnapshotSignal;
readonly events: BoardEventStream;
private readonly snapshotSignal: ValueSignal<BoardSnapshot>;
@@ -157,7 +201,7 @@ class MockBoardProvider implements BoardProvider {
async applyOps(ops: BoardOp[]): Promise<void> {
let snapshot = this.snapshotSignal.value;
for (const op of ops) {
snapshot = normalizeMockSnapshot(applyMockOp(snapshot, op));
snapshot = normalizeMockBoardSnapshot(applyMockBoardOp(snapshot, op));
}
this.snapshotSignal.set({ ...snapshot, revision: snapshot.revision + 1 });
}
@@ -177,170 +221,328 @@ class MockBoardProvider implements BoardProvider {
});
}
async pinWidget(input: BoardPinWidgetInput): Promise<void> {
const snapshot = this.snapshotSignal.value;
const name = input.name ?? canvasWidgetNameForDocument(input.docId);
const title = boardWidgetTitle(input.title);
const tabId = input.tabId ?? snapshot.tabs[0]?.tabId ?? "main";
const tabs = snapshot.tabs.length
? snapshot.tabs
: [
{
tabId: "main",
title: t("chat.board.defaultTab"),
position: 0,
chatDock: "right" as const,
},
];
const existing = snapshot.widgets.find((widget) => widget.name === name);
const widgets = snapshot.widgets.filter((widget) => widget.name !== name);
widgets.push({
name,
tabId,
...(title ? { title } : {}),
contentKind: "html",
sizeW: existing?.sizeW ?? 6,
sizeH: existing?.sizeH ?? 4,
position: existing?.position ?? widgets.filter((widget) => widget.tabId === tabId).length,
grantState: "none",
revision: (existing?.revision ?? 0) + 1,
frameUrl: `about:blank#board-widget=${encodeURIComponent(name)}`,
});
this.snapshotSignal.set(
normalizeMockBoardSnapshot({ ...snapshot, revision: snapshot.revision + 1, tabs, widgets }),
);
}
widgetFrameUrl(name: string, revision: number): string {
return (
this.snapshotSignal.value.widgets.find(
(widget) => widget.name === name && widget.revision === revision,
)?.frameUrl ?? `about:blank#board-widget=${encodeURIComponent(name)}&revision=${revision}`
);
}
async refreshWidgetFrame(_name: string): Promise<void> {}
emitCommand(command: BoardCommand): void {
this.eventStream.emit({ sessionKey: this.sessionKey, command });
}
}
function normalizeMockSnapshot(snapshot: BoardSnapshot): BoardSnapshot {
const tabs = snapshot.tabs
.toSorted((left, right) => left.position - right.position)
.map((tab, position) => Object.assign({}, tab, { position }));
const tabPositions = new Map(tabs.map((tab) => [tab.tabId, tab.position]));
const nextWidgetPosition = new Map<string, number>();
const widgets = snapshot.widgets
.toSorted((left, right) => {
const tabDelta =
(tabPositions.get(left.tabId) ?? Number.MAX_SAFE_INTEGER) -
(tabPositions.get(right.tabId) ?? Number.MAX_SAFE_INTEGER);
return tabDelta || left.position - right.position;
})
.map((widget) => {
const position = nextWidgetPosition.get(widget.tabId) ?? 0;
nextWidgetPosition.set(widget.tabId, position + 1);
return Object.assign({}, widget, { position });
});
return { ...snapshot, tabs, widgets };
}
export class GatewayBoardProvider implements BoardProvider {
canPinWidgets: boolean;
readonly snapshot$: BoardSnapshotSignal;
readonly events: BoardEventStream;
private readonly snapshotSignal: ValueSignal<BoardSnapshot>;
private readonly eventStream = new EventStream<BoardCommandEvent>();
private client: BoardGatewayClient;
private clientGeneration = 0;
private unsubscribe: (() => void) | undefined;
private refreshLoop: Promise<void> | undefined;
private refreshRequested = false;
private readonly changedWidgets = new Set<string>();
private stateGeneration = 0;
private connected = false;
private wakeRetryDelay: (() => void) | undefined;
function applyMockOp(snapshot: BoardSnapshot, op: BoardOp): BoardSnapshot {
switch (op.kind) {
case "tab_create":
if (snapshot.tabs.some((tab) => tab.tabId === op.tabId)) {
return snapshot;
}
return {
...snapshot,
tabs: [
...snapshot.tabs,
{
tabId: op.tabId,
title: op.title,
position: snapshot.tabs.length,
chatDock: op.chatDock ?? "right",
},
],
};
case "tab_update": {
const orderedTabs = snapshot.tabs.toSorted((left, right) => left.position - right.position);
const tabIndex = orderedTabs.findIndex((tab) => tab.tabId === op.tabId);
if (tabIndex < 0) {
return snapshot;
}
const [tab] = orderedTabs.splice(tabIndex, 1);
const updated = {
...tab!,
...(op.title !== undefined ? { title: op.title } : {}),
...(op.chatDock !== undefined ? { chatDock: op.chatDock } : {}),
};
const position = Math.max(
0,
Math.min(
op.position === undefined ? tabIndex : Math.trunc(op.position),
orderedTabs.length,
),
);
orderedTabs.splice(position, 0, updated);
return {
...snapshot,
tabs: orderedTabs.map((candidate, nextPosition) =>
Object.assign({}, candidate, { position: nextPosition }),
),
};
constructor(
readonly sessionKey: string,
client: BoardGatewayClient,
connected = true,
canPinWidgets = true,
) {
this.snapshotSignal = new ValueSignal(emptySnapshot(sessionKey));
this.snapshot$ = this.snapshotSignal;
this.events = this.eventStream;
this.client = client;
this.connected = connected;
this.canPinWidgets = canPinWidgets;
this.subscribe(client);
if (connected) {
void this.activate();
}
case "tab_delete": {
const remainingTabs = snapshot.tabs.filter((tab) => tab.tabId !== op.tabId);
if (remainingTabs.length === 0 && snapshot.widgets.length > 0) {
return snapshot;
}
const firstTabId = remainingTabs[0]?.tabId;
return {
...snapshot,
tabs: remainingTabs,
widgets: snapshot.widgets.map((widget) =>
widget.tabId === op.tabId && firstTabId
? { ...widget, tabId: firstTabId, position: Number.MAX_SAFE_INTEGER }
: widget,
),
};
}
case "tabs_reorder": {
const requestedTabIds = new Set(op.tabIds);
if (
op.tabIds.length !== snapshot.tabs.length ||
requestedTabIds.size !== snapshot.tabs.length ||
snapshot.tabs.some((tab) => !requestedTabIds.has(tab.tabId))
) {
return snapshot;
}
return {
...snapshot,
tabs: op.tabIds.flatMap((tabId, position) => {
const tab = snapshot.tabs.find((candidate) => candidate.tabId === tabId);
return tab ? [{ ...tab, position }] : [];
}),
};
}
case "widget_move": {
const moving = snapshot.widgets.find((widget) => widget.name === op.name);
const anchor = op.after
? snapshot.widgets.find((widget) => widget.name === op.after)
: undefined;
if (!moving || (op.after && (!anchor || anchor.name === moving.name))) {
return snapshot;
}
const targetTabId = op.tabId ?? moving.tabId;
if (
(op.position !== undefined && op.after !== undefined) ||
!snapshot.tabs.some((tab) => tab.tabId === targetTabId) ||
(anchor && anchor.tabId !== targetTabId)
) {
return snapshot;
}
const remaining = snapshot.widgets.filter((widget) => widget.name !== moving.name);
const targetWidgets = remaining
.filter((widget) => widget.tabId === targetTabId)
.toSorted((left, right) => left.position - right.position);
const anchorIndex = anchor
? targetWidgets.findIndex((widget) => widget.name === anchor.name)
: -1;
const insertionIndex = anchor
? anchorIndex + 1
: Math.max(0, Math.min(op.position ?? targetWidgets.length, targetWidgets.length));
targetWidgets.splice(insertionIndex, 0, { ...moving, tabId: targetTabId });
return {
...snapshot,
widgets: snapshot.tabs.flatMap((tab) =>
(tab.tabId === targetTabId
? targetWidgets
: remaining
.filter((widget) => widget.tabId === tab.tabId)
.toSorted((left, right) => left.position - right.position)
).map((widget, position) => Object.assign({}, widget, { position })),
),
};
}
case "widget_resize":
return {
...snapshot,
widgets: snapshot.widgets.map((widget) =>
widget.name === op.name
? {
...widget,
sizeW: Math.min(12, Math.max(1, Math.trunc(op.sizeW))),
sizeH: Math.min(20, Math.max(1, Math.trunc(op.sizeH))),
}
: widget,
),
};
case "widget_remove":
return { ...snapshot, widgets: snapshot.widgets.filter((widget) => widget.name !== op.name) };
}
return snapshot;
attachClient(client: BoardGatewayClient, connected = true, canPinWidgets = true): void {
const connectionActivated = connected && !this.connected;
this.connected = connected;
this.canPinWidgets = canPinWidgets;
if (client === this.client) {
if (connectionActivated) {
void this.activate();
}
return;
}
this.unsubscribe?.();
this.client = client;
this.clientGeneration += 1;
this.stateGeneration += 1;
this.changedWidgets.clear();
this.snapshotSignal.set(emptySnapshot(this.sessionKey));
this.subscribe(client);
if (connected) {
void this.activate();
}
}
activate(): Promise<void> {
return this.requestRefresh();
}
async applyOps(ops: BoardOp[]): Promise<void> {
await this.mutate("board.update", {
sessionKey: this.sessionKey,
ops,
});
}
async grant(name: string, decision: "granted" | "rejected"): Promise<void> {
const widget = this.snapshotSignal.value.widgets.find((candidate) => candidate.name === name);
if (!widget) {
void this.requestRefresh();
throw new Error(`Dashboard widget not found: ${name}`);
}
await this.mutate("board.widget.grant", {
sessionKey: this.sessionKey,
name,
decision,
revision: widget.revision,
});
}
async pinWidget(input: BoardPinWidgetInput): Promise<void> {
const name = input.name ?? canvasWidgetNameForDocument(input.docId);
const title = boardWidgetTitle(input.title);
await this.mutate(
"board.widget.put",
{
sessionKey: this.sessionKey,
name,
...(title ? { title } : {}),
content: { kind: "canvas-doc", docId: input.docId },
...(input.tabId || input.size || input.after
? {
placement: {
...(input.tabId ? { tabId: input.tabId } : {}),
...(input.size ? { size: input.size } : {}),
...(input.after ? { after: input.after } : {}),
},
}
: {}),
},
name,
);
}
widgetFrameUrl(name: string, revision: number): string {
return (
this.snapshotSignal.value.widgets.find(
(widget) => widget.name === name && widget.revision === revision,
)?.frameUrl ?? ""
);
}
refreshWidgetFrame(name: string): Promise<void> {
return this.requestRefresh(name);
}
private subscribe(client: BoardGatewayClient): void {
this.unsubscribe = client.addEventListener((event) => {
if (event.event === "board.changed") {
const payload = event.payload as Partial<BoardChangedEvent> | undefined;
if (payload && this.matchesSession(payload.sessionKey)) {
this.stateGeneration += 1;
void this.requestRefresh(payload.widget);
}
return;
}
if (event.event === "board.command") {
const payload = event.payload as Partial<BoardCommandEvent> | undefined;
if (payload?.command && this.matchesSession(payload.sessionKey)) {
this.eventStream.emit({ sessionKey: this.sessionKey, command: payload.command });
}
}
});
}
private matchesSession(sessionKey: string | undefined): boolean {
return (
typeof sessionKey === "string" &&
normalizeSessionKeyForUiComparison(sessionKey) ===
normalizeSessionKeyForUiComparison(this.sessionKey)
);
}
private requestRefresh(changedWidget?: string): Promise<void> {
this.refreshRequested = true;
if (changedWidget) {
this.changedWidgets.add(changedWidget);
}
this.wakeRetryDelay?.();
this.refreshLoop ??= this.runRefreshLoop().finally(() => {
this.refreshLoop = undefined;
if (this.refreshRequested) {
void this.requestRefresh();
}
});
return this.refreshLoop;
}
private async runRefreshLoop(): Promise<void> {
const retry = { delayMs: 1_000 };
while (this.refreshRequested) {
this.refreshRequested = false;
const changedWidgets = new Set(this.changedWidgets);
this.changedWidgets.clear();
const client = this.client;
const stateGeneration = this.stateGeneration;
try {
const snapshot = await client.request<BoardSnapshot>("board.get", {
sessionKey: this.sessionKey,
});
if (client !== this.client) {
this.refreshRequested = true;
continue;
}
if (stateGeneration !== this.stateGeneration) {
this.refreshRequested = true;
for (const name of changedWidgets) {
this.changedWidgets.add(name);
}
continue;
}
this.setSnapshot(snapshot, changedWidgets);
retry.delayMs = 1_000;
} catch {
this.refreshRequested = true;
if (client !== this.client) {
continue;
}
for (const name of changedWidgets) {
this.changedWidgets.add(name);
}
const delayMs = retry.delayMs;
// Carry backoff across failed loop iterations; successful refreshes reset it above.
retry.delayMs = Math.min(delayMs * 2, 30_000);
await this.waitForRetry(delayMs);
continue;
}
}
}
private waitForRetry(delayMs: number): Promise<void> {
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (!timer) {
return;
}
clearTimeout(timer);
timer = undefined;
if (this.wakeRetryDelay === finish) {
this.wakeRetryDelay = undefined;
}
resolve();
};
timer = setTimeout(finish, delayMs);
this.wakeRetryDelay = finish;
});
}
private async mutate(
method: "board.update" | "board.widget.grant" | "board.widget.put",
params: Record<string, unknown>,
changedWidget?: string,
): Promise<void> {
const client = this.client;
const clientGeneration = this.clientGeneration;
const stateGeneration = ++this.stateGeneration;
try {
const snapshot = await client.request<BoardSnapshot>(method, params);
if (
client === this.client &&
clientGeneration === this.clientGeneration &&
stateGeneration === this.stateGeneration
) {
this.stateGeneration += 1;
this.setSnapshot(snapshot, changedWidget ? new Set([changedWidget]) : new Set());
}
} catch (error) {
if (
client === this.client &&
clientGeneration === this.clientGeneration &&
stateGeneration === this.stateGeneration
) {
void this.requestRefresh();
}
throw error;
}
}
private setSnapshot(snapshot: BoardSnapshot, changedWidgets = new Set<string>()): void {
const previousWidgets = new Map(
this.snapshotSignal.value.widgets.map((widget) => [widget.name, widget]),
);
const widgets = snapshot.widgets.map((widget) => {
const previous = previousWidgets.get(widget.name);
if (
previous &&
!changedWidgets.has(widget.name) &&
previous.revision === widget.revision &&
previous.frameUrl
) {
return { ...widget, frameUrl: previous.frameUrl };
}
return widget;
});
this.snapshotSignal.set({ ...snapshot, widgets });
}
}
const nullProviders = new Map<string, NullProvider>();
const mockProviders = new Map<string, MockBoardProvider>();
const gatewayProviders = new Map<string, GatewayBoardProvider>();
let mockProviderScope: object | null = null;
function resolveMockBoardScope(): object | null {
@@ -348,11 +550,7 @@ function resolveMockBoardScope(): object | null {
if (new URLSearchParams(location?.search ?? "").get("mockBoard") === "1") {
return location;
}
return (
(typeof document !== "undefined" &&
document.querySelector("script[data-openclaw-control-ui-mock-gateway]")) ||
null
);
return null;
}
export function isMockBoardEnabled(): boolean {
@@ -368,7 +566,13 @@ function boardProviderCacheKey(sessionKey: string): string {
return normalized === "main" ? buildAgentMainSessionKey({ agentId: "main" }) : normalized;
}
export function boardProviderForSession(sessionKey: string): BoardProvider {
export function boardProviderForSession(
sessionKey: string,
client?: BoardGatewayClient | null,
available = true,
connected = true,
canPinWidgets = available,
): BoardProvider {
const key = boardProviderCacheKey(sessionKey);
const mockScope = resolveMockBoardScope();
if (mockScope && isMockBoardSession(key)) {
@@ -383,6 +587,28 @@ export function boardProviderForSession(sessionKey: string): BoardProvider {
}
return provider;
}
if (!available) {
let provider = nullProviders.get(key);
if (!provider) {
provider = new NullProvider(key);
nullProviders.set(key, provider);
}
return provider;
}
if (client) {
let provider = gatewayProviders.get(key);
if (!provider) {
provider = new GatewayBoardProvider(key, client, connected, canPinWidgets);
gatewayProviders.set(key, provider);
} else {
provider.attachClient(client, connected, canPinWidgets);
}
return provider;
}
const gatewayProvider = gatewayProviders.get(key);
if (gatewayProvider) {
return gatewayProvider;
}
let provider = nullProviders.get(key);
if (!provider) {
provider = new NullProvider(key);
+3 -2
View File
@@ -2,6 +2,7 @@ import type { GatewaySessionRow } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import { areUiSessionKeysEquivalent } from "../sessions/session-key.ts";
import type { BoardSnapshot } from "./types.ts";
import type { BoardViewSnapshot } from "./view-types.ts";
const SWARM_TAB_ID = "builtin-swarm";
const SWARM_WIDGET_NAME = "builtin:swarm";
@@ -26,7 +27,7 @@ function hasSwarmRowsForSession(
export function withSwarmWidget(
snapshot: BoardSnapshot,
sessions: readonly GatewaySessionRow[],
): BoardSnapshot {
): BoardViewSnapshot {
// Keep the card mounted through terminal collector updates so its explicit
// empty state is visible before the retention sweep removes the group.
if (!hasSwarmRowsForSession(sessions, snapshot.sessionKey)) {
@@ -55,7 +56,7 @@ export function withSwarmWidget(
position: 0,
grantState: "granted" as const,
revision: snapshot.revision,
};
} satisfies BoardViewSnapshot["widgets"][number];
const widgets = snapshot.widgets.some((candidate) => candidate.name === SWARM_WIDGET_NAME)
? snapshot.widgets.map((candidate) =>
candidate.name === SWARM_WIDGET_NAME ? widget : candidate,
+1 -44
View File
@@ -1,44 +1 @@
export type BoardTab = {
tabId: string;
title: string;
position: number;
chatDock: "left" | "right" | "bottom" | "hidden";
};
export type BoardWidget = {
name: string;
tabId: string;
title?: string;
contentKind: "html" | "mcp-app" | "builtin";
/** Named native Control UI card; unlike framed widgets it never persists board mutations. */
builtin?: "swarm";
/** Virtual cards are derived from live session state and cannot be moved or removed. */
readOnly?: boolean;
sizeW: number;
sizeH: number;
position: number;
grantState: "none" | "pending" | "granted" | "rejected";
revision: number;
};
export type BoardSnapshot = {
sessionKey: string;
revision: number;
tabs: BoardTab[];
widgets: BoardWidget[];
};
export type BoardOp =
| { kind: "tab_create"; tabId: string; title: string; chatDock?: BoardTab["chatDock"] }
| {
kind: "tab_update";
tabId: string;
title?: string;
chatDock?: BoardTab["chatDock"];
position?: number;
}
| { kind: "tab_delete"; tabId: string }
| { kind: "tabs_reorder"; tabIds: string[] }
| { kind: "widget_move"; name: string; tabId?: string; position?: number; after?: string }
| { kind: "widget_resize"; name: string; sizeW: number; sizeH: number }
| { kind: "widget_remove"; name: string };
export type { BoardOp, BoardSnapshot, BoardTab, BoardWidget } from "@openclaw/gateway-protocol";
+13 -45
View File
@@ -1,59 +1,27 @@
/** Temporary board view contract. A stitch commit will point these at gateway-protocol. */
import type { BoardOp, BoardSnapshot, BoardWidget } from "@openclaw/gateway-protocol";
type BoardChatDock = "left" | "right" | "bottom" | "hidden";
type BoardGrantState = "none" | "pending" | "granted" | "rejected";
export type BoardGrantDecision = "granted" | "rejected";
export type BoardTab = {
tabId: string;
title: string;
position: number;
chatDock: BoardChatDock;
/** Native Control UI card, derived from session state rather than the board store. */
type BoardStoredWidget = BoardWidget & {
builtin?: never;
readOnly?: false | undefined;
};
export type BoardWidget = {
name: string;
tabId: string;
title?: string;
contentKind: "html" | "mcp-app" | "builtin";
/** Named native Control UI card; unlike framed widgets it never persists board mutations. */
builtin?: "swarm";
/** Virtual cards are derived from live session state and cannot be moved or removed. */
readOnly?: boolean;
sizeW: number;
sizeH: number;
position: number;
grantState: BoardGrantState;
revision: number;
type BoardBuiltinWidget = Omit<BoardWidget, "contentKind"> & {
builtin: "swarm";
contentKind: "builtin";
readOnly: true;
};
export type BoardSnapshot = {
sessionKey: string;
revision: number;
tabs: BoardTab[];
widgets: BoardWidget[];
export type BoardViewWidget = BoardStoredWidget | BoardBuiltinWidget;
export type BoardViewSnapshot = Omit<BoardSnapshot, "widgets"> & {
widgets: BoardViewWidget[];
};
export type BoardOp =
| { kind: "tab_create"; tabId: string; title: string; chatDock?: BoardChatDock }
| {
kind: "tab_update";
tabId: string;
title?: string;
chatDock?: BoardChatDock;
position?: number;
}
| { kind: "tab_delete"; tabId: string }
| { kind: "tabs_reorder"; tabIds: string[] }
| { kind: "widget_move"; name: string; tabId?: string; position?: number; after?: string }
| { kind: "widget_resize"; name: string; sizeW: number; sizeH: number }
| { kind: "widget_remove"; name: string };
export type BoardViewCallbacks = {
applyOps: (ops: BoardOp[]) => Promise<void>;
grant: (name: string, decision: BoardGrantDecision) => Promise<void>;
selectTab: (tabId: string) => void;
pinRequest?: never;
frameLoadFailed?: (name: string) => Promise<void>;
};
export type BoardWidgetFrameUrl = (name: string, revision: number) => string;
+1
View File
@@ -170,6 +170,7 @@ export type ToolCard = {
className?: string;
style?: string;
sandbox?: "strict" | "scripts";
boardWidgetName?: string;
mcpApp?: {
viewId: string;
serverName?: string;
+15
View File
@@ -12,3 +12,18 @@ export function isGatewayMethodAdvertised(
}
return methods.includes(method);
}
export function isGatewayCapabilityAdvertised(
host: {
hello?: {
features?: { capabilities?: string[] } | null;
} | null;
},
capability: string,
): boolean | null {
const capabilities = host.hello?.features?.capabilities;
if (!Array.isArray(capabilities)) {
return null;
}
return capabilities.includes(capability);
}
@@ -22,10 +22,7 @@ afterEach(() => {
});
beforeEach(() => {
const marker = document.createElement("script");
marker.dataset.openclawControlUiMockGateway = "";
document.head.append(marker);
containers.push(marker);
window.history.replaceState({}, "", "/?mockBoard=1");
});
describe("board session shell", () => {
@@ -78,6 +75,7 @@ describe("board session shell", () => {
grant: (name, decision) => provider.grant(name, decision),
selectTab: () => {},
},
widgetFrameUrl: (name, revision) => provider.widgetFrameUrl(name, revision),
onDockChange: () => {},
}),
container,
@@ -108,6 +106,7 @@ describe("board session shell", () => {
grant: (name, decision) => provider.grant(name, decision),
selectTab: () => {},
},
widgetFrameUrl: (name, revision) => provider.widgetFrameUrl(name, revision),
onDockChange,
}),
container,
@@ -136,6 +135,7 @@ describe("board session shell", () => {
grant: (...args: Parameters<typeof provider.grant>) => provider.grant(...args),
selectTab: () => {},
},
widgetFrameUrl: (name: string, revision: number) => provider.widgetFrameUrl(name, revision),
onDockChange: () => {},
};
+5 -5
View File
@@ -5,7 +5,8 @@ import { renderSettingsSegmented } from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { isMockBoardEnabled, type BoardViewCallbacks } from "../../lib/board/provider.ts";
import type { BoardFace, BoardVisibleChatDock } from "../../lib/board/settings.ts";
import type { BoardSnapshot, BoardTab } from "../../lib/board/types.ts";
import type { BoardTab } from "../../lib/board/types.ts";
import type { BoardViewSnapshot, BoardWidgetFrameUrl } from "../../lib/board/view-types.ts";
export type BoardChatDockSize = {
height: number;
@@ -13,7 +14,7 @@ export type BoardChatDockSize = {
};
type BoardSessionSurfaceProps = {
snapshot: BoardSnapshot;
snapshot: BoardViewSnapshot;
sessions: readonly GatewaySessionRow[];
activeTabId: string;
dock: BoardTab["chatDock"];
@@ -22,6 +23,7 @@ type BoardSessionSurfaceProps = {
chat: TemplateResult;
divider: TemplateResult;
callbacks: BoardViewCallbacks;
widgetFrameUrl: BoardWidgetFrameUrl;
onDockChange: (dock: BoardTab["chatDock"]) => void;
};
@@ -129,14 +131,12 @@ export function renderBoardDockMenu(
}
function renderBoardView(props: BoardSessionSurfaceProps) {
const widgetFrameUrl = (name: string, revision: number) =>
`about:blank#board-widget=${encodeURIComponent(name)}&revision=${revision}`;
return html`
<div class="board-session-surface__board">
<openclaw-board-view
.snapshot=${props.snapshot}
.activeTabId=${props.activeTabId}
.widgetFrameUrl=${widgetFrameUrl}
.widgetFrameUrl=${props.widgetFrameUrl}
.callbacks=${props.callbacks}
.sessions=${props.sessions}
></openclaw-board-view>
+15 -5
View File
@@ -193,17 +193,27 @@ export function shouldQueueLocalSlashCommand(name: string): boolean {
return !["stop", "export-session", "steer", "redirect", "new"].includes(name);
}
async function confirmConversationResetForCurrentSession(
export async function confirmConversationResetForCurrentSession(
host: ChatCommandHost,
target?: { sessionKey: string; agentId?: string },
): Promise<"confirmed" | "cancelled" | "deferred"> {
const resetSessionKey = target?.sessionKey ?? host.sessionKey;
const targetIsCurrent = target
? () => visibleSessionMatches(host, target.sessionKey, target.agentId)
: () =>
host.sessionKey === resetSessionKey ||
areUiSessionKeysEquivalent(host.sessionKey, resetSessionKey);
if (!targetIsCurrent()) {
return "deferred";
}
if (!host.confirmConversationReset) {
return "confirmed";
}
const resetSessionKey = host.sessionKey;
if (!(await host.confirmConversationReset())) {
return "cancelled";
const confirmed = await host.confirmConversationReset();
if (!targetIsCurrent()) {
return target ? "deferred" : "cancelled";
}
if (!areUiSessionKeysEquivalent(host.sessionKey, resetSessionKey)) {
if (!confirmed) {
return "cancelled";
}
return host.chatRunId ? "deferred" : "confirmed";
+48 -9
View File
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { loadSettings, patchSettings } from "../../app/settings.ts";
import { resolveBoardChatLayoutWidth } from "../../lib/board/chat-layout.ts";
import {
boardProviderForSession,
type BoardCommandEvent,
@@ -15,6 +16,7 @@ import "./chat-pane.ts";
import type { ChatPageHost } from "./chat-state.ts";
type TestChatPane = HTMLElement & {
boardChatDockSize: { height: number; width: number };
boardProvider?: BoardProvider;
connectedClient: GatewayBrowserClient | null;
connectionGeneration: number;
@@ -27,6 +29,10 @@ type TestChatPane = HTMLElement & {
updated: () => void;
handleBoardCommand: (event: BoardCommandEvent) => void;
handleBoardDockChange: (dock: "bottom" | "hidden" | "left" | "right") => void;
handleBoardDockResize: (
dock: "bottom" | "left" | "right",
event: CustomEvent<{ splitRatio: number }>,
) => void;
persistBoardSessionView: (patch: { face?: "chat" | "dashboard"; activeTabId?: string }) => void;
resolveBoardProvider: () => BoardProvider;
resolveBoardView: () => { activeTabId: string; dock: string; face: string };
@@ -39,7 +45,7 @@ function mockBoardProvider(sessionKey: string): MockProvider {
}
function nullBoardProvider(sessionKey: string): BoardProvider {
document.querySelector("script[data-openclaw-control-ui-mock-gateway]")?.remove();
window.history.replaceState({}, "", "/");
return boardProviderForSession(sessionKey);
}
@@ -76,13 +82,11 @@ function createTestPane(sessions: SessionCapability = {} as SessionCapability) {
beforeEach(() => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
const marker = document.createElement("script");
marker.dataset.openclawControlUiMockGateway = "";
document.head.append(marker);
window.history.replaceState({}, "", "/?mockBoard=1");
});
afterEach(() => {
document.querySelector("script[data-openclaw-control-ui-mock-gateway]")?.remove();
window.history.replaceState({}, "", "/");
localStorage.clear();
sessionStorage.clear();
vi.unstubAllGlobals();
@@ -317,9 +321,6 @@ describe("chat pane board shell", () => {
});
it("resolves configured main aliases before selecting a provider", () => {
const marker = document.createElement("script");
marker.dataset.openclawControlUiMockGateway = "";
document.head.append(marker);
const pane = createTestPane();
pane.state.sessionKey = "primary";
pane.context = {
@@ -342,6 +343,44 @@ describe("chat pane board shell", () => {
};
expect(pane.resolveBoardProvider().snapshot$.value.sessionKey).toBe("agent:work:primary");
marker.remove();
});
it("uses the side dock width for rail and detail breakpoints", () => {
expect(
resolveBoardChatLayoutWidth({
paneWidth: 1400,
hasBoard: true,
face: "dashboard",
dock: "right",
dockWidth: 420,
}),
).toBe(420);
expect(
resolveBoardChatLayoutWidth({
paneWidth: 1400,
hasBoard: true,
face: "dashboard",
dock: "bottom",
dockWidth: 420,
}),
).toBe(1400);
});
it("persists dashboard chat dock resizing across pane recreation", () => {
const pane = createTestPane();
const previous = document.createElement("div");
const divider = document.createElement("div");
const next = document.createElement("div");
previous.getBoundingClientRect = () => ({ width: 650 }) as DOMRect;
next.getBoundingClientRect = () => ({ width: 350 }) as DOMRect;
const container = document.createElement("div");
container.append(previous, divider, next);
divider.addEventListener("resize", (event) => {
pane.handleBoardDockResize("right", event as unknown as CustomEvent<{ splitRatio: number }>);
});
divider.dispatchEvent(new CustomEvent("resize", { detail: { splitRatio: 0.65 } }));
const recreated = createTestPane();
expect(recreated.boardChatDockSize.width).toBe(350);
});
});
+53 -26
View File
@@ -2,22 +2,23 @@ import { consume } from "@lit/context";
import { asNullableRecord as catalogRawRecord } from "@openclaw/normalization-core/record-coerce";
import { html, nothing } from "lit";
import { property, state as litState } from "lit/decorators.js";
import type {
SessionCatalogHost,
SessionCatalogSession,
SessionCatalogTranscriptItem,
SessionDiscussionInfo,
SessionDiscussionState,
SessionsCatalogContinueResult,
SessionsCatalogReadResult,
SessionsFilesRevealResult,
SystemInfoResult,
TaskSuggestion,
TaskSuggestionEvent,
TaskSuggestionsAcceptResult,
TaskSuggestionsListResult,
WorktreesBranchesResult,
WorktreesListResult,
import {
GATEWAY_SERVER_CAPS,
type SessionCatalogHost,
type SessionCatalogSession,
type SessionCatalogTranscriptItem,
type SessionDiscussionInfo,
type SessionDiscussionState,
type SessionsCatalogContinueResult,
type SessionsCatalogReadResult,
type SessionsFilesRevealResult,
type SystemInfoResult,
type TaskSuggestion,
type TaskSuggestionEvent,
type TaskSuggestionsAcceptResult,
type TaskSuggestionsListResult,
type WorktreesBranchesResult,
type WorktreesListResult,
} from "../../../../packages/gateway-protocol/src/index.js";
import type {
ControlUiSessionBranch,
@@ -58,8 +59,8 @@ import { createDockPanelLayout } from "../../components/dock-panel-layout.ts";
import { icons } from "../../components/icons.ts";
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
import { t } from "../../i18n/index.ts";
import { resolveBoardChatLayoutWidth } from "../../lib/board/chat-layout.ts";
import {
boardExists,
boardProviderForSession,
type BoardCommandEvent,
type BoardProvider,
@@ -71,6 +72,7 @@ import {
type BoardSessionView,
} from "../../lib/board/settings.ts";
import type { BoardSnapshot, BoardTab } from "../../lib/board/types.ts";
import type { BoardViewSnapshot } from "../../lib/board/view-types.ts";
import {
resolveControlUiFollowUpMode,
resolveControlUiServerQueueMode,
@@ -78,7 +80,10 @@ import {
import { retirePendingChatSideQuestion } from "../../lib/chat/side-result.ts";
import { copyToClipboard } from "../../lib/clipboard.ts";
import { clampText } from "../../lib/format.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import {
isGatewayCapabilityAdvertised,
isGatewayMethodAdvertised,
} from "../../lib/gateway-methods.ts";
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
import {
announceCatalogSessionContinued,
@@ -225,7 +230,7 @@ type PaneSessionChangeOptions = { replace?: boolean };
type VisibleBoardDock = Exclude<BoardTab["chatDock"], "hidden">;
type ResolvedBoardView = {
provider: BoardProvider;
snapshot: BoardSnapshot;
snapshot: BoardViewSnapshot;
hasBoard: boolean;
face: BoardFace;
activeTabId: string;
@@ -407,7 +412,7 @@ class ChatPane extends OpenClawLightDomElement {
}
| undefined;
private readonly lastVisibleBoardDock = new Map<string, VisibleBoardDock>();
private swarmBoardSnapshot: BoardSnapshot | null = null;
private swarmBoardSnapshot: BoardViewSnapshot | null = null;
private swarmBoardSnapshotBase: BoardSnapshot | null = null;
private swarmBoardSnapshotRequest = 0;
private readonly sessionDiscussionStates = new Map<string, SessionDiscussionState>();
@@ -1500,7 +1505,19 @@ class ChatPane extends OpenClawLightDomElement {
this.state?.sessionKey ?? this.sessionKey,
this.context?.gateway.snapshot.hello,
);
return this.boardProvider ?? boardProviderForSession(sessionKey);
if (this.boardProvider) {
return this.boardProvider;
}
const gateway = this.context?.gateway.snapshot;
return boardProviderForSession(
sessionKey,
gateway?.client,
!gateway || isGatewayMethodAdvertised(gateway, "board.get") !== false,
gateway?.connected ?? false,
!gateway ||
isGatewayCapabilityAdvertised(gateway, GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC) ===
true,
);
}
private resolveBoardSessionKey(snapshotSessionKey = ""): string {
@@ -1533,11 +1550,11 @@ class ChatPane extends OpenClawLightDomElement {
private resolveBoardView(): ResolvedBoardView {
const provider = this.resolveBoardProvider();
const baseSnapshot = provider.snapshot$.value;
const snapshot =
const snapshot: BoardViewSnapshot =
this.swarmBoardSnapshotBase === baseSnapshot
? (this.swarmBoardSnapshot ?? baseSnapshot)
: baseSnapshot;
const hasBoard = boardExists(snapshot);
const hasBoard = snapshot.tabs.length > 0 || snapshot.widgets.length > 0;
const sessionKey = this.resolveBoardSessionKey(snapshot.sessionKey);
const saved =
loadSettings().boardSessionViews?.[sessionKey] ??
@@ -2972,9 +2989,16 @@ class ChatPane extends OpenClawLightDomElement {
? t("chat.catalog.remoteViewOnly")
: t("chat.catalog.unsupportedViewOnly")
: null;
const chatLayoutWidth = resolveBoardChatLayoutWidth({
paneWidth: this.paneWidth,
hasBoard: board.hasBoard,
face: board.face,
dock: board.dock,
dockWidth: this.boardChatDockSize.width,
});
const sessionWorkspace = createSessionWorkspaceProps(state, {
draftScope: this.paneId,
narrowLayout: this.paneWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
narrowLayout: chatLayoutWidth < WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH,
});
const railSideDocked =
!sessionWorkspace.collapsed &&
@@ -2984,7 +3008,7 @@ class ChatPane extends OpenClawLightDomElement {
// room for both columns before it may side-dock next to it.
const backgroundTasks = createBackgroundTasksProps(state, {
narrowLayout:
this.paneWidth <
chatLayoutWidth <
WORKSPACE_RAIL_SIDE_MIN_PANE_WIDTH + (railSideDocked ? WORKSPACE_RAIL_MAX_WIDTH : 0),
onOpenSession: (sessionKey) => {
this.onPaneSessionChange?.(this.paneId, sessionKey);
@@ -2994,7 +3018,7 @@ class ChatPane extends OpenClawLightDomElement {
// Every side-docked rail narrows the room left for the chat + detail
// split; bottom strips do not.
const sideRailCount = (railSideDocked ? 1 : 0) + (tasksSideDocked ? 1 : 0);
const detailSplitWidth = this.paneWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
const detailSplitWidth = chatLayoutWidth - sideRailCount * WORKSPACE_RAIL_MAX_WIDTH;
const props: ChatProps = {
transcript: this.transcript,
paneId: this.paneId,
@@ -3277,6 +3301,7 @@ class ChatPane extends OpenClawLightDomElement {
sidebarStacked: detailSplitWidth < DETAIL_SIDEBAR_SIDE_MIN_WIDTH,
splitRatio: state.splitRatio,
canvasPluginSurfaceUrl: state.hello?.pluginSurfaceUrls?.canvas ?? null,
boardProvider: board.provider,
onOpenSidebar: state.handleOpenSidebar,
onCloseSidebar: state.handleCloseSidebar,
onSplitRatioChange: state.handleSplitRatioChange,
@@ -3313,7 +3338,9 @@ class ChatPane extends OpenClawLightDomElement {
this.boardCommandDock = null;
this.persistBoardSessionView({ face: "dashboard", activeTabId: tabId });
},
frameLoadFailed: (name) => board.provider.refreshWidgetFrame(name),
} satisfies BoardViewCallbacks,
widgetFrameUrl: (name, revision) => board.provider.widgetFrameUrl(name, revision),
onDockChange: (dock) => this.handleBoardDockChange(dock),
})
: chat;
+124
View File
@@ -3956,6 +3956,130 @@ describe("handleSendChat", () => {
expect(reset).not.toHaveBeenCalled();
});
it("cancels a queued reset when dashboard reset confirmation is rejected", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chat.history") {
return idleChatHistory();
}
throw new Error(`Unexpected request: ${method}`);
});
const item = {
id: "queued-reset-cancelled",
text: "/reset",
createdAt: 1,
localCommandArgs: "",
localCommandName: "reset",
sessionKey: "agent:main",
};
const confirmConversationReset = vi.fn(async () => false);
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatQueue: [item],
confirmConversationReset,
});
admitHostQueueItems(host);
await retryReconnectableQueuedChatSends(host);
expect(confirmConversationReset).toHaveBeenCalledOnce();
expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything());
expect(listStoredChatOutboxes(host)).toStrictEqual([]);
});
it("preserves queued reset approval when a run starts during confirmation", async () => {
const confirmation = createDeferred<boolean>();
const sendPayloads: Array<Record<string, unknown>> = [];
const request = vi.fn((method: string, params?: unknown) => {
if (method === "chat.history") {
return Promise.resolve(idleChatHistory());
}
if (method === "chat.send") {
const payload = requireRecord(params, "approved queued reset payload");
sendPayloads.push(payload);
return Promise.resolve({ runId: payload.idempotencyKey, status: "ok" });
}
throw new Error(`Unexpected request: ${method}`);
});
const item = {
id: "queued-reset-approved-before-run",
text: "/reset",
createdAt: 1,
localCommandArgs: "",
localCommandName: "reset",
sessionKey: "agent:main",
};
const confirmConversationReset = vi.fn(async () => await confirmation.promise);
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatQueue: [item],
confirmConversationReset,
});
admitHostQueueItems(host);
const draining = retryReconnectableQueuedChatSends(host);
await waitForFast(() => expect(confirmConversationReset).toHaveBeenCalledOnce());
host.chatRunId = "run-started-during-reset-confirmation";
confirmation.resolve(true);
await draining;
const approvedReset = listStoredChatOutboxes(host)[0]?.queue[0];
expect(approvedReset).toEqual(
expect.objectContaining({
id: item.id,
sendState: "waiting-idle",
text: "/reset",
}),
);
expect(approvedReset).not.toHaveProperty("localCommandName");
host.chatRunId = null;
await retryReconnectableQueuedChatSends(host);
expect(confirmConversationReset).toHaveBeenCalledOnce();
expect(sendPayloads.map((payload) => payload.message)).toEqual(["/reset"]);
expect(listStoredChatOutboxes(host)).toStrictEqual([]);
});
it("keeps a queued reset when its session changes during confirmation", async () => {
const confirmation = createDeferred<boolean>();
const request = vi.fn(async (method: string) => {
if (method === "chat.history") {
return idleChatHistory("agent:main:first");
}
throw new Error(`Unexpected request: ${method}`);
});
const item = {
id: "queued-reset-route-switch",
text: "/reset",
createdAt: 1,
localCommandArgs: "",
localCommandName: "reset",
sessionKey: "agent:main:first",
};
const confirmConversationReset = vi.fn(async () => await confirmation.promise);
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatQueue: [item],
confirmConversationReset,
sessionKey: item.sessionKey,
});
admitHostQueueItems(host);
const draining = retryReconnectableQueuedChatSends(host);
await waitForFast(() => expect(confirmConversationReset).toHaveBeenCalledOnce());
host.sessionKey = "agent:main:second";
confirmation.resolve(false);
await draining;
expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything());
expect(listStoredChatOutboxes(host)).toEqual([
expect.objectContaining({
sessionKey: item.sessionKey,
queue: [expect.objectContaining({ id: item.id, sendState: "waiting-idle" })],
}),
]);
});
it("retires a queued local command without applying its late result after a route switch", async () => {
const command = createDeferred<Awaited<ReturnType<ExecuteSlashCommand>>>();
executeSlashCommandMock.mockImplementationOnce(() => command.promise);
+31 -7
View File
@@ -47,6 +47,7 @@ import {
releaseChatAttachmentPayloads,
} from "./attachment-payload-store.ts";
import {
confirmConversationResetForCurrentSession,
dispatchChatSlashCommand,
type ChatCommandHost,
type ChatCommandResetOptions,
@@ -1785,18 +1786,41 @@ async function drainStoredChatOutbox(
syncChatQueueFromStoredOutbox(host, outbox);
if (item.localCommandName === "reset") {
const resetText = item.localCommandArgs ? `/reset ${item.localCommandArgs}` : "/reset";
const converted = updateQueuedMessageForSession(
host,
outbox.sessionKey,
item.id,
(entry) => ({
const convertResetToMessage = (sendState?: ChatQueueItem["sendState"]) =>
updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
localCommandArgs: undefined,
localCommandName: undefined,
refreshSessions: true,
text: resetText,
}),
);
...(sendState ? { sendState } : {}),
}));
const confirmation = await confirmConversationResetForCurrentSession(host, {
sessionKey: outbox.sessionKey,
...(outbox.agentId ? { agentId: outbox.agentId } : {}),
});
if (confirmation === "deferred") {
const approvedDuringRun =
visibleSessionMatches(host, outbox.sessionKey, outbox.agentId) && host.chatRunId;
const deferred = approvedDuringRun
? convertResetToMessage("waiting-idle")
: updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendError: undefined,
sendState: "waiting-idle",
}));
if (!deferred) {
return "blocked";
}
return "blocked";
}
if (confirmation === "cancelled") {
if (!removeQueuedMessageWithoutReleasing(host, item.id, outbox.sessionKey)) {
return "blocked";
}
continue;
}
const converted = convertResetToMessage();
if (!converted) {
return "blocked";
}
+3
View File
@@ -15,6 +15,7 @@ import type { ChatSendShortcut } from "../../app/settings.ts";
import { renderExecApprovalCard } from "../../components/exec-approval-card.ts";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import type { BoardProvider } from "../../lib/board/provider.ts";
import type {
ChatAttachment,
ChatQueueItem,
@@ -143,6 +144,7 @@ export type ChatProps = {
sidebarStacked?: boolean;
splitRatio?: number;
canvasPluginSurfaceUrl?: string | null;
boardProvider?: BoardProvider;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
chatMessageMaxWidth?: string | null;
@@ -338,6 +340,7 @@ export function renderChat(props: ChatProps) {
sessions: props.sessions,
sessionHost: props.sessionHost,
gatewayUrl: props.gatewayUrl,
boardProvider: props.boardProvider,
assistantName: props.assistantName,
assistantAvatar: props.assistantAvatar,
assistantAvatarUrl: props.assistantAvatarUrl,
@@ -13,6 +13,7 @@ import {
} from "../../../components/markdown.ts";
import { t } from "../../../i18n/index.ts";
import type { AssistantIdentity } from "../../../lib/assistant-identity.ts";
import type { BoardProvider } from "../../../lib/board/provider.ts";
import type {
ChatItem,
MessageContentItem,
@@ -766,6 +767,7 @@ type RenderMessageGroupOptions = {
onOpenSidebar?: (content: SidebarContent) => void;
onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void;
sessionKey?: string;
boardProvider?: BoardProvider;
agentId?: string;
showReasoning: boolean;
showToolCalls?: boolean;
@@ -804,6 +806,7 @@ function buildGroupedMessageRenderOptions(
return {
isStreaming: group.isStreaming && index === group.messages.length - 1,
sessionKey: opts.sessionKey,
boardProvider: opts.boardProvider,
agentId: opts.agentId,
entryId: persistedMessageEntryId(item.message) ?? undefined,
onOpenWorkspaceFile: opts.onOpenWorkspaceFile,
@@ -2304,6 +2307,7 @@ function renderGroupedMessage(
opts: {
isStreaming: boolean;
sessionKey?: string;
boardProvider?: BoardProvider;
agentId?: string;
duplicateCount?: number;
showReasoning: boolean;
@@ -2444,6 +2448,7 @@ function renderGroupedMessage(
onOpenSidebar,
rawText: block.rawText ?? null,
canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl,
boardProvider: opts.boardProvider,
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
sessionKey: opts.sessionKey,
})}
@@ -25,6 +25,7 @@ import {
} from "../../../components/markdown.ts";
import { McpAppUnmountGate } from "../../../components/mcp-app-unmount.ts";
import { i18n, t } from "../../../i18n/index.ts";
import type { BoardProvider } from "../../../lib/board/provider.ts";
import type {
ChatQueueItem,
ChatStreamSegment,
@@ -97,6 +98,7 @@ type ChatThreadState = {
type ChatThreadProps = {
paneId: string;
sessionKey: string;
boardProvider?: BoardProvider;
announceTranscript?: boolean;
loading: boolean;
historyPagination?: {
@@ -1137,6 +1139,7 @@ function renderChatThreadContents(
onOpenSidebar: props.onOpenSidebar,
onOpenWorkspaceFile: props.onOpenWorkspaceFile,
sessionKey: props.sessionKey,
boardProvider: props.boardProvider,
agentId: props.fullMessageAgentId,
showReasoning,
showToolCalls: props.showToolCalls,
@@ -1256,6 +1259,9 @@ function renderChatThreadContents(
getToolTitlesVersion(),
props.sessionKey,
props.gatewayUrl,
props.boardProvider,
props.boardProvider?.canPinWidgets,
props.boardProvider?.snapshot$.value.revision,
props.fullMessageAgentId,
showReasoning,
props.showToolCalls,
@@ -1,7 +1,8 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { BoardProvider } from "../../../lib/board/provider.ts";
import { renderToolPreview } from "./widget-card.ts";
describe("widget-card", () => {
@@ -51,4 +52,156 @@ describe("widget-card", () => {
render(renderToolPreview({ kind: "unknown" } as never, "chat_message"), unknown);
expect(unknown.childElementCount).toBe(0);
});
it("pins Canvas HTML through the board provider and hides the action for MCP Apps", async () => {
const pinWidget = vi.fn(async () => undefined);
const snapshotSignal = {
value: {
sessionKey: "agent:main:main",
revision: 0,
tabs: [],
widgets: [],
} as BoardProvider["snapshot$"]["value"],
subscribe: () => () => {},
};
const provider = {
canPinWidgets: true,
pinWidget,
snapshot$: snapshotSignal,
} as unknown as BoardProvider;
const canvas = document.createElement("div");
render(
renderToolPreview(
{
kind: "canvas",
surface: "assistant_message",
render: "url",
title: "Release status",
viewId: "cv_release",
url: "/__openclaw__/canvas/documents/cv_release/index.html",
sandbox: "scripts",
},
"chat_message",
{ boardProvider: provider },
),
canvas,
);
canvas.querySelector<HTMLButtonElement>("[data-pin-widget]")?.click();
await vi.waitFor(() => {
expect(pinWidget).toHaveBeenCalledWith({
docId: "cv_release",
name: "canvas-cv_release",
title: "Release status",
});
});
snapshotSignal.value = {
sessionKey: "agent:main:main",
revision: 1,
tabs: [],
widgets: [
{
name: "release-status",
tabId: "main",
contentKind: "html",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none",
revision: 1,
},
],
};
const pinned = document.createElement("div");
render(
renderToolPreview(
{
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_release",
boardWidgetName: "release-status",
url: "/__openclaw__/canvas/documents/cv_release/index.html",
sandbox: "scripts",
},
"chat_message",
{ boardProvider: provider },
),
pinned,
);
expect(pinned.querySelector<HTMLButtonElement>("[data-pin-widget]")?.disabled).toBe(true);
expect(pinned.querySelector("[data-pin-widget]")?.textContent).toContain("Pinned");
const external = document.createElement("div");
render(
renderToolPreview(
{
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_external",
url: "https://example.com/widget.html",
sandbox: "scripts",
},
"chat_message",
{ allowExternalEmbedUrls: true, boardProvider: provider },
),
external,
);
expect(external.querySelector("[data-pin-widget]")).toBeNull();
const mismatched = document.createElement("div");
render(
renderToolPreview(
{
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_expected",
url: "/__openclaw__/canvas/documents/cv_other/index.html",
sandbox: "scripts",
},
"chat_message",
{ boardProvider: provider },
),
mismatched,
);
expect(mismatched.querySelector("[data-pin-widget]")).toBeNull();
const strict = document.createElement("div");
render(
renderToolPreview(
{
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_strict",
url: "/__openclaw__/canvas/documents/cv_strict/index.html",
sandbox: "strict",
},
"chat_message",
{ boardProvider: provider },
),
strict,
);
expect(strict.querySelector("[data-pin-widget]")).toBeNull();
const app = document.createElement("div");
render(
renderToolPreview(
{
kind: "canvas",
surface: "assistant_message",
render: "url",
viewId: "cv_app",
mcpApp: { viewId: "cv_app" },
},
"chat_message",
{ boardProvider: provider, sessionKey: "agent:main:main" },
),
app,
);
expect(app.querySelector("[data-pin-widget]")).toBeNull();
});
});
+90 -1
View File
@@ -9,6 +9,7 @@ import {
} from "../../../components/mcp-app-security.ts";
import "../../../components/web-awesome.ts";
import { t } from "../../../i18n/index.ts";
import { canvasWidgetNameForDocument, type BoardProvider } from "../../../lib/board/provider.ts";
import type { ToolPreview } from "../../../lib/chat/tool-cards.ts";
import {
isInternalCanvasEntryUrl,
@@ -31,8 +32,66 @@ type WidgetCardOptions = {
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
sessionKey?: string;
boardProvider?: BoardProvider;
};
async function pinCanvasWidget(
event: Event,
preview: ToolPreview,
provider: BoardProvider,
name: string,
): Promise<void> {
const button = event.currentTarget;
if (!(button instanceof HTMLButtonElement) || !preview.viewId) {
return;
}
button.disabled = true;
button.textContent = t("chat.toolCards.pinToDashboardPending");
try {
await provider.pinWidget({
docId: preview.viewId,
name,
...(preview.title?.trim() ? { title: preview.title.trim() } : {}),
});
button.textContent = t("chat.toolCards.pinnedToDashboard");
button.dataset.pinned = "true";
} catch (error) {
button.disabled = false;
button.textContent = t("chat.toolCards.pinToDashboard");
button.title = error instanceof Error ? error.message : String(error);
}
}
function canvasWidgetName(preview: ToolPreview): string | undefined {
if (preview.boardWidgetName) {
return preview.boardWidgetName;
}
return preview.viewId ? canvasWidgetNameForDocument(preview.viewId) : undefined;
}
function isManagedCanvasDocumentPreview(preview: ToolPreview): boolean {
const viewId = preview.viewId?.trim();
const entryUrl = preview.url?.trim();
if (!viewId || !entryUrl) {
return false;
}
try {
const entry = new URL(entryUrl, "http://localhost");
const prefix = "/__openclaw__/canvas/documents/";
if (entry.origin !== "http://localhost" || !entry.pathname.startsWith(prefix)) {
return false;
}
const [encodedDocumentId, entrypoint] = entry.pathname.slice(prefix.length).split("/", 2);
if (!encodedDocumentId || !entrypoint) {
return false;
}
const documentId = decodeURIComponent(encodedDocumentId);
return /^[A-Za-z0-9._-]+$/u.test(documentId) && documentId === viewId;
} catch {
return false;
}
}
// Sandboxed widget documents report their content height via postMessage so the
// preview iframe can fit short/tall widgets. The event source must be one of our
// preview frames and the height is clamped, so widget code can only resize its
@@ -365,11 +424,41 @@ function renderWidgetCard(
}
const label = preview.title?.trim() || t("chat.toolCards.canvas");
const contentKind = preview.mcpApp ? "mcp-app" : "canvas-html";
const pinName = canvasWidgetName(preview);
const pinned = Boolean(
pinName &&
options?.boardProvider?.snapshot$.value.widgets.some((widget) => widget.name === pinName),
);
const pinAction =
contentKind === "canvas-html" &&
preview.sandbox === "scripts" &&
options?.boardProvider?.canPinWidgets &&
isManagedCanvasDocumentPreview(preview) &&
pinName
? html`<button
class="chat-tool-card__widget-action"
type="button"
data-pin-widget
?disabled=${pinned}
?data-pinned=${pinned}
title=${t(pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard")}
aria-label=${t(
pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard",
)}
@click=${(event: Event) =>
void pinCanvasWidget(event, preview, options.boardProvider!, pinName)}
>
${t(pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard")}
</button>`
: nothing;
return html`
<div class="chat-tool-card__preview" data-kind="canvas" data-surface=${surface}>
<div class="chat-tool-card__preview-header">
<span class="chat-tool-card__preview-label">${label}</span>
${renderWidgetActions(preview)}
<div class="chat-tool-card__preview-actions">
<div data-widget-actions ?hidden=${pinAction === nothing}>${pinAction}</div>
${renderWidgetActions(preview)}
</div>
</div>
<div class="chat-tool-card__preview-panel" data-side="canvas">
${renderWidgetContent(contentKind, preview, options)}
+12
View File
@@ -429,6 +429,18 @@ openclaw-board-widget-cell {
max-width: 32ch;
}
.board-widget__grant-summary {
color: var(--muted, #8a919e);
display: grid;
font-size: 11px;
gap: 4px;
line-height: 1.45;
margin: 0;
max-width: 36ch;
padding-left: 18px;
text-align: left;
}
.board-widget__grant-mark {
align-items: center;
background: color-mix(in srgb, var(--warning, #e6ad55) 15%, transparent);
+37
View File
@@ -758,6 +758,13 @@
color: var(--muted);
}
.chat-tool-card__preview-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.chat-tool-card__widget-actions {
position: relative;
flex-shrink: 0;
@@ -786,6 +793,36 @@
height: 16px;
}
[data-widget-actions] {
opacity: 0;
transition: opacity 120ms ease-out;
}
.chat-tool-card__preview:hover [data-widget-actions],
.chat-tool-card__preview:focus-within [data-widget-actions] {
opacity: 1;
}
.chat-tool-card__widget-action {
background: transparent;
border: 0;
color: var(--muted);
cursor: pointer;
font: inherit;
font-size: 11px;
padding: 2px 4px;
}
.chat-tool-card__widget-action:hover,
.chat-tool-card__widget-action:focus-visible {
color: var(--text);
}
.chat-tool-card__widget-action:disabled {
cursor: default;
opacity: 0.7;
}
.chat-tool-card__preview-panel {
padding: 12px;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { BoardOp, BoardSnapshot } from "../lib/board/view-types.ts";
import type { BoardOp, BoardSnapshot } from "../lib/board/types.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import "../components/board/board-view.ts";
+7 -1
View File
@@ -54,6 +54,7 @@ export type ControlUiMockGatewayScenario = {
label: string;
pluginId: string;
}>;
featureCapabilities?: string[];
defaultAgentId?: string;
deferredMethods?: string[];
/** Non-release gateway checkout branch surfaced in the sidebar footer. */
@@ -258,6 +259,7 @@ function normalizeScenario(
assistantName: scenario.assistantName?.trim() || "OpenClaw",
basePath,
controlUiTabs: scenario.controlUiTabs ?? [],
featureCapabilities: scenario.featureCapabilities ?? [],
defaultAgentId,
deferredMethods: scenario.deferredMethods ?? [],
devGitBranch: scenario.devGitBranch?.trim() || "",
@@ -798,7 +800,11 @@ function installControlUiMockGateway(input: {
"operator.pairing",
],
},
features: { events: [], methods: scenario.featureMethods },
features: {
capabilities: scenario.featureCapabilities,
events: [],
methods: scenario.featureMethods,
},
controlUiTabs: scenario.controlUiTabs,
protocol: protocolVersion,
server: { connId: "control-ui-e2e", version: "e2e" },