diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts
index 49abdfbc3b16..1d7ed8275c50 100644
--- a/scripts/control-ui-mock-dev.ts
+++ b/scripts/control-ui-mock-dev.ts
@@ -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 = `
+
+
+
+
+ OpenClaw Board Fixture
+
+
+
+
+
+
+
+`;
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 {
@@ -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();
diff --git a/ui/src/components/board/board-view.browser.test.ts b/ui/src/components/board/board-view.browser.test.ts
new file mode 100644
index 000000000000..1f7736d52ccf
--- /dev/null
+++ b/ui/src/components/board/board-view.browser.test.ts
@@ -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 {
+ 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('[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(".board-grid");
+ const handle = view.querySelector(".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(".board-widget__resize-handle");
+ pointer(handle!, "pointerdown", 23, 100, 100);
+ pointer(window, "pointerup", 23, 100, 100);
+ const dragHandle = view.querySelector(".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(".board-grid");
+ const handles = view.querySelectorAll(".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(".board-widget__drag-handle");
+ const foreignTab = other.querySelector('[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(".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(".board-widget__drag-handle");
+ pointer(handle!, "pointerdown", 61, 100, 100);
+ await view.updateComplete;
+ const appendZone = view.querySelector(".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(".board-widget__body--scrollable");
+ const allow = view.querySelector('[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(".board-widget__body--scrollable");
+ expect(getComputedStyle(body!).overflowY).toBe("auto");
+ expect(body!.scrollHeight).toBeGreaterThan(body!.clientHeight);
+ expect(body?.textContent).toContain("one-row resolver failed");
+ });
+});
diff --git a/ui/src/components/board/board-view.test.ts b/ui/src/components/board/board-view.test.ts
new file mode 100644
index 000000000000..0c7f1e6351ed
--- /dev/null
+++ b/ui/src/components/board/board-view.test.ts
@@ -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 {
+ 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 {
+ 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 {
+ return {
+ applyOps: vi.fn(async () => undefined),
+ grant: vi.fn(async () => undefined),
+ selectTab: vi.fn(),
+ ...overrides,
+ };
+}
+
+function deferred(): {
+ promise: Promise;
+ resolve: () => void;
+ reject: (error: Error) => void;
+} {
+ let resolve: () => void = () => undefined;
+ let reject: (error: Error) => void = () => undefined;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+async function settleCells(view: OpenClawBoardView): Promise {
+ 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 {
+ 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(".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(".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(".board-widget__menu-danger")?.click();
+ await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(1));
+ await settleCells(view);
+ const secondRemove = cells[1]?.querySelector(".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(".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('[data-test-id="board-grant-allow"]');
+ const reject = view.querySelector('[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((resolve) => {
+ finishGrant = resolve;
+ }),
+ );
+ const source = snapshot({ widgets: [boardWidget({ grantState: "pending" })] });
+ const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) });
+ const allow = view.querySelector('[data-test-id="board-grant-allow"]');
+ const reject = view.querySelector('[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('[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('[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('[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('[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('[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(".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("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"]);
+ });
+});
diff --git a/ui/src/components/board/board-view.ts b/ui/src/components/board/board-view.ts
new file mode 100644
index 000000000000..345afbfa47b8
--- /dev/null
+++ b/ui/src/components/board/board-view.ts
@@ -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();
+ private stableCellOrderSequence = 0;
+
+ override willUpdate(changed: PropertyValues): 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 {
+ 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("[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(".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(".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 {
+ 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(".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`
+
+ ${tab.title}
+
+ `;
+ }
+
+ private renderOverflowTab(tab: BoardTab): TemplateResult {
+ return html`
+
+ ${tab.title}
+
+ `;
+ }
+
+ 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`
+
+ `;
+ }
+
+ private renderGrid(
+ widgets: readonly BoardWidget[],
+ tabs: readonly BoardTab[],
+ sessionKey: string,
+ ): TemplateResult {
+ if (widgets.length === 0) {
+ return html`
+
+ +
+ ${t("board.emptyTitle")}
+ ${t("board.emptyHint")}
+
+ `;
+ }
+ 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`
+
+ ${repeat(
+ stableRects,
+ (rect) => `${sessionKey}\u0000${rect.name}`,
+ (rect) => {
+ const widget = widgetByName.get(rect.name);
+ if (!widget) {
+ return nothing;
+ }
+ return html`
+
+ `;
+ },
+ )}
+ ${this.gesture?.mode === "move"
+ ? html`
`
+ : nothing}
+
+ `;
+ }
+
+ 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`
+
+ ${this.renderTabs(tabs, activeTabId)} ${this.renderGrid(widgets, tabs, snapshot.sessionKey)}
+ ${this.actionError
+ ? html`${this.actionError}
`
+ : nothing}
+
+ ${this.announcement
+ ? keyed(
+ this.announcementRevision,
+ html`${this.announcement}`,
+ )
+ : nothing}
+
+
+ `;
+ }
+}
+
+if (!customElements.get("openclaw-board-view")) {
+ customElements.define("openclaw-board-view", OpenClawBoardView);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "openclaw-board-view": OpenClawBoardView;
+ }
+}
diff --git a/ui/src/components/board/board-widget-cell.ts b/ui/src/components/board/board-widget-cell.ts
new file mode 100644
index 000000000000..491c201cef50
--- /dev/null
+++ b/ui/src/components/board/board-widget-cell.ts
@@ -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;
+ movePointerDown: (widget: BoardWidget, event: PointerEvent) => void;
+ resizePointerDown: (widget: BoardWidget, event: PointerEvent) => void;
+ moveToTab: (widget: BoardWidget, tabId: string) => Promise;
+ resizeTo: (widget: BoardWidget, w: number, h: number) => Promise;
+ remove: (widget: BoardWidget) => Promise;
+ nudge: (widget: BoardWidget, direction: BoardGridDirection) => Promise;
+ 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): void {
+ const previousWidget = changed.get("widget");
+ if (previousWidget && previousWidget !== this.widget) {
+ this.actionError = "";
+ }
+ }
+
+ private closeMenu(): void {
+ const menu = this.querySelector(".board-widget__menu");
+ if (menu) {
+ menu.open = false;
+ }
+ }
+
+ private async runAction(action: () => Promise): Promise {
+ 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`
+
+ `;
+ }
+
+ private renderPending(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
+ return html`
+
+ `;
+ }
+
+ private renderRejected(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
+ return html`
+
+ ${t("board.widget.rejected")}
+ ${t("board.widget.rejectedDetail")}
+
+
+ `;
+ }
+
+ 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`
+
+ `;
+ }
+
+ 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`
+
+ ${t("board.widget.errorTitle")}
+ ${t("board.widget.errorDetail")}
+
+ ${t("board.widget.errorShow")}
+ ${message}
+
+
+ `;
+ }
+
+ private renderActionError(error: string, inline = false): TemplateResult {
+ return html`
+
+ ${t("board.widget.actionErrorTitle")}
+ ${t("board.widget.actionErrorDetail")}
+
+ ${t("board.widget.errorShow")}
+ ${error}
+
+
+ `;
+ }
+
+ 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`
+
+ `;
+ }
+}
+
+if (!customElements.get("openclaw-board-widget-cell")) {
+ customElements.define("openclaw-board-widget-cell", OpenClawBoardWidgetCell);
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "openclaw-board-widget-cell": OpenClawBoardWidgetCell;
+ }
+}
diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts
index 44e2b2d4e4a7..e03e92f0773d 100644
--- a/ui/src/i18n/locales/en.ts
+++ b/ui/src/i18n/locales/en.ts
@@ -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",
diff --git a/ui/src/lib/board/grid.test.ts b/ui/src/lib/board/grid.test.ts
new file mode 100644
index 000000000000..9d2677c04a0d
--- /dev/null
+++ b/ui/src/lib/board/grid.test.ts
@@ -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;",
+ );
+ });
+});
diff --git a/ui/src/lib/board/grid.ts b/ui/src/lib/board/grid.ts
new file mode 100644
index 000000000000..9d918ab65b67
--- /dev/null
+++ b/ui/src/lib/board/grid.ts
@@ -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};`;
+}
diff --git a/ui/src/lib/board/view-types.ts b/ui/src/lib/board/view-types.ts
new file mode 100644
index 000000000000..5596b62f3274
--- /dev/null
+++ b/ui/src/lib/board/view-types.ts
@@ -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;
+ grant: (name: string, decision: BoardGrantDecision) => Promise;
+ selectTab: (tabId: string) => void;
+ pinRequest?: never;
+};
+
+export type BoardWidgetFrameUrl = (name: string, revision: number) => string;
diff --git a/ui/src/main.ts b/ui/src/main.ts
index 33b27250302e..68b35f937517 100644
--- a/ui/src/main.ts
+++ b/ui/src/main.ts
@@ -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,
diff --git a/ui/src/styles/board.css b/ui/src/styles/board.css
new file mode 100644
index 000000000000..3aa8f6d3f16a
--- /dev/null
+++ b/ui/src/styles/board.css
@@ -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;
+ }
+}
diff --git a/ui/src/test-helpers/board-fixture.ts b/ui/src/test-helpers/board-fixture.ts
new file mode 100644
index 000000000000..5d4a8dce33c9
--- /dev/null
+++ b/ui/src/test-helpers/board-fixture.ts
@@ -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 = {
+ "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 = `${copy.eyebrow}
${copy.value}
+ ${copy.detail}
${[28, 52, 37, 68, 44, 81, 59, 72]
+ .map((height) => ``)
+ .join("")}
`;
+ 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,
+): 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 {
+ this.snapshot = applyBoardFixtureOps(this.snapshot, ops);
+ }
+
+ private async grant(name: string, decision: "granted" | "rejected"): Promise {
+ 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`
+
+
+ this.applyOps(ops),
+ grant: (name: string, decision: "granted" | "rejected") => this.grant(name, decision),
+ selectTab: (tabId: string) => {
+ this.activeTabId = tabId;
+ },
+ }}
+ >
+
+ `;
+ }
+}
+
+if (!customElements.get("openclaw-board-fixture")) {
+ customElements.define("openclaw-board-fixture", BoardFixture);
+}
+
+document.querySelector("#app")?.append(document.createElement("openclaw-board-fixture"));