feat(ui): session board grid engine and board view (#110644)

* feat(ui): add session board grid view

* fix(ui): use shared board controls

* fix(ui): keep board cell registration type-safe

* chore(ui): drop unused board cell class export
This commit is contained in:
Peter Steinberger
2026-07-18 17:40:41 +01:00
committed by GitHub
parent 81df196b8d
commit 8a66aa76dd
12 changed files with 3075 additions and 3 deletions

View File

@@ -23,6 +23,7 @@ import { buildSkillWorkshopMocks } from "./control-ui-mock-skill-workshop.js";
type CliOptions = {
allowedHosts: string[];
fixture?: "board";
host: string;
port: number;
};
@@ -40,6 +41,30 @@ const TOTAL_TELEGRAM_SESSIONS = 180;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const uiRoot = path.join(repoRoot, "ui");
const boardFixturePath = "/__fixtures/board/";
const boardFixtureHtml = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenClaw Board Fixture</title>
<link rel="stylesheet" href="/src/styles.css" />
<style>
body { margin: 0; min-width: 320px; min-height: 100vh; background: #0f1115; }
.board-fixture-shell { box-sizing: border-box; margin: 0 auto; max-width: 1440px; padding: 36px; }
.board-fixture-header { align-items: end; display: flex; justify-content: space-between; margin-bottom: 24px; }
.board-fixture-header span { color: #747e8d; font: 10px ui-monospace, monospace; letter-spacing: .15em; }
.board-fixture-header h1 { color: #e5e7eb; font-size: 24px; letter-spacing: -.03em; margin: 5px 0 0; }
.board-fixture-status { color: #8892a0; font: 11px ui-monospace, monospace; }
.board-fixture-status i { background: #4ec9a8; border-radius: 50%; display: inline-block; height: 7px; margin-right: 6px; width: 7px; }
@media (max-width: 700px) { .board-fixture-shell { padding: 18px; } }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/test-helpers/board-fixture.ts"></script>
</body>
</html>`;
function mockFileHash(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
@@ -63,6 +88,10 @@ function parseArgs(args: string[]): CliOptions {
options.host = args[++i] ?? options.host;
} else if (arg.startsWith("--host=")) {
options.host = arg.slice("--host=".length) || options.host;
} else if (arg === "--fixture") {
options.fixture = parseFixture(args[++i]);
} else if (arg.startsWith("--fixture=")) {
options.fixture = parseFixture(arg.slice("--fixture=".length));
} else if (arg === "--port") {
options.port = parsePort(args[++i], options.port);
} else if (arg.startsWith("--port=")) {
@@ -72,6 +101,16 @@ function parseArgs(args: string[]): CliOptions {
return options;
}
function parseFixture(value: string | undefined): "board" | undefined {
if (!value) {
return undefined;
}
if (value !== "board") {
throw new Error(`Unknown Control UI mock fixture: ${value}`);
}
return value;
}
function parsePort(value: string | undefined, fallback: number): number {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 && parsed < 65_536 ? parsed : fallback;
@@ -1421,18 +1460,42 @@ function createMockGatewayPlugin(scenario: ControlUiMockGatewayScenario): Plugin
};
}
function createBoardFixturePlugin(): Plugin {
return {
name: "openclaw-control-ui-board-fixture",
configureServer(server) {
server.middlewares.use(boardFixturePath, (_req, res, next) => {
void server
.transformIndexHtml(boardFixturePath, boardFixtureHtml)
.then((html) => {
res.statusCode = 200;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(html);
})
.catch((error: unknown) => {
next(error as Error);
});
});
},
};
}
function hostForUrl(boundAddress: string, requestedHost: string): string {
const host = boundAddress === "0.0.0.0" || boundAddress === "::" ? requestedHost : boundAddress;
const reachableHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
return reachableHost.includes(":") ? `[${reachableHost}]` : reachableHost;
}
function resolveServerUrl(server: ViteDevServer, requestedHost: string): string {
function resolveServerUrl(
server: ViteDevServer,
requestedHost: string,
pathname = "/chat",
): string {
const address = server.httpServer?.address();
if (!address || typeof address === "string") {
throw new Error("Control UI mock server did not expose a TCP port");
}
return `http://${hostForUrl(address.address, requestedHost)}:${address.port}/chat`;
return `http://${hostForUrl(address.address, requestedHost)}:${address.port}${pathname}`;
}
async function waitForShutdown(): Promise<void> {
@@ -1459,9 +1522,12 @@ const server = await createServer({
},
logLevel: "error",
optimizeDeps: {
...(options.fixture === "board"
? { entries: [path.join(uiRoot, "src", "test-helpers", "board-fixture.ts")] }
: {}),
include: ["lit/directives/repeat.js"],
},
plugins: [createMockGatewayPlugin(scenario)],
plugins: [createMockGatewayPlugin(scenario), createBoardFixturePlugin()],
publicDir: path.join(uiRoot, "public"),
resolve: {
alias: [
@@ -1481,5 +1547,8 @@ const server = await createServer({
await server.listen();
console.log(`[control-ui-mock] ${resolveServerUrl(server, options.host)}`);
console.log(
`[control-ui-mock] board fixture: ${resolveServerUrl(server, options.host, boardFixturePath)}`,
);
await waitForShutdown();
await server.close();

View File

@@ -0,0 +1,283 @@
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 "./board-view.ts";
type OpenClawBoardView = HTMLElementTagNameMap["openclaw-board-view"];
const hasBrowserLayout = !navigator.userAgent.toLowerCase().includes("jsdom");
const source: BoardSnapshot = {
sessionKey: "agent:main:browser-board",
revision: 1,
tabs: [
{ tabId: "main", title: "Main", position: 0, chatDock: "right" },
{ tabId: "ops", title: "Operations", position: 1, chatDock: "right" },
],
widgets: [
{
name: "first",
tabId: "main",
title: "First",
contentKind: "html",
sizeW: 6,
sizeH: 3,
position: 0,
grantState: "none",
revision: 1,
},
{
name: "second",
tabId: "main",
title: "Second",
contentKind: "html",
sizeW: 6,
sizeH: 3,
position: 1,
grantState: "none",
revision: 1,
},
],
};
async function mount(applyOps = vi.fn(async () => undefined)): Promise<OpenClawBoardView> {
const view = document.createElement("openclaw-board-view");
view.snapshot = structuredClone(source);
view.activeTabId = "main";
view.widgetFrameUrl = () => "about:blank";
view.callbacks = { applyOps, grant: vi.fn(async () => undefined), selectTab: vi.fn() };
document.body.append(view);
await view.updateComplete;
await Promise.all(
[...view.querySelectorAll("openclaw-board-widget-cell")].map((cell) => cell.updateComplete),
);
return view;
}
function pointer(
target: EventTarget,
type: "pointerdown" | "pointermove" | "pointerup",
pointerId: number,
clientX = 0,
clientY = 0,
): void {
target.dispatchEvent(
new PointerEvent(type, {
pointerId,
clientX,
clientY,
button: 0,
bubbles: true,
cancelable: true,
}),
);
}
afterEach(() => {
document.body.replaceChildren();
});
describe.skipIf(!hasBrowserLayout)("openclaw-board-view browser layout", () => {
it("lays out adjacent first-fit cells without pixel overlap", async () => {
const view = await mount();
const cells = [...view.querySelectorAll<HTMLElement>('[data-test-id="board-widget"]')];
expect(cells).toHaveLength(2);
const [first, second] = cells.map((cell) => cell.getBoundingClientRect());
expect(first?.width).toBeGreaterThan(0);
expect(second?.left).toBeGreaterThanOrEqual((first?.right ?? 0) + BOARD_GRID_GAP - 1);
expect(Math.round(first?.height ?? 0)).toBe(BOARD_GRID_ROW_HEIGHT * 3 + BOARD_GRID_GAP * 2);
});
it("snaps pointer resize to columns and rows before committing", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount(applyOps);
const grid = view.querySelector<HTMLElement>(".board-grid");
const handle = view.querySelector<HTMLElement>(".board-widget__resize-handle");
expect(grid).not.toBeNull();
expect(handle).not.toBeNull();
const gridBounds = grid!.getBoundingClientRect();
const columnUnit = (gridBounds.width - BOARD_GRID_GAP * 11) / 12 + BOARD_GRID_GAP;
pointer(handle!, "pointerdown", 19, 100, 100);
pointer(
window,
"pointermove",
19,
100 + columnUnit,
100 + BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP,
);
pointer(
window,
"pointerup",
19,
100 + columnUnit * 2,
100 + (BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP) * 2,
);
await vi.waitFor(() =>
expect(applyOps).toHaveBeenCalledWith([
{ kind: "widget_resize", name: "first", sizeW: 8, sizeH: 5 },
]),
);
});
it("does not commit pointer gestures that never change placement", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount(applyOps);
const handle = view.querySelector<HTMLElement>(".board-widget__resize-handle");
pointer(handle!, "pointerdown", 23, 100, 100);
pointer(window, "pointerup", 23, 100, 100);
const dragHandle = view.querySelector<HTMLElement>(".board-widget__drag-handle");
pointer(dragHandle!, "pointerdown", 24, 100, 100);
pointer(window, "pointerup", 24, 100, 100);
await Promise.resolve();
expect(applyOps).not.toHaveBeenCalled();
});
it("keeps an active gesture owned by its initiating pointer", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount(applyOps);
const grid = view.querySelector<HTMLElement>(".board-grid");
const handles = view.querySelectorAll<HTMLElement>(".board-widget__resize-handle");
const gridBounds = grid!.getBoundingClientRect();
const columnUnit = (gridBounds.width - BOARD_GRID_GAP * 11) / 12 + BOARD_GRID_GAP;
pointer(handles[0]!, "pointerdown", 31, 100, 100);
pointer(handles[1]!, "pointerdown", 32, 200, 100);
pointer(
window,
"pointermove",
32,
200 + columnUnit,
100 + BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP,
);
pointer(window, "pointerup", 32);
expect(applyOps).not.toHaveBeenCalled();
pointer(
window,
"pointermove",
31,
100 + columnUnit,
100 + BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP,
);
pointer(
window,
"pointerup",
31,
100 + columnUnit,
100 + BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP,
);
await vi.waitFor(() =>
expect(applyOps).toHaveBeenCalledWith([
{ kind: "widget_resize", name: "first", sizeW: 7, sizeH: 4 },
]),
);
});
it("rejects tab drop targets owned by another board", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount(applyOps);
const other = await mount();
other.snapshot = {
...structuredClone(source),
tabs: [
{ tabId: "foreign-a", title: "Foreign A", position: 0, chatDock: "right" },
{ tabId: "foreign-b", title: "Foreign B", position: 1, chatDock: "right" },
],
widgets: [],
};
other.activeTabId = "foreign-a";
await other.updateComplete;
const handle = view.querySelector<HTMLElement>(".board-widget__drag-handle");
const foreignTab = other.querySelector<HTMLElement>('[data-board-tab-id="foreign-b"]');
const target = foreignTab!.getBoundingClientRect();
pointer(handle!, "pointerdown", 41, 100, 100);
pointer(
window,
"pointermove",
41,
target.left + target.width / 2,
target.top + target.height / 2,
);
pointer(
window,
"pointerup",
41,
target.left + target.width / 2,
target.top + target.height / 2,
);
await Promise.resolve();
expect(applyOps).not.toHaveBeenCalled();
});
it("rejects widget drops outside the board grid", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount(applyOps);
const handle = view.querySelector<HTMLElement>(".board-widget__drag-handle");
pointer(handle!, "pointerdown", 51, 100, 100);
pointer(window, "pointermove", 51, 0, 10_000);
pointer(window, "pointerup", 51, 0, 10_000);
await Promise.resolve();
expect(applyOps).not.toHaveBeenCalled();
});
it("offers an append drop zone after the final widget", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount(applyOps);
view.snapshot = {
...structuredClone(source),
widgets: source.widgets.map((widget) => ({ ...widget, sizeW: 12 })),
};
await view.updateComplete;
const cells = view.querySelectorAll("openclaw-board-widget-cell");
await Promise.all([...cells].map((cell) => cell.updateComplete));
const handle = view.querySelector<HTMLElement>(".board-widget__drag-handle");
pointer(handle!, "pointerdown", 61, 100, 100);
await view.updateComplete;
const appendZone = view.querySelector<HTMLElement>(".board-grid__append-zone");
const target = appendZone!.getBoundingClientRect();
const targetX = target.left + target.width / 2;
const targetY = target.top + target.height / 2;
pointer(window, "pointermove", 61, targetX, targetY);
pointer(window, "pointerup", 61, targetX, targetY);
await vi.waitFor(() =>
expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_move", name: "first", position: 1 }]),
);
});
it("keeps approval controls scrollable in a one-row widget", async () => {
const view = await mount();
view.snapshot = {
...structuredClone(source),
widgets: [{ ...source.widgets[0]!, sizeH: 1, grantState: "pending" }],
};
await view.updateComplete;
const cell = view.querySelector("openclaw-board-widget-cell");
await cell?.updateComplete;
const body = view.querySelector<HTMLElement>(".board-widget__body--scrollable");
const allow = view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-allow"]');
expect(getComputedStyle(body!).overflowY).toBe("auto");
expect(body!.scrollHeight).toBeGreaterThan(body!.clientHeight);
allow?.focus();
expect(document.activeElement).toBe(allow);
});
it("keeps contained errors scrollable in a one-row widget", async () => {
const view = await mount();
view.snapshot = {
...structuredClone(source),
widgets: [{ ...source.widgets[0]!, sizeH: 1 }],
};
view.widgetFrameUrl = () => {
throw new Error("one-row resolver failed");
};
await view.updateComplete;
const cell = view.querySelector("openclaw-board-widget-cell");
await cell?.updateComplete;
const body = view.querySelector<HTMLElement>(".board-widget__body--scrollable");
expect(getComputedStyle(body!).overflowY).toBe("auto");
expect(body!.scrollHeight).toBeGreaterThan(body!.clientHeight);
expect(body?.textContent).toContain("one-row resolver failed");
});
});

View File

@@ -0,0 +1,507 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BoardSnapshot, BoardViewCallbacks, BoardWidget } from "../../lib/board/view-types.ts";
import { applyBoardFixtureOps } from "../../test-helpers/board-fixture.ts";
import "./board-view.ts";
type OpenClawBoardView = HTMLElementTagNameMap["openclaw-board-view"];
type OpenClawBoardWidgetCell = HTMLElementTagNameMap["openclaw-board-widget-cell"];
function boardWidget(overrides: Partial<BoardWidget> = {}): BoardWidget {
return {
name: "alpha",
tabId: "main",
title: "Alpha status",
contentKind: "html",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none",
revision: 1,
...overrides,
};
}
function snapshot(overrides: Partial<BoardSnapshot> = {}): BoardSnapshot {
return {
sessionKey: "agent:main:test",
revision: 1,
tabs: [
{ tabId: "main", title: "Main", position: 0, chatDock: "right" },
{ tabId: "ops", title: "Operations", position: 1, chatDock: "bottom" },
],
widgets: [
boardWidget(),
boardWidget({
name: "beta",
title: "Beta chart",
sizeW: 6,
position: 1,
revision: 2,
}),
boardWidget({
name: "ops-only",
title: "Queue depth",
tabId: "ops",
sizeW: 12,
}),
],
...overrides,
};
}
function callbacks(overrides: Partial<BoardViewCallbacks> = {}): BoardViewCallbacks {
return {
applyOps: vi.fn(async () => undefined),
grant: vi.fn(async () => undefined),
selectTab: vi.fn(),
...overrides,
};
}
function deferred(): {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
} {
let resolve: () => void = () => undefined;
let reject: (error: Error) => void = () => undefined;
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function settleCells(view: OpenClawBoardView): Promise<OpenClawBoardWidgetCell[]> {
await view.updateComplete;
const cells = [...view.querySelectorAll("openclaw-board-widget-cell")];
await Promise.all(cells.map((cell) => cell.updateComplete));
return cells;
}
async function mount(
options: {
snapshot?: BoardSnapshot;
activeTabId?: string;
callbacks?: BoardViewCallbacks;
widgetFrameUrl?: (name: string, revision: number) => string;
} = {},
): Promise<OpenClawBoardView> {
const view = document.createElement("openclaw-board-view");
view.snapshot = options.snapshot ?? snapshot();
view.activeTabId = options.activeTabId ?? "main";
view.widgetFrameUrl = options.widgetFrameUrl ?? (() => "about:blank");
view.callbacks = options.callbacks ?? callbacks();
document.body.append(view);
await settleCells(view);
return view;
}
afterEach(() => {
document.body.replaceChildren();
});
describe("openclaw-board-view", () => {
it("renders only the active tab widgets with sandboxed frames", async () => {
const view = await mount();
const cells = view.querySelectorAll('[data-test-id="board-widget"]');
expect(cells).toHaveLength(2);
expect([...cells].map((cell) => cell.getAttribute("data-widget-name"))).toEqual([
"alpha",
"beta",
]);
const frames = view.querySelectorAll("iframe");
expect(frames).toHaveLength(2);
for (const frame of frames) {
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer");
}
});
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(
(cell) => cell.widget?.name === "alpha",
);
const frame = before?.querySelector("iframe");
const removedNodes: Node[] = [];
const observer = new MutationObserver((records) => {
for (const record of records) {
removedNodes.push(...record.removedNodes);
}
});
observer.observe(view.querySelector(".board-grid")!, { childList: true });
const reordered = snapshot();
reordered.widgets = reordered.widgets.map((widget) =>
widget.name === "alpha"
? { ...widget, position: 1 }
: widget.name === "beta"
? { ...widget, position: 0 }
: widget,
);
view.snapshot = reordered;
const cells = await settleCells(view);
const after = cells.find((cell) => cell.widget?.name === "alpha");
expect(after).toBe(before);
expect(after?.querySelector("iframe")).toBe(frame);
expect(removedNodes).not.toContain(before);
expect(after?.querySelector(".board-widget")?.getAttribute("aria-posinset")).toBe("2");
expect(
cells
.find((cell) => cell.widget?.name === "beta")
?.querySelector(".board-widget")
?.getAttribute("aria-posinset"),
).toBe("1");
observer.disconnect();
view.snapshot = { ...snapshot(), sessionKey: "agent:main:other-session" };
const sessionCells = await settleCells(view);
const afterSessionChange = sessionCells.find((cell) => cell.widget?.name === "alpha");
expect(afterSessionChange).not.toBe(before);
expect(afterSessionChange?.querySelector("iframe")).not.toBe(frame);
});
it("routes tab selection and updates cells when the host changes the active prop", async () => {
const selectTab = vi.fn();
const view = await mount({ callbacks: callbacks({ selectTab }) });
expect(selectTab).not.toHaveBeenCalled();
view.querySelector(".board-tabs__track")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
detail: { name: "ops" },
bubbles: true,
}),
);
expect(selectTab).toHaveBeenCalledWith("ops");
view.activeTabId = "ops";
const cells = await settleCells(view);
expect(cells).toHaveLength(1);
expect(cells[0]?.widget?.name).toBe("ops-only");
});
it("hides the tab strip when the board has only one tab", async () => {
const source = snapshot();
source.tabs = source.tabs.slice(0, 1);
source.widgets = source.widgets.filter((widget) => widget.tabId === "main");
const view = await mount({ snapshot: source });
expect(view.querySelector(".board-tabs")).toBeNull();
});
it("calls applyOps from the kebab remove action", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount({ callbacks: callbacks({ applyOps }) });
const firstCell = view.querySelector("openclaw-board-widget-cell");
firstCell?.querySelector<HTMLButtonElement>(".board-widget__menu-danger")?.click();
await vi.waitFor(() => {
expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_remove", name: "alpha" }]);
});
});
it("shows failed host operations in the board and originating cell", async () => {
const applyOps = vi.fn(async () => {
throw new Error("fixture write failed");
});
const view = await mount({ callbacks: callbacks({ applyOps }) });
const frame = view.querySelector("iframe");
view.querySelector<HTMLButtonElement>(".board-widget__menu-danger")?.click();
await vi.waitFor(() => {
expect(view.querySelector(".board-view__error")?.textContent).toContain("could not be saved");
expect(
view.querySelector('[data-test-id="board-widget-action-error"]')?.textContent,
).toContain("fixture write failed");
});
expect(view.querySelector("iframe")).toBe(frame);
view.snapshot = { ...snapshot(), revision: 2 };
await settleCells(view);
expect(view.querySelector(".board-view__error")).toBeNull();
});
it("allows only one snapshot-derived board mutation at a time", async () => {
const pending = deferred();
const applyOps = vi.fn(() => pending.promise);
const view = await mount({ callbacks: callbacks({ applyOps }) });
const cells = [...view.querySelectorAll("openclaw-board-widget-cell")];
cells[0]?.querySelector<HTMLButtonElement>(".board-widget__menu-danger")?.click();
await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(1));
await settleCells(view);
const secondRemove = cells[1]?.querySelector<HTMLButtonElement>(".board-widget__menu-danger");
expect(secondRemove?.disabled).toBe(true);
secondRemove?.click();
expect(applyOps).toHaveBeenCalledTimes(1);
pending.resolve();
await vi.waitFor(() => expect(secondRemove?.disabled).toBe(false));
});
it("ignores a late operation failure after switching sessions", async () => {
const pending = deferred();
const applyOps = vi.fn(() => pending.promise);
const view = await mount({ callbacks: callbacks({ applyOps }) });
view.querySelector<HTMLButtonElement>(".board-widget__menu-danger")?.click();
await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(1));
view.snapshot = { ...snapshot(), sessionKey: "agent:main:new-session" };
await settleCells(view);
pending.reject(new Error("old session failed"));
await Promise.resolve();
await Promise.resolve();
expect(view.querySelector(".board-view__error")).toBeNull();
expect(view.querySelector(".board-announcer")?.textContent?.trim()).toBe("");
});
it("renders pending approval without an iframe and routes both decisions", async () => {
const grant = vi.fn(async () => undefined);
const source = snapshot({
widgets: [boardWidget({ grantState: "pending" })],
});
const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) });
expect(view.querySelector('[data-test-id="board-pending"]')).not.toBeNull();
expect(view.querySelector("iframe")).toBeNull();
const allow = view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-allow"]');
const reject = view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-reject"]');
allow?.click();
await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "granted"));
await vi.waitFor(() => expect(reject?.disabled).toBe(false));
reject?.click();
await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "rejected"));
});
it("serializes pending approval decisions while the callback is in flight", async () => {
let finishGrant: (() => void) | undefined;
const grant = vi.fn(
async () =>
new Promise<void>((resolve) => {
finishGrant = resolve;
}),
);
const source = snapshot({ widgets: [boardWidget({ grantState: "pending" })] });
const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) });
const allow = view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-allow"]');
const reject = view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-reject"]');
allow?.click();
reject?.click();
expect(grant).toHaveBeenCalledTimes(1);
const cell = view.querySelector("openclaw-board-widget-cell");
await cell?.updateComplete;
expect(allow?.disabled).toBe(true);
expect(reject?.disabled).toBe(true);
finishGrant?.();
await vi.waitFor(() => expect(allow?.disabled).toBe(false));
});
it("keeps approval controls after a failed decision and clears the error on refresh", async () => {
const grant = vi.fn(async () => {
throw new Error("approval service unavailable");
});
const source = snapshot({ widgets: [boardWidget({ grantState: "pending" })] });
const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) });
view.querySelector<HTMLButtonElement>('[data-test-id="board-grant-allow"]')?.click();
await vi.waitFor(() => {
expect(
view.querySelector('[data-test-id="board-widget-action-error"]')?.textContent,
).toContain("approval service unavailable");
});
expect(view.querySelector('[data-test-id="board-grant-allow"]')).not.toBeNull();
expect(view.querySelector('[data-test-id="board-grant-reject"]')).not.toBeNull();
view.snapshot = structuredClone({ ...source, revision: source.revision + 1 });
await settleCells(view);
expect(view.querySelector('[data-test-id="board-widget-action-error"]')).toBeNull();
});
it("keeps a rejected widget inert and removable", async () => {
const applyOps = vi.fn(async () => undefined);
const source = snapshot({ widgets: [boardWidget({ grantState: "rejected" })] });
const view = await mount({ snapshot: source, callbacks: callbacks({ applyOps }) });
expect(view.querySelector('[data-test-id="board-rejected"]')).not.toBeNull();
expect(view.querySelector("iframe")).toBeNull();
view.querySelector<HTMLButtonElement>('[data-test-id="board-rejected"] button')?.click();
await vi.waitFor(() =>
expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_remove", name: "alpha" }]),
);
});
it("contains one resolver throw without breaking sibling cells", async () => {
const view = await mount({
widgetFrameUrl: (name) => {
if (name === "alpha") {
throw new Error("fixture resolver failed");
}
return "about:blank";
},
});
const cells = view.querySelectorAll('[data-test-id="board-widget"]');
expect(cells).toHaveLength(2);
expect(view.querySelector('[data-test-id="board-widget-error"]')?.textContent).toContain(
"fixture resolver failed",
);
expect(view.querySelectorAll("iframe")).toHaveLength(1);
});
it("renders the friendly empty state", async () => {
const view = await mount({ snapshot: snapshot({ widgets: [] }) });
expect(view.querySelector('[data-test-id="board-empty"]')?.textContent).toContain(
"Your agent can pin widgets here",
);
});
it("keeps cell focus order stable and exposes move, resize, and menu labels", async () => {
const view = await mount();
const cells = [...view.querySelectorAll<HTMLElement>('[data-test-id="board-widget"]')];
expect(cells.map((cell) => cell.getAttribute("tabindex"))).toEqual(["0", "-1"]);
expect(cells.map((cell) => cell.getAttribute("aria-posinset"))).toEqual(["1", "2"]);
expect(cells.map((cell) => cell.getAttribute("data-widget-name"))).toEqual(["alpha", "beta"]);
for (const cell of cells) {
expect(cell.getAttribute("aria-label")).toContain("Dashboard widget");
expect(cell.querySelector(".board-widget__drag-handle")?.getAttribute("title")).toMatch(
/^Move /u,
);
expect(cell.querySelector(".board-widget__drag-handle")?.getAttribute("tabindex")).toBeNull();
expect(cell.querySelector(".board-widget__resize-handle")?.getAttribute("title")).toMatch(
/^Resize /u,
);
expect(
cell.querySelector(".board-widget__resize-handle")?.getAttribute("tabindex"),
).toBeNull();
expect(cell.querySelector(".board-widget__menu-trigger")?.getAttribute("aria-label")).toBe(
"Widget options",
);
}
cells[0]?.focus();
cells[0]?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
cancelable: true,
}),
);
await vi.waitFor(() => expect(document.activeElement).toBe(cells[1]));
await vi.waitFor(() => expect(cells[1]?.getAttribute("tabindex")).toBe("0"));
});
it("uses Alt+Arrow as the keyboard reorder fallback and announces the move", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount({ callbacks: callbacks({ applyOps }) });
const secondCell = view.querySelectorAll<HTMLElement>('[data-test-id="board-widget"]')[1];
secondCell?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "ArrowLeft",
altKey: true,
bubbles: true,
cancelable: true,
}),
);
await vi.waitFor(() =>
expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_move", name: "beta", position: 0 }]),
);
await vi.waitFor(() =>
expect(view.querySelector(".board-announcer")?.textContent).toContain("Moved Beta chart"),
);
});
it("replaces live-region content for repeated identical announcements", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount({ callbacks: callbacks({ applyOps }) });
const secondCell = view.querySelectorAll<HTMLElement>('[data-test-id="board-widget"]')[1];
const move = () =>
secondCell?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "ArrowLeft",
altKey: true,
bubbles: true,
cancelable: true,
}),
);
move();
await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(1));
await view.updateComplete;
const firstAnnouncement = view.querySelector(".board-announcer > span");
const cell = secondCell?.closest("openclaw-board-widget-cell");
await vi.waitFor(() => expect(Reflect.get(cell ?? {}, "actionPending")).toBe(false));
move();
await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(2));
await view.updateComplete;
expect(view.querySelector(".board-announcer > span")).not.toBe(firstAnnouncement);
});
it("leaves arrow keys from nested widget controls untouched", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount({ callbacks: callbacks({ applyOps }) });
const menuButton = view.querySelector<HTMLButtonElement>(".board-widget__menu-danger");
menuButton?.focus();
menuButton?.dispatchEvent(
new KeyboardEvent("keydown", {
key: "ArrowRight",
altKey: true,
bubbles: true,
cancelable: true,
}),
);
await Promise.resolve();
expect(document.activeElement).toBe(menuButton);
expect(applyOps).not.toHaveBeenCalled();
});
it("moves widgets to another tab from the kebab menu", async () => {
const applyOps = vi.fn(async () => undefined);
const view = await mount({ callbacks: callbacks({ applyOps }) });
const moveButton = [...view.querySelectorAll<HTMLElement>("wa-dropdown-item")].find(
(button) => button.textContent?.trim() === "Operations",
);
view.querySelector(".board-widget__menu")?.dispatchEvent(
new CustomEvent("wa-select", {
detail: { item: moveButton },
bubbles: true,
}),
);
await vi.waitFor(() =>
expect(applyOps).toHaveBeenCalledWith([
{ kind: "widget_move", name: "alpha", tabId: "ops", position: 1 },
]),
);
});
it("places excess tabs in an accessible overflow menu", async () => {
const source = snapshot({
tabs: Array.from({ length: 8 }, (_entry, position) => ({
tabId: `tab-${position}`,
title: `Tab ${position}`,
position,
chatDock: "right" as const,
})),
widgets: [],
});
const selectTab = vi.fn();
const view = await mount({
snapshot: source,
activeTabId: "tab-7",
callbacks: callbacks({ selectTab }),
});
expect(view.querySelector(".board-tabs__overflow-trigger")?.getAttribute("aria-label")).toBe(
"More dashboard tabs",
);
expect(view.querySelectorAll(".board-tabs__overflow > wa-dropdown-item")).toHaveLength(2);
const firstOverflowTab = view.querySelector(".board-tabs__overflow > wa-dropdown-item");
view.querySelector(".board-tabs__overflow")?.dispatchEvent(
new CustomEvent("wa-select", {
detail: { item: firstOverflowTab },
bubbles: true,
}),
);
expect(selectTab).toHaveBeenCalledWith(firstOverflowTab?.getAttribute("value"));
});
it("applies mock moves as insertion and shifts sibling positions", () => {
const moved = applyBoardFixtureOps(snapshot(), [
{ kind: "widget_move", name: "beta", position: 0 },
]);
expect(
moved.widgets
.filter((widget) => widget.tabId === "main")
.toSorted((left, right) => left.position - right.position)
.map((widget) => `${widget.name}:${widget.position}`),
).toEqual(["beta:0", "alpha:1"]);
});
});

View File

@@ -0,0 +1,618 @@
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import { repeat } from "lit/directives/repeat.js";
import { t } from "../../i18n/index.ts";
import {
BOARD_GRID_COLUMNS,
BOARD_GRID_GAP,
BOARD_GRID_ROW_HEIGHT,
layout,
nudge,
previewDrag,
resize,
type BoardGridDirection,
type BoardGridItem,
} from "../../lib/board/grid.ts";
import type {
BoardGrantDecision,
BoardOp,
BoardSnapshot,
BoardTab,
BoardViewCallbacks,
BoardWidget,
BoardWidgetFrameUrl,
} from "../../lib/board/view-types.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import "../../styles/board.css";
import "../web-awesome-tabs.ts";
import "../web-awesome.ts";
import type { BoardWidgetCellCallbacks } from "./board-widget-cell.ts";
import "./board-widget-cell.ts";
type BoardPointerGesture = {
dropValid: boolean;
mode: "move" | "resize";
name: string;
originClientX: number;
originClientY: number;
originW: number;
originH: number;
pointerId: number;
items: BoardGridItem[];
};
function orderedTabs(snapshot: BoardSnapshot): BoardTab[] {
return snapshot.tabs.toSorted(
(left, right) => left.position - right.position || left.tabId.localeCompare(right.tabId),
);
}
function orderedWidgets(snapshot: BoardSnapshot, tabId: string): BoardWidget[] {
return snapshot.widgets
.filter((widget) => widget.tabId === tabId)
.toSorted(
(left, right) => left.position - right.position || left.name.localeCompare(right.name),
);
}
function itemsForWidgets(widgets: readonly BoardWidget[]): BoardGridItem[] {
return widgets.map((widget) => ({
name: widget.name,
w: widget.sizeW,
h: widget.sizeH,
order: widget.position,
}));
}
class OpenClawBoardView extends OpenClawLightDomElement {
@property({ attribute: false }) snapshot?: BoardSnapshot;
@property({ attribute: false }) activeTabId = "";
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
@property({ attribute: false }) callbacks?: BoardViewCallbacks;
@state() private previewItems: BoardGridItem[] | null = null;
@state() private gestureName = "";
@state() private hoverTabId = "";
@state() private announcement = "";
@state() private announcementRevision = 0;
@state() private actionError = "";
@state() private focusName = "";
@state() private mutationPending = false;
private gesture: BoardPointerGesture | null = null;
private mutationRequestId = 0;
private stableCellOrder = new Map<string, number>();
private stableCellOrderSequence = 0;
override willUpdate(changed: PropertyValues<this>): void {
if (changed.has("snapshot")) {
this.actionError = "";
const previousSnapshot = changed.get("snapshot");
if (previousSnapshot?.sessionKey !== this.snapshot?.sessionKey) {
this.mutationRequestId += 1;
this.mutationPending = false;
this.focusName = "";
this.stableCellOrder.clear();
this.stableCellOrderSequence = 0;
}
}
if (changed.has("activeTabId")) {
this.focusName = "";
}
if (this.gesture && (changed.has("snapshot") || changed.has("activeTabId"))) {
this.cancelGesture();
}
}
override disconnectedCallback(): void {
this.cancelGesture();
super.disconnectedCallback();
}
private activeTab(tabs: readonly BoardTab[]): BoardTab | undefined {
return tabs.find((tab) => tab.tabId === this.activeTabId) ?? tabs[0];
}
private announce(message: string): void {
this.announcement = message;
this.announcementRevision += 1;
}
private async applyOps(ops: BoardOp[], announcement: string): Promise<void> {
if (!this.callbacks) {
return;
}
if (this.mutationPending) {
throw new Error(t("board.actionInProgress"));
}
const sessionKey = this.snapshot?.sessionKey;
const requestId = this.mutationRequestId + 1;
this.mutationRequestId = requestId;
this.mutationPending = true;
this.actionError = "";
try {
await this.callbacks.applyOps(ops);
if (requestId === this.mutationRequestId && sessionKey === this.snapshot?.sessionKey) {
this.announce(announcement);
}
} catch (error) {
if (requestId === this.mutationRequestId && sessionKey === this.snapshot?.sessionKey) {
this.actionError = t("board.actionFailed");
this.announce(this.actionError);
}
throw error;
} finally {
if (requestId === this.mutationRequestId) {
this.mutationPending = false;
}
}
}
private nextPosition(tabId: string): number {
const positions = this.snapshot?.widgets
.filter((widget) => widget.tabId === tabId)
.map((widget) => widget.position) ?? [0];
return Math.max(-1, ...positions) + 1;
}
private readonly cellCallbacks: BoardWidgetCellCallbacks = {
grant: async (name: string, decision: BoardGrantDecision) => {
if (!this.callbacks) {
return;
}
const sessionKey = this.snapshot?.sessionKey;
await this.callbacks.grant(name, decision);
if (sessionKey === this.snapshot?.sessionKey) {
this.announce(
decision === "granted"
? t("board.announcement.granted")
: t("board.announcement.rejected"),
);
}
},
movePointerDown: (widget, event) => this.beginGesture("move", widget, event),
resizePointerDown: (widget, event) => this.beginGesture("resize", widget, event),
moveToTab: async (widget, tabId) => {
await this.applyOps(
[
{
kind: "widget_move",
name: widget.name,
tabId,
position: this.nextPosition(tabId),
},
],
t("board.announcement.moved", { title: widget.title || widget.name }),
);
},
resizeTo: async (widget, w, h) => {
await this.applyOps(
[{ kind: "widget_resize", name: widget.name, sizeW: w, sizeH: h }],
t("board.announcement.resized", { title: widget.title || widget.name }),
);
},
remove: async (widget) => {
await this.applyOps(
[{ kind: "widget_remove", name: widget.name }],
t("board.announcement.removed", { title: widget.title || widget.name }),
);
},
nudge: async (widget, direction) => this.nudgeWidget(widget, direction),
focus: (widget, direction) => this.focusWidget(widget, direction),
focusChanged: (name) => {
this.focusName = name;
},
};
private beginGesture(
mode: BoardPointerGesture["mode"],
widget: BoardWidget,
event: PointerEvent,
): void {
if (event.button !== 0 || this.gesture || this.mutationPending) {
return;
}
const snapshot = this.snapshot;
const tabs = snapshot ? orderedTabs(snapshot) : [];
const tab = this.activeTab(tabs);
if (!snapshot || !tab) {
return;
}
event.preventDefault();
event.stopPropagation();
try {
(event.currentTarget as HTMLElement | null)?.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic pointers and detached test targets cannot be captured.
}
const items = itemsForWidgets(orderedWidgets(snapshot, tab.tabId));
this.gesture = {
dropValid: false,
mode,
name: widget.name,
originClientX: event.clientX,
originClientY: event.clientY,
originW: widget.sizeW,
originH: widget.sizeH,
pointerId: event.pointerId,
items,
};
this.previewItems = items;
this.gestureName = widget.name;
window.addEventListener("pointermove", this.handlePointerMove);
window.addEventListener("pointerup", this.handlePointerUp);
window.addEventListener("pointercancel", this.handlePointerCancel);
}
private readonly handlePointerMove = (event: PointerEvent): void => {
const gesture = this.gesture;
if (!gesture || event.pointerId !== gesture.pointerId) {
return;
}
if (gesture.mode === "move") {
const tabTarget = document
.elementFromPoint(event.clientX, event.clientY)
?.closest<HTMLElement>("[data-board-tab-id]");
const candidateTabId =
tabTarget?.closest("openclaw-board-view") === this
? (tabTarget.dataset.boardTabId ?? "")
: "";
const candidateIsValid =
candidateTabId !== "" &&
(this.snapshot?.tabs.some((tab) => tab.tabId === candidateTabId) ?? false);
const currentTabId = this.snapshot
? this.activeTab(orderedTabs(this.snapshot))?.tabId
: this.activeTabId;
this.hoverTabId = candidateIsValid && candidateTabId !== currentTabId ? candidateTabId : "";
if (tabTarget) {
this.previewItems = gesture.items;
gesture.dropValid = this.hoverTabId !== "";
return;
}
const grid = this.querySelector<HTMLElement>(".board-grid");
const pointerElement = document.elementFromPoint(event.clientX, event.clientY);
if (!grid || pointerElement?.closest(".board-grid") !== grid) {
this.hoverTabId = "";
this.previewItems = gesture.items;
gesture.dropValid = false;
return;
}
gesture.dropValid = true;
const bounds = grid.getBoundingClientRect();
const columnWidth = Math.max(
1,
(bounds.width - BOARD_GRID_GAP * (BOARD_GRID_COLUMNS - 1)) / BOARD_GRID_COLUMNS,
);
const targetCell = {
x: Math.floor((event.clientX - bounds.left) / (columnWidth + BOARD_GRID_GAP)),
y: Math.floor((event.clientY - bounds.top) / (BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP)),
};
this.previewItems = previewDrag(gesture.items, gesture.name, targetCell).items;
return;
}
const grid = this.querySelector<HTMLElement>(".board-grid");
const bounds = grid?.getBoundingClientRect();
const columnWidth = bounds
? Math.max(1, (bounds.width - BOARD_GRID_GAP * (BOARD_GRID_COLUMNS - 1)) / BOARD_GRID_COLUMNS)
: BOARD_GRID_ROW_HEIGHT;
const deltaW = Math.round(
(event.clientX - gesture.originClientX) / (columnWidth + BOARD_GRID_GAP),
);
const deltaH = Math.round(
(event.clientY - gesture.originClientY) / (BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP),
);
this.previewItems = resize(
gesture.items,
gesture.name,
gesture.originW + deltaW,
gesture.originH + deltaH,
);
};
private readonly handlePointerUp = (event: PointerEvent): void => {
const gesture = this.gesture;
if (!gesture || event.pointerId !== gesture.pointerId) {
return;
}
this.handlePointerMove(event);
const previewItems = this.previewItems;
const hoverTabId = this.hoverTabId;
this.cancelGesture();
const widget = this.snapshot?.widgets.find((entry) => entry.name === gesture.name);
if (!widget) {
return;
}
if (gesture.mode === "move") {
if (!gesture.dropValid) {
return;
}
const position = hoverTabId
? this.nextPosition(hoverTabId)
: (previewItems?.find((item) => item.name === gesture.name)?.order ?? widget.position);
if (!hoverTabId && position === widget.position) {
return;
}
void this.applyOps(
[
{
kind: "widget_move",
name: gesture.name,
...(hoverTabId ? { tabId: hoverTabId } : {}),
position,
},
],
t("board.announcement.moved", { title: widget.title || widget.name }),
).catch(() => undefined);
return;
}
const resized = previewItems?.find((item) => item.name === gesture.name);
if (resized && (resized.w !== widget.sizeW || resized.h !== widget.sizeH)) {
void this.applyOps(
[
{
kind: "widget_resize",
name: gesture.name,
sizeW: resized.w,
sizeH: resized.h,
},
],
t("board.announcement.resized", { title: widget.title || widget.name }),
).catch(() => undefined);
}
};
private readonly handlePointerCancel = (event: PointerEvent): void => {
if (this.gesture && event.pointerId === this.gesture.pointerId) {
this.cancelGesture();
}
};
private cancelGesture(): void {
window.removeEventListener("pointermove", this.handlePointerMove);
window.removeEventListener("pointerup", this.handlePointerUp);
window.removeEventListener("pointercancel", this.handlePointerCancel);
this.gesture = null;
this.previewItems = null;
this.gestureName = "";
this.hoverTabId = "";
}
private async nudgeWidget(widget: BoardWidget, direction: BoardGridDirection): Promise<void> {
const snapshot = this.snapshot;
if (!snapshot) {
return;
}
const items = itemsForWidgets(orderedWidgets(snapshot, widget.tabId));
const moved = nudge(items, widget.name, direction).find((item) => item.name === widget.name);
if (!moved || moved.order === widget.position) {
return;
}
await this.applyOps(
[{ kind: "widget_move", name: widget.name, position: moved.order }],
t("board.announcement.moved", { title: widget.title || widget.name }),
);
}
private focusWidget(widget: BoardWidget, direction: BoardGridDirection): void {
const snapshot = this.snapshot;
if (!snapshot) {
return;
}
const widgets = orderedWidgets(snapshot, widget.tabId);
const index = widgets.findIndex((entry) => entry.name === widget.name);
if (index < 0) {
return;
}
const offset = direction === "left" || direction === "up" ? -1 : 1;
const target = widgets[Math.max(0, Math.min(index + offset, widgets.length - 1))];
if (!target || target.name === widget.name) {
return;
}
this.focusName = target.name;
void this.updateComplete.then(() => {
const cell = [...this.querySelectorAll("openclaw-board-widget-cell")].find(
(entry) => entry.widget?.name === target.name,
);
cell?.querySelector<HTMLElement>(".board-widget")?.focus();
});
}
private readonly handleTabShow = (event: CustomEvent<{ name: string }>): void => {
const tabs = this.snapshot ? orderedTabs(this.snapshot) : [];
const currentTabId = this.activeTab(tabs)?.tabId ?? this.activeTabId;
if (event.detail.name !== currentTabId && tabs.some((tab) => tab.tabId === event.detail.name)) {
this.callbacks?.selectTab(event.detail.name);
}
};
private readonly handleOverflowSelect = (
event: CustomEvent<{ item: { value?: string } }>,
): void => {
const tabId = event.detail.item.value;
if (tabId && this.snapshot?.tabs.some((tab) => tab.tabId === tabId)) {
this.callbacks?.selectTab(tabId);
}
};
private renderTab(tab: BoardTab, activeTabId: string): TemplateResult {
const active = tab.tabId === activeTabId;
const dropTarget = tab.tabId === this.hoverTabId;
return html`
<wa-tab
class=${`board-tabs__tab ${active ? "board-tabs__tab--active" : ""} ${dropTarget ? "board-tabs__tab--drop" : ""}`}
panel=${tab.tabId}
?active=${active}
data-board-tab-id=${tab.tabId}
>
${tab.title}
</wa-tab>
`;
}
private renderOverflowTab(tab: BoardTab): TemplateResult {
return html`
<wa-dropdown-item
class="board-tabs__overflow-item"
value=${tab.tabId}
data-board-tab-id=${tab.tabId}
>
${tab.title}
</wa-dropdown-item>
`;
}
private renderTabs(
tabs: readonly BoardTab[],
activeTabId: string,
): TemplateResult | typeof nothing {
if (tabs.length <= 1) {
return nothing;
}
const visible = tabs.slice(0, 6);
const active = tabs.find((tab) => tab.tabId === activeTabId);
if (active && !visible.some((tab) => tab.tabId === active.tabId)) {
visible[visible.length - 1] = active;
}
const visibleIds = new Set(visible.map((tab) => tab.tabId));
const overflow = tabs.filter((tab) => !visibleIds.has(tab.tabId));
return html`
<nav class="board-tabs" aria-label=${t("board.tabsLabel")}>
<wa-tab-group
class="board-tabs__track"
.active=${activeTabId}
activation="manual"
without-scroll-controls
@wa-tab-show=${this.handleTabShow}
>
${visible.map((tab) => this.renderTab(tab, activeTabId))}
</wa-tab-group>
${overflow.length > 0
? html`
<wa-dropdown
class="board-tabs__overflow"
placement="bottom-end"
@wa-select=${this.handleOverflowSelect}
>
<button
class="board-tabs__overflow-trigger"
slot="trigger"
type="button"
aria-label=${t("board.moreTabs")}
title=${t("board.moreTabs")}
>
•••
</button>
${overflow.map((tab) => this.renderOverflowTab(tab))}
</wa-dropdown>
`
: nothing}
</nav>
`;
}
private renderGrid(
widgets: readonly BoardWidget[],
tabs: readonly BoardTab[],
sessionKey: string,
): TemplateResult {
if (widgets.length === 0) {
return html`
<div class="board-empty" data-test-id="board-empty">
<span class="board-empty__mark" aria-hidden="true"></span>
<strong>${t("board.emptyTitle")}</strong>
<span>${t("board.emptyHint")}</span>
</div>
`;
}
const items = this.previewItems ?? itemsForWidgets(widgets);
const rects = layout(items);
for (const rect of rects) {
if (!this.stableCellOrder.has(rect.name)) {
this.stableCellOrder.set(rect.name, this.stableCellOrderSequence);
this.stableCellOrderSequence += 1;
}
}
const stableRects = rects.toSorted(
(left, right) =>
(this.stableCellOrder.get(left.name) ?? 0) - (this.stableCellOrder.get(right.name) ?? 0) ||
left.name.localeCompare(right.name),
);
const logicalPosition = new Map(rects.map((rect, index) => [rect.name, index]));
const focusName = rects.some((rect) => rect.name === this.focusName)
? this.focusName
: (rects[0]?.name ?? "");
const widgetByName = new Map(widgets.map((widget) => [widget.name, widget]));
return html`
<div class="board-grid" role="list" aria-label=${t("board.gridLabel")}>
${repeat(
stableRects,
(rect) => `${sessionKey}\u0000${rect.name}`,
(rect) => {
const widget = widgetByName.get(rect.name);
if (!widget) {
return nothing;
}
return html`
<openclaw-board-widget-cell
.widget=${widget}
.rect=${rect}
.tabs=${tabs}
.widgetFrameUrl=${this.widgetFrameUrl}
.callbacks=${this.cellCallbacks}
.dragging=${widget.name === this.gestureName}
.focusTabIndex=${widget.name === focusName ? 0 : -1}
.positionInSet=${(logicalPosition.get(widget.name) ?? 0) + 1}
.setSize=${rects.length}
.busy=${this.mutationPending}
></openclaw-board-widget-cell>
`;
},
)}
${this.gesture?.mode === "move"
? html`<div class="board-grid__append-zone" aria-hidden="true"></div>`
: nothing}
</div>
`;
}
override render() {
const snapshot = this.snapshot;
if (!snapshot) {
return nothing;
}
const tabs = orderedTabs(snapshot);
const activeTab = this.activeTab(tabs);
const activeTabId = activeTab?.tabId ?? this.activeTabId;
const widgets = activeTab ? orderedWidgets(snapshot, activeTab.tabId) : [];
return html`
<section class="board-view" aria-label=${t("board.label")}>
${this.renderTabs(tabs, activeTabId)} ${this.renderGrid(widgets, tabs, snapshot.sessionKey)}
${this.actionError
? html`<div class="board-view__error" role="alert">${this.actionError}</div>`
: nothing}
<div class="board-announcer" aria-live="polite" aria-atomic="true">
${this.announcement
? keyed(
this.announcementRevision,
html`<span data-announcement-revision=${this.announcementRevision}
>${this.announcement}</span
>`,
)
: nothing}
</div>
</section>
`;
}
}
if (!customElements.get("openclaw-board-view")) {
customElements.define("openclaw-board-view", OpenClawBoardView);
}
declare global {
interface HTMLElementTagNameMap {
"openclaw-board-view": OpenClawBoardView;
}
}

View File

@@ -0,0 +1,374 @@
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
import { t } from "../../i18n/index.ts";
import type { BoardGridDirection, BoardGridRect } from "../../lib/board/grid.ts";
import { toCssPlacement } from "../../lib/board/grid.ts";
import type {
BoardGrantDecision,
BoardTab,
BoardWidget,
BoardWidgetFrameUrl,
} from "../../lib/board/view-types.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import "../web-awesome.ts";
const BOARD_SIZE_PRESETS = {
sm: { w: 3, h: 3 },
md: { w: 6, h: 4 },
lg: { w: 8, h: 6 },
xl: { w: 12, h: 8 },
} as const;
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;
focusChanged: (name: string) => void;
};
class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@property({ attribute: false }) widget?: BoardWidget;
@property({ attribute: false }) rect?: BoardGridRect;
@property({ attribute: false }) tabs: readonly BoardTab[] = [];
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
@property({ attribute: false }) callbacks?: BoardWidgetCellCallbacks;
@property({ type: Boolean }) dragging = false;
@property({ type: Number }) focusTabIndex = -1;
@property({ type: Number }) positionInSet = 1;
@property({ type: Number }) setSize = 1;
@property({ type: Boolean }) busy = false;
@state() private actionError = "";
@state() private actionPending = false;
override willUpdate(changed: PropertyValues<this>): void {
const previousWidget = changed.get("widget");
if (previousWidget && previousWidget !== this.widget) {
this.actionError = "";
}
}
private closeMenu(): void {
const menu = this.querySelector<HTMLElement & { open: boolean }>(".board-widget__menu");
if (menu) {
menu.open = false;
}
}
private async runAction(action: () => Promise<void>): Promise<void> {
if (this.actionPending || this.busy) {
return;
}
this.actionPending = true;
this.actionError = "";
this.closeMenu();
try {
await action();
} catch (error) {
this.actionError = error instanceof Error ? error.message : String(error);
} finally {
this.actionPending = false;
}
}
private handleMenuSelect(
event: CustomEvent<{ item: { value?: string } }>,
widget: BoardWidget,
callbacks: BoardWidgetCellCallbacks,
): void {
const value = event.detail.item.value;
if (value === "remove") {
void this.runAction(() => callbacks.remove(widget));
return;
}
if (value?.startsWith("move:")) {
void this.runAction(() => callbacks.moveToTab(widget, value.slice("move:".length)));
return;
}
if (value?.startsWith("resize:")) {
const preset = value.slice("resize:".length) as keyof typeof BOARD_SIZE_PRESETS;
const size = BOARD_SIZE_PRESETS[preset];
if (size) {
void this.runAction(() => callbacks.resizeTo(widget, size.w, size.h));
}
}
}
private renderMenu(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
const otherTabs = this.tabs.filter((tab) => tab.tabId !== widget.tabId);
return html`
<wa-dropdown
class="board-widget__menu"
placement="bottom-end"
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) =>
this.handleMenuSelect(event, widget, callbacks)}
>
<button
class="board-widget__menu-trigger"
slot="trigger"
type="button"
aria-label=${t("board.widget.menuLabel")}
title=${t("board.widget.menuLabel")}
>
</button>
<div class="board-widget__menu-heading">${t("board.widget.moveToTab")}</div>
${otherTabs.length > 0
? otherTabs.map(
(tab) => html`
<wa-dropdown-item
value=${`move:${tab.tabId}`}
?disabled=${this.busy || this.actionPending}
>
${tab.title}
</wa-dropdown-item>
`,
)
: html`<span class="board-widget__menu-empty">${t("board.widget.noOtherTabs")}</span>`}
<div class="board-widget__menu-heading">${t("board.widget.resize")}</div>
${Object.entries(BOARD_SIZE_PRESETS).map(
([label, size]) => html`
<wa-dropdown-item
class="board-widget__preset"
value=${`resize:${label}`}
?disabled=${this.busy || this.actionPending}
>
${label.toUpperCase()}
<span slot="details">${size.w}×${size.h}</span>
</wa-dropdown-item>
`,
)}
<div class="board-widget__menu-separator" role="separator"></div>
<wa-dropdown-item
class="board-widget__menu-danger"
value="remove"
?disabled=${this.busy || this.actionPending}
>
${t("board.widget.remove")}
</wa-dropdown-item>
</wa-dropdown>
`;
}
private renderPending(widget: BoardWidget, 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>
<div class="board-widget__grant-actions">
<button
class="btn btn--small btn--primary"
type="button"
data-test-id="board-grant-allow"
?disabled=${this.busy || this.actionPending}
@click=${() => void this.runAction(() => callbacks.grant(widget.name, "granted"))}
>
${t("board.widget.allow")}
</button>
<button
class="btn btn--small"
type="button"
data-test-id="board-grant-reject"
?disabled=${this.busy || this.actionPending}
@click=${() => void this.runAction(() => callbacks.grant(widget.name, "rejected"))}
>
${t("board.widget.reject")}
</button>
</div>
${this.actionError ? this.renderActionError(this.actionError, true) : nothing}
</div>
`;
}
private renderRejected(widget: BoardWidget, 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>
<span>${t("board.widget.rejectedDetail")}</span>
<button
class="btn btn--small"
type="button"
?disabled=${this.busy || this.actionPending}
@click=${() => void this.runAction(() => callbacks.remove(widget))}
>
${t("board.widget.remove")}
</button>
</div>
`;
}
private renderFrame(widget: BoardWidget): TemplateResult {
if (!this.widgetFrameUrl) {
throw new Error(t("board.widget.frameResolverMissing"));
}
const src = this.widgetFrameUrl(widget.name, widget.revision);
return html`
<iframe
class="board-widget__frame"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
title=${widget.title || widget.name}
src=${src}
></iframe>
`;
}
private renderBody(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
if (widget.grantState === "pending") {
return this.renderPending(widget, callbacks);
}
if (widget.grantState === "rejected") {
return this.renderRejected(widget, callbacks);
}
return this.renderFrame(widget);
}
private renderError(error: unknown): TemplateResult {
const message = error instanceof Error ? error.message : String(error);
return html`
<div class="board-widget__error" role="alert" data-test-id="board-widget-error">
<strong>${t("board.widget.errorTitle")}</strong>
<span>${t("board.widget.errorDetail")}</span>
<details>
<summary>${t("board.widget.errorShow")}</summary>
<code>${message}</code>
</details>
</div>
`;
}
private renderActionError(error: string, inline = false): TemplateResult {
return html`
<div
class=${`board-widget__error ${inline ? "board-widget__error--inline" : ""}`}
role="alert"
data-test-id="board-widget-action-error"
>
<strong>${t("board.widget.actionErrorTitle")}</strong>
<span>${t("board.widget.actionErrorDetail")}</span>
<details>
<summary>${t("board.widget.errorShow")}</summary>
<code>${error}</code>
</details>
</div>
`;
}
private handleKeyDown(
event: KeyboardEvent,
widget: BoardWidget,
callbacks: BoardWidgetCellCallbacks,
): void {
if (event.target !== event.currentTarget) {
return;
}
const direction =
event.key === "ArrowLeft"
? "left"
: event.key === "ArrowRight"
? "right"
: event.key === "ArrowUp"
? "up"
: event.key === "ArrowDown"
? "down"
: null;
if (!direction) {
return;
}
event.preventDefault();
if (event.altKey) {
void this.runAction(() => callbacks.nudge(widget, direction));
} else {
callbacks.focus(widget, direction);
}
}
override render() {
const widget = this.widget;
const rect = this.rect;
const callbacks = this.callbacks;
if (!widget || !rect || !callbacks) {
return nothing;
}
let body: TemplateResult;
let bodyErrored = false;
try {
body = this.renderBody(widget, callbacks);
} catch (error) {
body = this.renderError(error);
bodyErrored = true;
}
const label = widget.title || widget.name;
const bodyScrollable =
bodyErrored ||
this.actionError !== "" ||
widget.grantState === "pending" ||
widget.grantState === "rejected";
return html`
<section
class=${`board-widget ${this.dragging ? "board-widget--dragging" : ""}`}
style=${toCssPlacement(rect)}
role="listitem"
tabindex=${this.focusTabIndex}
aria-posinset=${this.positionInSet}
aria-setsize=${this.setSize}
aria-label=${t("board.widget.cellLabel", { title: label })}
data-widget-name=${widget.name}
data-test-id="board-widget"
@focus=${() => callbacks.focusChanged(widget.name)}
@keydown=${(event: KeyboardEvent) => this.handleKeyDown(event, widget, callbacks)}
>
<header class="board-widget__bar">
<span
class="board-widget__drag-handle"
aria-hidden="true"
title=${t("board.widget.moveHandle", { title: label })}
@pointerdown=${(event: PointerEvent) => callbacks.movePointerDown(widget, event)}
>
<span aria-hidden="true">⠿</span>
</span>
<span class="board-widget__title" title=${label}>${label}</span>
<span class="board-widget__kind"
>${widget.contentKind === "mcp-app"
? t("board.widget.kindMcp")
: t("board.widget.kindHtml")}</span
>
${this.renderMenu(widget, callbacks)}
</header>
<div
class=${`board-widget__body ${bodyScrollable ? "board-widget__body--scrollable" : ""}`}
>
${body}
${this.actionError && widget.grantState !== "pending"
? html`<div class="board-widget__error-overlay">
${this.renderActionError(this.actionError)}
</div>`
: nothing}
</div>
<span
class="board-widget__resize-handle"
aria-hidden="true"
title=${t("board.widget.resizeHandle", { title: label })}
@pointerdown=${(event: PointerEvent) => callbacks.resizePointerDown(widget, event)}
></span>
</section>
`;
}
}
if (!customElements.get("openclaw-board-widget-cell")) {
customElements.define("openclaw-board-widget-cell", OpenClawBoardWidgetCell);
}
declare global {
interface HTMLElementTagNameMap {
"openclaw-board-widget-cell": OpenClawBoardWidgetCell;
}
}

View File

@@ -2472,6 +2472,48 @@ export const en: TranslationMap = {
submit: "Ask",
},
},
board: {
label: "Session dashboard",
tabsLabel: "Dashboard tabs",
moreTabs: "More dashboard tabs",
gridLabel: "Dashboard widgets",
emptyTitle: "A clear board, ready for work",
emptyHint: "Your agent can pin widgets here — try asking for a status card.",
actionFailed: "The dashboard change could not be saved.",
actionInProgress: "Another dashboard change is still being saved.",
announcement: {
moved: "Moved {title}.",
resized: "Resized {title}.",
removed: "Removed {title}.",
granted: "Widget access allowed.",
rejected: "Widget access rejected.",
},
widget: {
cellLabel:
"Dashboard widget: {title}. Use arrow keys to navigate. Hold Alt and press an arrow key to move it.",
moveHandle: "Move {title}",
resizeHandle: "Resize {title}",
menuLabel: "Widget options",
moveToTab: "Move to tab",
noOtherTabs: "No other tabs",
resize: "Resize",
remove: "Remove",
needsApproval: "Needs approval",
needsApprovalDetail: "This widget requested additional access.",
allow: "Allow",
reject: "Reject",
rejected: "Access rejected",
rejectedDetail: "This widget stays inactive until it is removed or replaced.",
frameResolverMissing: "Widget content is unavailable.",
errorTitle: "This widget could not load",
errorDetail: "The problem is contained to this card.",
actionErrorTitle: "Widget change failed",
actionErrorDetail: "The dashboard kept the previous widget state.",
errorShow: "Show details",
kindMcp: "MCP",
kindHtml: "HTML",
},
},
workboard: {
disabledHelpStart: "Workboard is disabled. Enable",
enableConfigKey: "plugins.entries.workboard.enabled = true",

View File

@@ -0,0 +1,250 @@
import { describe, expect, it } from "vitest";
import {
BOARD_GRID_COLUMNS,
BOARD_GRID_GAP,
BOARD_GRID_ROW_HEIGHT,
layout,
nudge,
previewDrag,
resize,
toCssPlacement,
type BoardGridItem,
type BoardGridRect,
} from "./grid.ts";
function item(name: string, w: number, h: number, order: number): BoardGridItem {
return { name, w, h, order };
}
function overlaps(left: BoardGridRect, right: BoardGridRect): boolean {
return (
left.x < right.x + right.w &&
right.x < left.x + left.w &&
left.y < right.y + right.h &&
right.y < left.y + left.h
);
}
function expectValid(rects: readonly BoardGridRect[]): void {
for (const [index, rect] of rects.entries()) {
expect(rect.x).toBeGreaterThanOrEqual(0);
expect(rect.x + rect.w).toBeLessThanOrEqual(BOARD_GRID_COLUMNS);
expect(rect.y).toBeGreaterThanOrEqual(0);
expect(rect.w).toBeGreaterThanOrEqual(1);
expect(rect.h).toBeGreaterThanOrEqual(1);
for (const other of rects.slice(index + 1)) {
expect(overlaps(rect, other), `${rect.name} overlaps ${other.name}`).toBe(false);
}
}
}
function expectGravityTight(rects: readonly BoardGridRect[]): void {
for (const rect of rects) {
if (rect.y === 0) {
continue;
}
const raised = { ...rect, y: rect.y - 1 };
expect(
rects.some((other) => other.name !== rect.name && overlaps(raised, other)),
`${rect.name} leaves a hole above itself`,
).toBe(true);
}
}
function propertyItems(seed: number, count: number): BoardGridItem[] {
let value = seed;
const random = () => {
value = (value * 1_664_525 + 1_013_904_223) >>> 0;
return value;
};
return Array.from({ length: count }, (_entry, order) =>
item(
`widget-${String(order).padStart(2, "0")}`,
(random() % 18) - 2,
(random() % 28) - 3,
order,
),
);
}
describe("board grid layout", () => {
it("exports the shared geometry constants", () => {
expect({
columns: BOARD_GRID_COLUMNS,
rowHeight: BOARD_GRID_ROW_HEIGHT,
gap: BOARD_GRID_GAP,
}).toEqual({ columns: 12, rowHeight: 56, gap: 12 });
});
it("flows first-fit from left to right and then downward", () => {
expect(layout([item("a", 6, 2, 0), item("b", 6, 1, 1), item("c", 3, 1, 2)])).toEqual([
{ name: "a", x: 0, y: 0, w: 6, h: 2 },
{ name: "b", x: 6, y: 0, w: 6, h: 1 },
{ name: "c", x: 6, y: 1, w: 3, h: 1 },
]);
});
it("is deterministic by explicit order even when input order changes", () => {
const items = [item("third", 3, 1, 30), item("first", 4, 2, 10), item("second", 8, 1, 20)];
expect(layout(items)).toEqual(layout(items.toReversed()));
expect(layout(items).map((rect) => rect.name)).toEqual(["first", "second", "third"]);
});
it("breaks duplicate order ties by name", () => {
expect(layout([item("z", 2, 1, 0), item("a", 2, 1, 0)]).map((rect) => rect.name)).toEqual([
"a",
"z",
]);
});
it("never overlaps, escapes the columns, or leaves vertical holes across generated cases", () => {
for (let seed = 1; seed <= 40; seed += 1) {
const rects = layout(propertyItems(seed, 35));
expectValid(rects);
expectGravityTight(rects);
}
});
it("clamps invalid dimensions without mutating source items", () => {
const items = [item("wide", 99, 0, 0), item("tall", -3, 99, 1)];
const before = structuredClone(items);
const rects = layout(items);
expect(rects).toEqual([
{ name: "wide", x: 0, y: 0, w: 12, h: 1 },
{ name: "tall", x: 0, y: 1, w: 1, h: 20 },
]);
expect(items).toEqual(before);
});
});
describe("board grid drag preview", () => {
const items = [item("a", 4, 2, 0), item("b", 4, 2, 1), item("c", 4, 2, 2)];
it("pushes an occupied target and its followers aside", () => {
const preview = previewDrag(items, "c", { x: 1, y: 0 });
expect(preview.items.map((entry) => [entry.name, entry.order])).toEqual([
["c", 0],
["a", 1],
["b", 2],
]);
expect(preview.rects.map((rect) => rect.name)).toEqual(["c", "a", "b"]);
expectValid(preview.rects);
});
it("keeps the order while the pointer remains inside the dragged rect", () => {
expect(previewDrag(items, "b", { x: 6, y: 1 }).items.map((entry) => entry.name)).toEqual([
"a",
"b",
"c",
]);
});
it("resolves occupied targets before removing and compacting the moving item", () => {
const preview = previewDrag([item("a", 6, 1, 0), item("b", 6, 1, 1), item("c", 3, 1, 2)], "a", {
x: 6,
y: 0,
});
expect(preview.items.map((entry) => entry.name)).toEqual(["a", "b", "c"]);
});
it("chooses the next empty-cell target by rendered row-major geometry", () => {
const heterogeneous = [
item("a", 1, 4, 0),
item("b", 3, 2, 1),
item("c", 5, 4, 2),
item("d", 7, 2, 3),
item("e", 1, 4, 4),
item("f", 3, 2, 5),
item("g", 9, 4, 6),
item("h", 11, 2, 7),
];
expect(
previewDrag(heterogeneous, "a", { x: 10, y: 0 }).items.map((entry) => entry.name),
).toEqual(["b", "c", "d", "e", "a", "f", "g", "h"]);
});
it("appends from an empty row tail or below the board", () => {
const tailItems = [item("a", 3, 2, 0), item("b", 3, 2, 1), item("c", 3, 2, 2)];
expect(previewDrag(tailItems, "a", { x: 10, y: 0 }).items.map((entry) => entry.name)).toEqual([
"b",
"c",
"a",
]);
expect(previewDrag(items, "a", { x: 100, y: 100 }).items.map((entry) => entry.name)).toEqual([
"b",
"c",
"a",
]);
});
it("is deterministic and leaves inputs unchanged", () => {
const before = structuredClone(items);
expect(previewDrag(items, "c", { x: 0, y: 0 })).toEqual(
previewDrag(items, "c", { x: 0, y: 0 }),
);
expect(items).toEqual(before);
});
it("preserves compaction invariants across generated drag targets", () => {
for (let seed = 1; seed <= 24; seed += 1) {
const generated = propertyItems(seed, 24);
const moving = generated[(seed * 7) % generated.length]!;
const preview = previewDrag(generated, moving.name, {
x: (seed * 5) % BOARD_GRID_COLUMNS,
y: (seed * 3) % 18,
});
expectValid(preview.rects);
expectGravityTight(preview.rects);
expect(preview.items.map((entry) => entry.order)).toEqual(
Array.from({ length: generated.length }, (_entry, order) => order),
);
}
});
it("returns a compact canonical board for an unknown item", () => {
const preview = previewDrag([item("b", 3, 1, 5), item("a", 3, 1, 2)], "missing", {
x: 0,
y: 0,
});
expect(preview.items.map((entry) => [entry.name, entry.order])).toEqual([
["a", 0],
["b", 1],
]);
expectValid(preview.rects);
});
});
describe("board grid mutations", () => {
it("resizes immutably and clamps both axes", () => {
const items = [item("a", 3, 3, 0), item("b", 4, 4, 1)];
expect(resize(items, "a", 40, -2)).toEqual([item("a", 12, 1, 0), item("b", 4, 4, 1)]);
expect(items[0]).toEqual(item("a", 3, 3, 0));
});
it.each([
["left", ["b", "a", "c"]],
["up", ["b", "a", "c"]],
["right", ["a", "c", "b"]],
["down", ["a", "c", "b"]],
] as const)("nudges order %s", (direction, expected) => {
const result = nudge(
[item("a", 1, 1, 0), item("b", 1, 1, 1), item("c", 1, 1, 2)],
"b",
direction,
);
expect(result.map((entry) => entry.name)).toEqual(expected);
expect(result.map((entry) => entry.order)).toEqual([0, 1, 2]);
});
it("does not nudge beyond either order boundary", () => {
const items = [item("a", 1, 1, 0), item("b", 1, 1, 1)];
expect(nudge(items, "a", "up").map((entry) => entry.name)).toEqual(["a", "b"]);
expect(nudge(items, "b", "right").map((entry) => entry.name)).toEqual(["a", "b"]);
});
it("serializes one-based CSS grid placement", () => {
expect(toCssPlacement({ name: "chart", x: 2, y: 4, w: 5, h: 3 })).toBe(
"grid-column: 3 / span 5; grid-row: 5 / span 3;",
);
});
});

199
ui/src/lib/board/grid.ts Normal file
View File

@@ -0,0 +1,199 @@
/** Pure order-and-size layout for the session dashboard board. */
export const BOARD_GRID_COLUMNS = 12;
export const BOARD_GRID_ROW_HEIGHT = 56;
export const BOARD_GRID_GAP = 12;
const BOARD_GRID_MAX_HEIGHT = 20;
export type BoardGridItem = {
name: string;
w: number;
h: number;
order: number;
};
export type BoardGridRect = {
name: string;
x: number;
y: number;
w: number;
h: number;
};
type BoardGridCell = {
x: number;
y: number;
};
export type BoardGridDirection = "left" | "right" | "up" | "down";
type BoardGridPreview = {
items: BoardGridItem[];
rects: BoardGridRect[];
};
function clampInteger(value: number, minimum: number, maximum: number): number {
const integer = Number.isFinite(value) ? Math.round(value) : minimum;
return Math.min(maximum, Math.max(minimum, integer));
}
function withOrder(item: BoardGridItem, order: number): BoardGridItem {
return { name: item.name, w: item.w, h: item.h, order };
}
function canonicalItems(items: readonly BoardGridItem[]): BoardGridItem[] {
return items
.map((item) => ({
name: item.name,
w: clampInteger(item.w, 1, BOARD_GRID_COLUMNS),
h: clampInteger(item.h, 1, BOARD_GRID_MAX_HEIGHT),
order: Number.isFinite(item.order) ? item.order : 0,
}))
.toSorted((left, right) => left.order - right.order || left.name.localeCompare(right.name))
.map(withOrder);
}
function fits(occupied: readonly boolean[][], x: number, y: number, w: number, h: number): boolean {
for (let row = y; row < y + h; row += 1) {
for (let column = x; column < x + w; column += 1) {
if (occupied[row]?.[column]) {
return false;
}
}
}
return true;
}
function occupy(occupied: boolean[][], rect: BoardGridRect): void {
for (let row = rect.y; row < rect.y + rect.h; row += 1) {
const cells = occupied[row] ?? Array.from({ length: BOARD_GRID_COLUMNS }, () => false);
occupied[row] = cells;
for (let column = rect.x; column < rect.x + rect.w; column += 1) {
cells[column] = true;
}
}
}
function firstFit(occupied: readonly boolean[][], item: BoardGridItem): BoardGridRect {
for (let y = 0; ; y += 1) {
for (let x = 0; x <= BOARD_GRID_COLUMNS - item.w; x += 1) {
if (fits(occupied, x, y, item.w, item.h)) {
return { name: item.name, x, y, w: item.w, h: item.h };
}
}
}
}
/**
* Places canonical-order items at the first available row-major cell. Earlier
* items therefore keep priority while every later item is gravity-tight.
*/
export function layout(items: readonly BoardGridItem[]): BoardGridRect[] {
const occupied: boolean[][] = [];
const rects: BoardGridRect[] = [];
for (const item of canonicalItems(items)) {
const placed = firstFit(occupied, item);
occupy(occupied, placed);
rects.push(placed);
}
return rects;
}
function contains(rect: BoardGridRect, cell: BoardGridCell): boolean {
return (
cell.x >= rect.x && cell.x < rect.x + rect.w && cell.y >= rect.y && cell.y < rect.y + rect.h
);
}
/**
* Reorders one item around the target cell, then fully reflows the board.
* Occupied targets insert before their occupant: that item and its followers
* are pushed aside by the normal first-fit pass.
*/
export function previewDrag(
items: readonly BoardGridItem[],
name: string,
targetCell: BoardGridCell,
): BoardGridPreview {
const canonical = canonicalItems(items);
const movingIndex = canonical.findIndex((item) => item.name === name);
if (movingIndex < 0) {
return { items: canonical, rects: layout(canonical) };
}
const currentRects = layout(canonical);
const cell = {
x: clampInteger(targetCell.x, 0, BOARD_GRID_COLUMNS - 1),
y: Math.max(0, Number.isFinite(targetCell.y) ? Math.floor(targetCell.y) : 0),
};
const currentRect = currentRects.find((rect) => rect.name === name);
if (currentRect && contains(currentRect, cell)) {
return { items: canonical, rects: currentRects };
}
const [moving] = canonical.splice(movingIndex, 1);
if (!moving) {
return { items: canonical, rects: layout(canonical) };
}
const occupiedTarget = currentRects.find((rect) => rect.name !== name && contains(rect, cell));
const nextRect =
occupiedTarget ??
currentRects
.filter(
(rect) =>
rect.name !== name && (rect.y > cell.y || (rect.y === cell.y && rect.x >= cell.x)),
)
.toSorted((left, right) => left.y - right.y || left.x - right.x)[0];
const insertionIndex = nextRect
? canonical.findIndex((item) => item.name === nextRect.name)
: canonical.length;
canonical.splice(Math.max(0, insertionIndex), 0, moving);
const reordered = canonical.map(withOrder);
return { items: reordered, rects: layout(reordered) };
}
/** Returns a new canonical item list with one clamped size change. */
export function resize(
items: readonly BoardGridItem[],
name: string,
w: number,
h: number,
): BoardGridItem[] {
return canonicalItems(items).map((item) =>
item.name === name
? {
name: item.name,
w: clampInteger(w, 1, BOARD_GRID_COLUMNS),
h: clampInteger(h, 1, BOARD_GRID_MAX_HEIGHT),
order: item.order,
}
: item,
);
}
/** Arrow-key fallback: left/up move earlier; right/down move later. */
export function nudge(
items: readonly BoardGridItem[],
name: string,
direction: BoardGridDirection,
): BoardGridItem[] {
const canonical = canonicalItems(items);
const index = canonical.findIndex((item) => item.name === name);
if (index < 0) {
return canonical;
}
const delta = direction === "left" || direction === "up" ? -1 : 1;
const target = Math.min(canonical.length - 1, Math.max(0, index + delta));
if (target !== index) {
const [moving] = canonical.splice(index, 1);
if (moving) {
canonical.splice(target, 0, moving);
}
}
return canonical.map(withOrder);
}
/** CSS grid lines are one-based; engine cells are zero-based. */
export function toCssPlacement(rect: BoardGridRect): string {
return `grid-column: ${rect.x + 1} / span ${rect.w}; grid-row: ${rect.y + 1} / span ${rect.h};`;
}

View File

@@ -0,0 +1,55 @@
/** Temporary board view contract. A stitch commit will point these at 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;
};
export type BoardWidget = {
name: string;
tabId: string;
title?: string;
contentKind: "html" | "mcp-app";
sizeW: number;
sizeH: number;
position: number;
grantState: BoardGrantState;
revision: number;
};
export type BoardSnapshot = {
sessionKey: string;
revision: number;
tabs: BoardTab[];
widgets: BoardWidget[];
};
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;
};
export type BoardWidgetFrameUrl = (name: string, revision: number) => string;

View File

@@ -1,6 +1,7 @@
// Control UI module implements main behavior.
import "./styles.css";
import "./app/app-host.ts";
import "./components/board/board-view.ts";
import { inferControlUiPublicAssetPath } from "./app/public-assets.ts";
import {
installMissingStylesheetRecovery,

448
ui/src/styles/board.css Normal file
View File

@@ -0,0 +1,448 @@
openclaw-board-view {
display: block;
min-width: 0;
width: 100%;
}
.board-view {
--board-line: color-mix(in srgb, var(--border, #2c313a) 84%, transparent);
--board-surface: color-mix(in srgb, var(--panel, #171a20) 94%, transparent);
display: grid;
gap: 12px;
min-width: 0;
position: relative;
}
.board-view__error {
background: color-mix(in srgb, var(--danger, #ff6b6b) 8%, var(--panel, #171a20));
border: 1px solid color-mix(in srgb, var(--danger, #ff6b6b) 42%, transparent);
border-radius: 9px;
color: var(--danger, #ff6b6b);
font-size: 12px;
padding: 9px 12px;
}
.board-tabs {
align-items: center;
border-bottom: 1px solid var(--board-line);
display: flex;
min-width: 0;
}
.board-tabs__track {
--track-width: 0;
display: block;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.board-tabs__track::part(nav) {
display: flex;
}
.board-tabs__track::part(body) {
display: none;
}
.board-tabs__track::-webkit-scrollbar {
display: none;
}
.board-tabs__tab {
color: var(--muted, #8a919e);
flex: 0 0 auto;
}
.board-tabs__tab::part(base) {
border-bottom: 2px solid transparent;
font-size: 12.5px;
min-height: 38px;
padding: 0 13px;
transition:
background 120ms ease,
border-color 120ms ease,
color 120ms ease;
}
.board-tabs__tab:hover::part(base),
.board-tabs__tab:focus-visible::part(base) {
background: color-mix(in srgb, var(--text, #d7dae0) 6%, transparent);
color: var(--text, #d7dae0);
}
.board-tabs__tab--active::part(base) {
border-bottom-color: var(--accent, #ff5c5c);
color: var(--text, #d7dae0);
}
.board-tabs__tab--drop::part(base) {
background: color-mix(in srgb, var(--accent, #ff5c5c) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #ff5c5c) 70%, transparent);
}
.board-tabs__overflow,
.board-widget__menu {
margin-left: auto;
position: relative;
}
.board-tabs__overflow-trigger,
.board-widget__menu-trigger {
align-items: center;
background: transparent;
border: 0;
border-radius: 7px;
color: var(--muted, #8a919e);
cursor: pointer;
display: inline-flex;
justify-content: center;
user-select: none;
}
.board-tabs__overflow-trigger {
height: 30px;
width: 34px;
}
.board-tabs__overflow-trigger:hover,
.board-widget__menu-trigger:hover {
background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent);
color: var(--text, #d7dae0);
}
.board-tabs__overflow::part(menu),
.board-widget__menu::part(menu) {
background: var(--panel, #171a20);
border: 1px solid var(--board-line);
border-radius: 10px;
box-shadow: 0 18px 48px rgb(0 0 0 / 32%);
min-width: 190px;
padding: 6px;
}
.board-tabs__overflow-item {
min-width: 180px;
}
.board-grid {
display: grid;
gap: 12px;
grid-auto-rows: 56px;
grid-template-columns: repeat(12, minmax(0, 1fr));
min-width: 0;
position: relative;
}
.board-grid__append-zone {
border-top: 1px dashed color-mix(in srgb, var(--accent, #ff5c5c) 48%, transparent);
bottom: -42px;
height: 38px;
left: 0;
position: absolute;
right: 0;
z-index: 7;
}
openclaw-board-widget-cell {
display: contents;
}
.board-widget {
background: var(--board-surface);
border: 1px solid var(--board-line);
border-radius: 12px;
box-shadow: 0 1px 1px rgb(0 0 0 / 12%);
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
min-height: 0;
min-width: 0;
overflow: visible;
position: relative;
transition:
border-color 120ms ease,
box-shadow 120ms ease,
transform 120ms ease;
}
.board-widget:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #ff5c5c) 70%, transparent);
outline: none;
}
.board-widget--dragging {
border-color: color-mix(in srgb, var(--accent, #ff5c5c) 72%, transparent);
box-shadow: 0 16px 36px rgb(0 0 0 / 28%);
opacity: 0.88;
transform: scale(0.995);
z-index: 8;
}
.board-widget__bar {
align-items: center;
background: color-mix(in srgb, var(--text, #d7dae0) 3%, transparent);
border-bottom: 1px solid var(--board-line);
border-radius: 11px 11px 0 0;
display: flex;
gap: 7px;
min-width: 0;
padding: 0 6px;
}
.board-widget__drag-handle,
.board-widget__resize-handle {
background: transparent;
border: 0;
color: var(--muted, #8a919e);
}
.board-widget__drag-handle {
align-items: center;
border-radius: 6px;
cursor: grab;
display: inline-flex;
font-size: 17px;
height: 27px;
justify-content: center;
padding: 0;
touch-action: none;
width: 25px;
}
.board-widget__drag-handle:hover {
background: color-mix(in srgb, var(--text, #d7dae0) 9%, transparent);
color: var(--text, #d7dae0);
}
.board-widget__title {
color: var(--text, #d7dae0);
font-size: 12.5px;
font-weight: 590;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-widget__kind {
border: 1px solid var(--board-line);
border-radius: 999px;
color: var(--muted, #8a919e);
font-size: 8px;
letter-spacing: 0.08em;
line-height: 15px;
margin-left: auto;
padding: 0 5px;
}
.board-widget__menu {
margin-left: 0;
}
.board-widget__menu-trigger {
font-size: 21px;
height: 27px;
line-height: 1;
width: 27px;
}
.board-widget__menu-heading {
color: var(--muted, #8a919e);
font-size: 9px;
letter-spacing: 0.08em;
padding: 7px 9px 3px;
text-transform: uppercase;
}
.board-widget__menu-empty {
color: var(--muted, #8a919e);
font-size: 11px;
padding: 5px 9px;
}
.board-widget__preset {
font-size: 11px;
}
.board-widget__menu-separator {
border-top: 1px solid var(--board-line);
margin: 5px 3px;
}
.board-widget__menu-danger {
color: var(--danger, #ff6b6b);
}
.board-widget__body {
min-height: 0;
overflow: hidden;
position: relative;
}
.board-widget__body--scrollable {
overflow: auto;
}
.board-widget__body--scrollable .board-widget__grant {
box-sizing: border-box;
height: auto;
min-height: 100%;
}
.board-widget__error-overlay {
inset: 0;
position: absolute;
}
.board-widget__frame {
background: transparent;
border: 0;
display: block;
height: 100%;
width: 100%;
}
.board-widget__resize-handle {
bottom: 1px;
cursor: nwse-resize;
height: 20px;
position: absolute;
right: 1px;
touch-action: none;
width: 20px;
}
.board-widget__resize-handle::after {
border-bottom: 2px solid currentcolor;
border-right: 2px solid currentcolor;
bottom: 5px;
content: "";
height: 6px;
position: absolute;
right: 5px;
width: 6px;
}
.board-widget__grant,
.board-widget__error {
align-content: center;
display: grid;
gap: 7px;
height: 100%;
justify-items: center;
padding: 16px;
text-align: center;
}
.board-widget__grant strong,
.board-widget__error strong {
color: var(--text, #d7dae0);
font-size: 13px;
}
.board-widget__grant > span,
.board-widget__error > span {
color: var(--muted, #8a919e);
font-size: 11px;
line-height: 1.45;
max-width: 32ch;
}
.board-widget__grant-mark {
align-items: center;
background: color-mix(in srgb, var(--warning, #e6ad55) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--warning, #e6ad55) 55%, transparent);
border-radius: 50%;
color: var(--warning, #e6ad55);
display: flex;
font-size: 13px;
font-weight: 700;
height: 25px;
justify-content: center;
width: 25px;
}
.board-widget__grant-actions {
display: flex;
gap: 7px;
}
.board-widget__grant-actions button:disabled {
cursor: progress;
opacity: 0.55;
}
.board-widget__grant--rejected {
filter: saturate(0.35);
opacity: 0.74;
}
.board-widget__error {
background: color-mix(in srgb, var(--danger, #ff6b6b) 7%, transparent);
}
.board-widget__error--inline {
height: auto;
margin-top: 4px;
padding: 8px;
width: min(100%, 320px);
}
.board-widget__error details {
color: var(--muted, #8a919e);
font-size: 10px;
max-width: 100%;
}
.board-widget__error code {
display: block;
margin-top: 5px;
max-width: 100%;
overflow-wrap: anywhere;
}
.board-empty {
align-items: center;
border: 1px dashed color-mix(in srgb, var(--muted, #8a919e) 38%, transparent);
border-radius: 14px;
color: var(--muted, #8a919e);
display: flex;
flex-direction: column;
gap: 7px;
justify-content: center;
min-height: 260px;
padding: 32px;
text-align: center;
}
.board-empty strong {
color: var(--text, #d7dae0);
font-size: 14px;
}
.board-empty span:last-child {
font-size: 12px;
}
.board-empty__mark {
color: var(--accent, #ff5c5c);
font-size: 24px;
}
.board-announcer {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
@media (prefers-reduced-motion: reduce) {
.board-widget,
.board-tabs__tab {
transition: none;
}
}

View File

@@ -0,0 +1,226 @@
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { BoardOp, BoardSnapshot } from "../lib/board/view-types.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import "../components/board/board-view.ts";
const initialSnapshot: BoardSnapshot = {
sessionKey: "agent:main:board-fixture",
revision: 7,
tabs: [
{ tabId: "overview", title: "Overview", position: 0, chatDock: "right" },
{ tabId: "operations", title: "Operations", position: 1, chatDock: "bottom" },
],
widgets: [
{
name: "service-pulse",
tabId: "overview",
title: "Service pulse",
contentKind: "html",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none",
revision: 3,
},
{
name: "deploy-window",
tabId: "overview",
title: "Deploy window",
contentKind: "mcp-app",
sizeW: 6,
sizeH: 4,
position: 1,
grantState: "granted",
revision: 2,
},
{
name: "latency-bands",
tabId: "overview",
title: "Latency bands",
contentKind: "html",
sizeW: 4,
sizeH: 5,
position: 2,
grantState: "none",
revision: 1,
},
{
name: "incident-tools",
tabId: "overview",
title: "Incident tools",
contentKind: "html",
sizeW: 4,
sizeH: 5,
position: 3,
grantState: "pending",
revision: 1,
},
{
name: "retired-report",
tabId: "overview",
title: "Retired report",
contentKind: "html",
sizeW: 4,
sizeH: 5,
position: 4,
grantState: "rejected",
revision: 4,
},
{
name: "worker-queue",
tabId: "operations",
title: "Worker queue",
contentKind: "html",
sizeW: 12,
sizeH: 6,
position: 0,
grantState: "none",
revision: 5,
},
],
};
const frameCopy: Record<string, { eyebrow: string; value: string; detail: string }> = {
"service-pulse": { eyebrow: "UPTIME / 24H", value: "99.982%", detail: "All regions nominal" },
"deploy-window": { eyebrow: "NEXT WINDOW", value: "14:30 UTC", detail: "3 changes queued" },
"latency-bands": { eyebrow: "P95 LATENCY", value: "184 ms", detail: "12 ms since last hour" },
"worker-queue": { eyebrow: "QUEUE DEPTH", value: "38", detail: "12 active · 4 waiting" },
};
function frameUrl(name: string): string {
const copy = frameCopy[name] ?? { eyebrow: "WIDGET", value: name, detail: "Mock fixture" };
const document = `<!doctype html><meta charset="utf-8"><style>
:root{color-scheme:dark;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
body{box-sizing:border-box;margin:0;height:100vh;padding:22px;color:#d9dce3;background:
radial-gradient(circle at 85% 5%,rgba(74,201,168,.16),transparent 38%),#13161b}
.eyebrow{font-size:10px;letter-spacing:.14em;color:#7f8897}.value{font-size:clamp(24px,7vw,48px);
font-weight:650;letter-spacing:-.06em;margin-top:16px}.detail{color:#77cdb5;font-size:11px;margin-top:9px}
.ticks{display:flex;align-items:end;gap:5px;height:52px;margin-top:20px}.ticks i{display:block;flex:1;
min-width:4px;border-radius:2px 2px 0 0;background:#39434d}.ticks i:nth-child(3n){background:#4ec9a8}
</style><div class="eyebrow">${copy.eyebrow}</div><div class="value">${copy.value}</div>
<div class="detail">${copy.detail}</div><div class="ticks">${[28, 52, 37, 68, 44, 81, 59, 72]
.map((height) => `<i style="height:${height}%"></i>`)
.join("")}</div>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(document)}`;
}
function normalizePositions(snapshot: BoardSnapshot): BoardSnapshot {
const widgets = snapshot.widgets.map((widget) => ({ ...widget }));
for (const tab of snapshot.tabs) {
widgets
.filter((widget) => widget.tabId === tab.tabId)
.toSorted(
(left, right) => left.position - right.position || left.name.localeCompare(right.name),
)
.forEach((widget, position) => {
widget.position = position;
});
}
return { ...snapshot, widgets };
}
function moveFixtureWidget(
snapshot: BoardSnapshot,
op: Extract<BoardOp, { kind: "widget_move" }>,
): BoardSnapshot {
const moving = snapshot.widgets.find((widget) => widget.name === op.name);
if (!moving) {
return snapshot;
}
const tabId = op.tabId ?? moving.tabId;
const remaining = snapshot.widgets.filter((widget) => widget.name !== op.name);
const destination = remaining
.filter((widget) => widget.tabId === tabId)
.toSorted(
(left, right) => left.position - right.position || left.name.localeCompare(right.name),
);
const afterIndex = op.after ? destination.findIndex((widget) => widget.name === op.after) : -1;
const requestedPosition =
afterIndex >= 0
? afterIndex + 1
: (op.position ?? (tabId === moving.tabId ? moving.position : destination.length));
const position = Math.max(0, Math.min(Math.trunc(requestedPosition), destination.length));
destination.splice(position, 0, { ...moving, tabId, position });
destination.forEach((widget, nextPosition) => {
widget.position = nextPosition;
});
return normalizePositions({
...snapshot,
widgets: [...remaining.filter((widget) => widget.tabId !== tabId), ...destination],
});
}
export function applyBoardFixtureOps(
snapshot: BoardSnapshot,
ops: readonly BoardOp[],
): BoardSnapshot {
let next = structuredClone(snapshot);
for (const op of ops) {
if (op.kind === "widget_remove") {
next.widgets = next.widgets.filter((widget) => widget.name !== op.name);
} else if (op.kind === "widget_resize") {
next.widgets = next.widgets.map((widget) => {
if (widget.name !== op.name || (widget.sizeW === op.sizeW && widget.sizeH === op.sizeH)) {
return widget;
}
return { ...widget, sizeW: op.sizeW, sizeH: op.sizeH, revision: widget.revision + 1 };
});
} else if (op.kind === "widget_move") {
next = moveFixtureWidget(next, op);
}
}
next = normalizePositions(next);
return { ...next, revision: next.revision + 1 };
}
class BoardFixture extends OpenClawLightDomElement {
@state() private snapshot = structuredClone(initialSnapshot);
@state() private activeTabId = "overview";
private async applyOps(ops: BoardOp[]): Promise<void> {
this.snapshot = applyBoardFixtureOps(this.snapshot, ops);
}
private async grant(name: string, decision: "granted" | "rejected"): Promise<void> {
this.snapshot = {
...this.snapshot,
revision: this.snapshot.revision + 1,
widgets: this.snapshot.widgets.map((widget) =>
widget.name === name ? { ...widget, grantState: decision } : widget,
),
};
}
override render() {
return html`
<main class="board-fixture-shell">
<header class="board-fixture-header">
<div>
<span>SESSION DASHBOARD / MOCK</span>
<h1>Launch control</h1>
</div>
<div class="board-fixture-status"><i></i> fixture online</div>
</header>
<openclaw-board-view
.snapshot=${this.snapshot}
.activeTabId=${this.activeTabId}
.widgetFrameUrl=${frameUrl}
.callbacks=${{
applyOps: (ops: BoardOp[]) => this.applyOps(ops),
grant: (name: string, decision: "granted" | "rejected") => this.grant(name, decision),
selectTab: (tabId: string) => {
this.activeTabId = tabId;
},
}}
></openclaw-board-view>
</main>
`;
}
}
if (!customElements.get("openclaw-board-fixture")) {
customElements.define("openclaw-board-fixture", BoardFixture);
}
document.querySelector("#app")?.append(document.createElement("openclaw-board-fixture"));