mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 13:41:15 +00:00
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:
committed by
GitHub
parent
81df196b8d
commit
8a66aa76dd
@@ -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();
|
||||
|
||||
283
ui/src/components/board/board-view.browser.test.ts
Normal file
283
ui/src/components/board/board-view.browser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
507
ui/src/components/board/board-view.test.ts
Normal file
507
ui/src/components/board/board-view.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
618
ui/src/components/board/board-view.ts
Normal file
618
ui/src/components/board/board-view.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
374
ui/src/components/board/board-widget-cell.ts
Normal file
374
ui/src/components/board/board-widget-cell.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
250
ui/src/lib/board/grid.test.ts
Normal file
250
ui/src/lib/board/grid.test.ts
Normal 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
199
ui/src/lib/board/grid.ts
Normal 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};`;
|
||||
}
|
||||
55
ui/src/lib/board/view-types.ts
Normal file
55
ui/src/lib/board/view-types.ts
Normal 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;
|
||||
@@ -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
448
ui/src/styles/board.css
Normal 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;
|
||||
}
|
||||
}
|
||||
226
ui/src/test-helpers/board-fixture.ts
Normal file
226
ui/src/test-helpers/board-fixture.ts
Normal 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"));
|
||||
Reference in New Issue
Block a user