diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 49abdfbc3b16..1d7ed8275c50 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -23,6 +23,7 @@ import { buildSkillWorkshopMocks } from "./control-ui-mock-skill-workshop.js"; type CliOptions = { allowedHosts: string[]; + fixture?: "board"; host: string; port: number; }; @@ -40,6 +41,30 @@ const TOTAL_TELEGRAM_SESSIONS = 180; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const uiRoot = path.join(repoRoot, "ui"); +const boardFixturePath = "/__fixtures/board/"; +const boardFixtureHtml = ` + + + + + OpenClaw Board Fixture + + + + +
+ + +`; function mockFileHash(value: string): string { return createHash("sha256").update(value, "utf8").digest("hex"); @@ -63,6 +88,10 @@ function parseArgs(args: string[]): CliOptions { options.host = args[++i] ?? options.host; } else if (arg.startsWith("--host=")) { options.host = arg.slice("--host=".length) || options.host; + } else if (arg === "--fixture") { + options.fixture = parseFixture(args[++i]); + } else if (arg.startsWith("--fixture=")) { + options.fixture = parseFixture(arg.slice("--fixture=".length)); } else if (arg === "--port") { options.port = parsePort(args[++i], options.port); } else if (arg.startsWith("--port=")) { @@ -72,6 +101,16 @@ function parseArgs(args: string[]): CliOptions { return options; } +function parseFixture(value: string | undefined): "board" | undefined { + if (!value) { + return undefined; + } + if (value !== "board") { + throw new Error(`Unknown Control UI mock fixture: ${value}`); + } + return value; +} + function parsePort(value: string | undefined, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 && parsed < 65_536 ? parsed : fallback; @@ -1421,18 +1460,42 @@ function createMockGatewayPlugin(scenario: ControlUiMockGatewayScenario): Plugin }; } +function createBoardFixturePlugin(): Plugin { + return { + name: "openclaw-control-ui-board-fixture", + configureServer(server) { + server.middlewares.use(boardFixturePath, (_req, res, next) => { + void server + .transformIndexHtml(boardFixturePath, boardFixtureHtml) + .then((html) => { + res.statusCode = 200; + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end(html); + }) + .catch((error: unknown) => { + next(error as Error); + }); + }); + }, + }; +} + function hostForUrl(boundAddress: string, requestedHost: string): string { const host = boundAddress === "0.0.0.0" || boundAddress === "::" ? requestedHost : boundAddress; const reachableHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host; return reachableHost.includes(":") ? `[${reachableHost}]` : reachableHost; } -function resolveServerUrl(server: ViteDevServer, requestedHost: string): string { +function resolveServerUrl( + server: ViteDevServer, + requestedHost: string, + pathname = "/chat", +): string { const address = server.httpServer?.address(); if (!address || typeof address === "string") { throw new Error("Control UI mock server did not expose a TCP port"); } - return `http://${hostForUrl(address.address, requestedHost)}:${address.port}/chat`; + return `http://${hostForUrl(address.address, requestedHost)}:${address.port}${pathname}`; } async function waitForShutdown(): Promise { @@ -1459,9 +1522,12 @@ const server = await createServer({ }, logLevel: "error", optimizeDeps: { + ...(options.fixture === "board" + ? { entries: [path.join(uiRoot, "src", "test-helpers", "board-fixture.ts")] } + : {}), include: ["lit/directives/repeat.js"], }, - plugins: [createMockGatewayPlugin(scenario)], + plugins: [createMockGatewayPlugin(scenario), createBoardFixturePlugin()], publicDir: path.join(uiRoot, "public"), resolve: { alias: [ @@ -1481,5 +1547,8 @@ const server = await createServer({ await server.listen(); console.log(`[control-ui-mock] ${resolveServerUrl(server, options.host)}`); +console.log( + `[control-ui-mock] board fixture: ${resolveServerUrl(server, options.host, boardFixturePath)}`, +); await waitForShutdown(); await server.close(); diff --git a/ui/src/components/board/board-view.browser.test.ts b/ui/src/components/board/board-view.browser.test.ts new file mode 100644 index 000000000000..1f7736d52ccf --- /dev/null +++ b/ui/src/components/board/board-view.browser.test.ts @@ -0,0 +1,283 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BOARD_GRID_GAP, BOARD_GRID_ROW_HEIGHT } from "../../lib/board/grid.ts"; +import type { BoardSnapshot } from "../../lib/board/view-types.ts"; +import "./board-view.ts"; + +type OpenClawBoardView = HTMLElementTagNameMap["openclaw-board-view"]; + +const hasBrowserLayout = !navigator.userAgent.toLowerCase().includes("jsdom"); + +const source: BoardSnapshot = { + sessionKey: "agent:main:browser-board", + revision: 1, + tabs: [ + { tabId: "main", title: "Main", position: 0, chatDock: "right" }, + { tabId: "ops", title: "Operations", position: 1, chatDock: "right" }, + ], + widgets: [ + { + name: "first", + tabId: "main", + title: "First", + contentKind: "html", + sizeW: 6, + sizeH: 3, + position: 0, + grantState: "none", + revision: 1, + }, + { + name: "second", + tabId: "main", + title: "Second", + contentKind: "html", + sizeW: 6, + sizeH: 3, + position: 1, + grantState: "none", + revision: 1, + }, + ], +}; + +async function mount(applyOps = vi.fn(async () => undefined)): Promise { + const view = document.createElement("openclaw-board-view"); + view.snapshot = structuredClone(source); + view.activeTabId = "main"; + view.widgetFrameUrl = () => "about:blank"; + view.callbacks = { applyOps, grant: vi.fn(async () => undefined), selectTab: vi.fn() }; + document.body.append(view); + await view.updateComplete; + await Promise.all( + [...view.querySelectorAll("openclaw-board-widget-cell")].map((cell) => cell.updateComplete), + ); + return view; +} + +function pointer( + target: EventTarget, + type: "pointerdown" | "pointermove" | "pointerup", + pointerId: number, + clientX = 0, + clientY = 0, +): void { + target.dispatchEvent( + new PointerEvent(type, { + pointerId, + clientX, + clientY, + button: 0, + bubbles: true, + cancelable: true, + }), + ); +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe.skipIf(!hasBrowserLayout)("openclaw-board-view browser layout", () => { + it("lays out adjacent first-fit cells without pixel overlap", async () => { + const view = await mount(); + const cells = [...view.querySelectorAll('[data-test-id="board-widget"]')]; + expect(cells).toHaveLength(2); + const [first, second] = cells.map((cell) => cell.getBoundingClientRect()); + expect(first?.width).toBeGreaterThan(0); + expect(second?.left).toBeGreaterThanOrEqual((first?.right ?? 0) + BOARD_GRID_GAP - 1); + expect(Math.round(first?.height ?? 0)).toBe(BOARD_GRID_ROW_HEIGHT * 3 + BOARD_GRID_GAP * 2); + }); + + it("snaps pointer resize to columns and rows before committing", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount(applyOps); + const grid = view.querySelector(".board-grid"); + const handle = view.querySelector(".board-widget__resize-handle"); + expect(grid).not.toBeNull(); + expect(handle).not.toBeNull(); + const gridBounds = grid!.getBoundingClientRect(); + const columnUnit = (gridBounds.width - BOARD_GRID_GAP * 11) / 12 + BOARD_GRID_GAP; + pointer(handle!, "pointerdown", 19, 100, 100); + pointer( + window, + "pointermove", + 19, + 100 + columnUnit, + 100 + BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP, + ); + pointer( + window, + "pointerup", + 19, + 100 + columnUnit * 2, + 100 + (BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP) * 2, + ); + + await vi.waitFor(() => + expect(applyOps).toHaveBeenCalledWith([ + { kind: "widget_resize", name: "first", sizeW: 8, sizeH: 5 }, + ]), + ); + }); + + it("does not commit pointer gestures that never change placement", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount(applyOps); + const handle = view.querySelector(".board-widget__resize-handle"); + pointer(handle!, "pointerdown", 23, 100, 100); + pointer(window, "pointerup", 23, 100, 100); + const dragHandle = view.querySelector(".board-widget__drag-handle"); + pointer(dragHandle!, "pointerdown", 24, 100, 100); + pointer(window, "pointerup", 24, 100, 100); + await Promise.resolve(); + expect(applyOps).not.toHaveBeenCalled(); + }); + + it("keeps an active gesture owned by its initiating pointer", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount(applyOps); + const grid = view.querySelector(".board-grid"); + const handles = view.querySelectorAll(".board-widget__resize-handle"); + const gridBounds = grid!.getBoundingClientRect(); + const columnUnit = (gridBounds.width - BOARD_GRID_GAP * 11) / 12 + BOARD_GRID_GAP; + pointer(handles[0]!, "pointerdown", 31, 100, 100); + pointer(handles[1]!, "pointerdown", 32, 200, 100); + pointer( + window, + "pointermove", + 32, + 200 + columnUnit, + 100 + BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP, + ); + pointer(window, "pointerup", 32); + expect(applyOps).not.toHaveBeenCalled(); + + pointer( + window, + "pointermove", + 31, + 100 + columnUnit, + 100 + BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP, + ); + pointer( + window, + "pointerup", + 31, + 100 + columnUnit, + 100 + BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP, + ); + await vi.waitFor(() => + expect(applyOps).toHaveBeenCalledWith([ + { kind: "widget_resize", name: "first", sizeW: 7, sizeH: 4 }, + ]), + ); + }); + + it("rejects tab drop targets owned by another board", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount(applyOps); + + const other = await mount(); + other.snapshot = { + ...structuredClone(source), + tabs: [ + { tabId: "foreign-a", title: "Foreign A", position: 0, chatDock: "right" }, + { tabId: "foreign-b", title: "Foreign B", position: 1, chatDock: "right" }, + ], + widgets: [], + }; + other.activeTabId = "foreign-a"; + await other.updateComplete; + + const handle = view.querySelector(".board-widget__drag-handle"); + const foreignTab = other.querySelector('[data-board-tab-id="foreign-b"]'); + const target = foreignTab!.getBoundingClientRect(); + pointer(handle!, "pointerdown", 41, 100, 100); + pointer( + window, + "pointermove", + 41, + target.left + target.width / 2, + target.top + target.height / 2, + ); + pointer( + window, + "pointerup", + 41, + target.left + target.width / 2, + target.top + target.height / 2, + ); + await Promise.resolve(); + expect(applyOps).not.toHaveBeenCalled(); + }); + + it("rejects widget drops outside the board grid", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount(applyOps); + const handle = view.querySelector(".board-widget__drag-handle"); + pointer(handle!, "pointerdown", 51, 100, 100); + pointer(window, "pointermove", 51, 0, 10_000); + pointer(window, "pointerup", 51, 0, 10_000); + await Promise.resolve(); + expect(applyOps).not.toHaveBeenCalled(); + }); + + it("offers an append drop zone after the final widget", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount(applyOps); + view.snapshot = { + ...structuredClone(source), + widgets: source.widgets.map((widget) => ({ ...widget, sizeW: 12 })), + }; + await view.updateComplete; + const cells = view.querySelectorAll("openclaw-board-widget-cell"); + await Promise.all([...cells].map((cell) => cell.updateComplete)); + const handle = view.querySelector(".board-widget__drag-handle"); + pointer(handle!, "pointerdown", 61, 100, 100); + await view.updateComplete; + const appendZone = view.querySelector(".board-grid__append-zone"); + const target = appendZone!.getBoundingClientRect(); + const targetX = target.left + target.width / 2; + const targetY = target.top + target.height / 2; + pointer(window, "pointermove", 61, targetX, targetY); + pointer(window, "pointerup", 61, targetX, targetY); + await vi.waitFor(() => + expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_move", name: "first", position: 1 }]), + ); + }); + + it("keeps approval controls scrollable in a one-row widget", async () => { + const view = await mount(); + view.snapshot = { + ...structuredClone(source), + widgets: [{ ...source.widgets[0]!, sizeH: 1, grantState: "pending" }], + }; + await view.updateComplete; + const cell = view.querySelector("openclaw-board-widget-cell"); + await cell?.updateComplete; + const body = view.querySelector(".board-widget__body--scrollable"); + const allow = view.querySelector('[data-test-id="board-grant-allow"]'); + expect(getComputedStyle(body!).overflowY).toBe("auto"); + expect(body!.scrollHeight).toBeGreaterThan(body!.clientHeight); + allow?.focus(); + expect(document.activeElement).toBe(allow); + }); + + it("keeps contained errors scrollable in a one-row widget", async () => { + const view = await mount(); + view.snapshot = { + ...structuredClone(source), + widgets: [{ ...source.widgets[0]!, sizeH: 1 }], + }; + view.widgetFrameUrl = () => { + throw new Error("one-row resolver failed"); + }; + await view.updateComplete; + const cell = view.querySelector("openclaw-board-widget-cell"); + await cell?.updateComplete; + const body = view.querySelector(".board-widget__body--scrollable"); + expect(getComputedStyle(body!).overflowY).toBe("auto"); + expect(body!.scrollHeight).toBeGreaterThan(body!.clientHeight); + expect(body?.textContent).toContain("one-row resolver failed"); + }); +}); diff --git a/ui/src/components/board/board-view.test.ts b/ui/src/components/board/board-view.test.ts new file mode 100644 index 000000000000..0c7f1e6351ed --- /dev/null +++ b/ui/src/components/board/board-view.test.ts @@ -0,0 +1,507 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { BoardSnapshot, BoardViewCallbacks, BoardWidget } from "../../lib/board/view-types.ts"; +import { applyBoardFixtureOps } from "../../test-helpers/board-fixture.ts"; +import "./board-view.ts"; + +type OpenClawBoardView = HTMLElementTagNameMap["openclaw-board-view"]; +type OpenClawBoardWidgetCell = HTMLElementTagNameMap["openclaw-board-widget-cell"]; + +function boardWidget(overrides: Partial = {}): BoardWidget { + return { + name: "alpha", + tabId: "main", + title: "Alpha status", + contentKind: "html", + sizeW: 6, + sizeH: 4, + position: 0, + grantState: "none", + revision: 1, + ...overrides, + }; +} + +function snapshot(overrides: Partial = {}): BoardSnapshot { + return { + sessionKey: "agent:main:test", + revision: 1, + tabs: [ + { tabId: "main", title: "Main", position: 0, chatDock: "right" }, + { tabId: "ops", title: "Operations", position: 1, chatDock: "bottom" }, + ], + widgets: [ + boardWidget(), + boardWidget({ + name: "beta", + title: "Beta chart", + sizeW: 6, + position: 1, + revision: 2, + }), + boardWidget({ + name: "ops-only", + title: "Queue depth", + tabId: "ops", + sizeW: 12, + }), + ], + ...overrides, + }; +} + +function callbacks(overrides: Partial = {}): BoardViewCallbacks { + return { + applyOps: vi.fn(async () => undefined), + grant: vi.fn(async () => undefined), + selectTab: vi.fn(), + ...overrides, + }; +} + +function deferred(): { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +} { + let resolve: () => void = () => undefined; + let reject: (error: Error) => void = () => undefined; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function settleCells(view: OpenClawBoardView): Promise { + await view.updateComplete; + const cells = [...view.querySelectorAll("openclaw-board-widget-cell")]; + await Promise.all(cells.map((cell) => cell.updateComplete)); + return cells; +} + +async function mount( + options: { + snapshot?: BoardSnapshot; + activeTabId?: string; + callbacks?: BoardViewCallbacks; + widgetFrameUrl?: (name: string, revision: number) => string; + } = {}, +): Promise { + const view = document.createElement("openclaw-board-view"); + view.snapshot = options.snapshot ?? snapshot(); + view.activeTabId = options.activeTabId ?? "main"; + view.widgetFrameUrl = options.widgetFrameUrl ?? (() => "about:blank"); + view.callbacks = options.callbacks ?? callbacks(); + document.body.append(view); + await settleCells(view); + return view; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("openclaw-board-view", () => { + it("renders only the active tab widgets with sandboxed frames", async () => { + const view = await mount(); + const cells = view.querySelectorAll('[data-test-id="board-widget"]'); + expect(cells).toHaveLength(2); + expect([...cells].map((cell) => cell.getAttribute("data-widget-name"))).toEqual([ + "alpha", + "beta", + ]); + const frames = view.querySelectorAll("iframe"); + expect(frames).toHaveLength(2); + for (const frame of frames) { + expect(frame.getAttribute("sandbox")).toBe("allow-scripts"); + expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer"); + } + }); + + it("preserves each widget cell and iframe identity when order changes", async () => { + const view = await mount(); + const before = [...view.querySelectorAll("openclaw-board-widget-cell")].find( + (cell) => cell.widget?.name === "alpha", + ); + const frame = before?.querySelector("iframe"); + const removedNodes: Node[] = []; + const observer = new MutationObserver((records) => { + for (const record of records) { + removedNodes.push(...record.removedNodes); + } + }); + observer.observe(view.querySelector(".board-grid")!, { childList: true }); + const reordered = snapshot(); + reordered.widgets = reordered.widgets.map((widget) => + widget.name === "alpha" + ? { ...widget, position: 1 } + : widget.name === "beta" + ? { ...widget, position: 0 } + : widget, + ); + view.snapshot = reordered; + const cells = await settleCells(view); + const after = cells.find((cell) => cell.widget?.name === "alpha"); + expect(after).toBe(before); + expect(after?.querySelector("iframe")).toBe(frame); + expect(removedNodes).not.toContain(before); + expect(after?.querySelector(".board-widget")?.getAttribute("aria-posinset")).toBe("2"); + expect( + cells + .find((cell) => cell.widget?.name === "beta") + ?.querySelector(".board-widget") + ?.getAttribute("aria-posinset"), + ).toBe("1"); + observer.disconnect(); + + view.snapshot = { ...snapshot(), sessionKey: "agent:main:other-session" }; + const sessionCells = await settleCells(view); + const afterSessionChange = sessionCells.find((cell) => cell.widget?.name === "alpha"); + expect(afterSessionChange).not.toBe(before); + expect(afterSessionChange?.querySelector("iframe")).not.toBe(frame); + }); + + it("routes tab selection and updates cells when the host changes the active prop", async () => { + const selectTab = vi.fn(); + const view = await mount({ callbacks: callbacks({ selectTab }) }); + expect(selectTab).not.toHaveBeenCalled(); + view.querySelector(".board-tabs__track")?.dispatchEvent( + new CustomEvent("wa-tab-show", { + detail: { name: "ops" }, + bubbles: true, + }), + ); + expect(selectTab).toHaveBeenCalledWith("ops"); + + view.activeTabId = "ops"; + const cells = await settleCells(view); + expect(cells).toHaveLength(1); + expect(cells[0]?.widget?.name).toBe("ops-only"); + }); + + it("hides the tab strip when the board has only one tab", async () => { + const source = snapshot(); + source.tabs = source.tabs.slice(0, 1); + source.widgets = source.widgets.filter((widget) => widget.tabId === "main"); + const view = await mount({ snapshot: source }); + expect(view.querySelector(".board-tabs")).toBeNull(); + }); + + it("calls applyOps from the kebab remove action", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount({ callbacks: callbacks({ applyOps }) }); + const firstCell = view.querySelector("openclaw-board-widget-cell"); + firstCell?.querySelector(".board-widget__menu-danger")?.click(); + await vi.waitFor(() => { + expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_remove", name: "alpha" }]); + }); + }); + + it("shows failed host operations in the board and originating cell", async () => { + const applyOps = vi.fn(async () => { + throw new Error("fixture write failed"); + }); + const view = await mount({ callbacks: callbacks({ applyOps }) }); + const frame = view.querySelector("iframe"); + view.querySelector(".board-widget__menu-danger")?.click(); + await vi.waitFor(() => { + expect(view.querySelector(".board-view__error")?.textContent).toContain("could not be saved"); + expect( + view.querySelector('[data-test-id="board-widget-action-error"]')?.textContent, + ).toContain("fixture write failed"); + }); + expect(view.querySelector("iframe")).toBe(frame); + view.snapshot = { ...snapshot(), revision: 2 }; + await settleCells(view); + expect(view.querySelector(".board-view__error")).toBeNull(); + }); + + it("allows only one snapshot-derived board mutation at a time", async () => { + const pending = deferred(); + const applyOps = vi.fn(() => pending.promise); + const view = await mount({ callbacks: callbacks({ applyOps }) }); + const cells = [...view.querySelectorAll("openclaw-board-widget-cell")]; + cells[0]?.querySelector(".board-widget__menu-danger")?.click(); + await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(1)); + await settleCells(view); + const secondRemove = cells[1]?.querySelector(".board-widget__menu-danger"); + expect(secondRemove?.disabled).toBe(true); + secondRemove?.click(); + expect(applyOps).toHaveBeenCalledTimes(1); + pending.resolve(); + await vi.waitFor(() => expect(secondRemove?.disabled).toBe(false)); + }); + + it("ignores a late operation failure after switching sessions", async () => { + const pending = deferred(); + const applyOps = vi.fn(() => pending.promise); + const view = await mount({ callbacks: callbacks({ applyOps }) }); + view.querySelector(".board-widget__menu-danger")?.click(); + await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(1)); + view.snapshot = { ...snapshot(), sessionKey: "agent:main:new-session" }; + await settleCells(view); + pending.reject(new Error("old session failed")); + await Promise.resolve(); + await Promise.resolve(); + expect(view.querySelector(".board-view__error")).toBeNull(); + expect(view.querySelector(".board-announcer")?.textContent?.trim()).toBe(""); + }); + + it("renders pending approval without an iframe and routes both decisions", async () => { + const grant = vi.fn(async () => undefined); + const source = snapshot({ + widgets: [boardWidget({ grantState: "pending" })], + }); + const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) }); + expect(view.querySelector('[data-test-id="board-pending"]')).not.toBeNull(); + expect(view.querySelector("iframe")).toBeNull(); + + const allow = view.querySelector('[data-test-id="board-grant-allow"]'); + const reject = view.querySelector('[data-test-id="board-grant-reject"]'); + allow?.click(); + await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "granted")); + await vi.waitFor(() => expect(reject?.disabled).toBe(false)); + reject?.click(); + await vi.waitFor(() => expect(grant).toHaveBeenCalledWith("alpha", "rejected")); + }); + + it("serializes pending approval decisions while the callback is in flight", async () => { + let finishGrant: (() => void) | undefined; + const grant = vi.fn( + async () => + new Promise((resolve) => { + finishGrant = resolve; + }), + ); + const source = snapshot({ widgets: [boardWidget({ grantState: "pending" })] }); + const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) }); + const allow = view.querySelector('[data-test-id="board-grant-allow"]'); + const reject = view.querySelector('[data-test-id="board-grant-reject"]'); + allow?.click(); + reject?.click(); + expect(grant).toHaveBeenCalledTimes(1); + const cell = view.querySelector("openclaw-board-widget-cell"); + await cell?.updateComplete; + expect(allow?.disabled).toBe(true); + expect(reject?.disabled).toBe(true); + finishGrant?.(); + await vi.waitFor(() => expect(allow?.disabled).toBe(false)); + }); + + it("keeps approval controls after a failed decision and clears the error on refresh", async () => { + const grant = vi.fn(async () => { + throw new Error("approval service unavailable"); + }); + const source = snapshot({ widgets: [boardWidget({ grantState: "pending" })] }); + const view = await mount({ snapshot: source, callbacks: callbacks({ grant }) }); + view.querySelector('[data-test-id="board-grant-allow"]')?.click(); + await vi.waitFor(() => { + expect( + view.querySelector('[data-test-id="board-widget-action-error"]')?.textContent, + ).toContain("approval service unavailable"); + }); + expect(view.querySelector('[data-test-id="board-grant-allow"]')).not.toBeNull(); + expect(view.querySelector('[data-test-id="board-grant-reject"]')).not.toBeNull(); + + view.snapshot = structuredClone({ ...source, revision: source.revision + 1 }); + await settleCells(view); + expect(view.querySelector('[data-test-id="board-widget-action-error"]')).toBeNull(); + }); + + it("keeps a rejected widget inert and removable", async () => { + const applyOps = vi.fn(async () => undefined); + const source = snapshot({ widgets: [boardWidget({ grantState: "rejected" })] }); + const view = await mount({ snapshot: source, callbacks: callbacks({ applyOps }) }); + expect(view.querySelector('[data-test-id="board-rejected"]')).not.toBeNull(); + expect(view.querySelector("iframe")).toBeNull(); + view.querySelector('[data-test-id="board-rejected"] button')?.click(); + await vi.waitFor(() => + expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_remove", name: "alpha" }]), + ); + }); + + it("contains one resolver throw without breaking sibling cells", async () => { + const view = await mount({ + widgetFrameUrl: (name) => { + if (name === "alpha") { + throw new Error("fixture resolver failed"); + } + return "about:blank"; + }, + }); + const cells = view.querySelectorAll('[data-test-id="board-widget"]'); + expect(cells).toHaveLength(2); + expect(view.querySelector('[data-test-id="board-widget-error"]')?.textContent).toContain( + "fixture resolver failed", + ); + expect(view.querySelectorAll("iframe")).toHaveLength(1); + }); + + it("renders the friendly empty state", async () => { + const view = await mount({ snapshot: snapshot({ widgets: [] }) }); + expect(view.querySelector('[data-test-id="board-empty"]')?.textContent).toContain( + "Your agent can pin widgets here", + ); + }); + + it("keeps cell focus order stable and exposes move, resize, and menu labels", async () => { + const view = await mount(); + const cells = [...view.querySelectorAll('[data-test-id="board-widget"]')]; + expect(cells.map((cell) => cell.getAttribute("tabindex"))).toEqual(["0", "-1"]); + expect(cells.map((cell) => cell.getAttribute("aria-posinset"))).toEqual(["1", "2"]); + expect(cells.map((cell) => cell.getAttribute("data-widget-name"))).toEqual(["alpha", "beta"]); + for (const cell of cells) { + expect(cell.getAttribute("aria-label")).toContain("Dashboard widget"); + expect(cell.querySelector(".board-widget__drag-handle")?.getAttribute("title")).toMatch( + /^Move /u, + ); + expect(cell.querySelector(".board-widget__drag-handle")?.getAttribute("tabindex")).toBeNull(); + expect(cell.querySelector(".board-widget__resize-handle")?.getAttribute("title")).toMatch( + /^Resize /u, + ); + expect( + cell.querySelector(".board-widget__resize-handle")?.getAttribute("tabindex"), + ).toBeNull(); + expect(cell.querySelector(".board-widget__menu-trigger")?.getAttribute("aria-label")).toBe( + "Widget options", + ); + } + + cells[0]?.focus(); + cells[0]?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + cancelable: true, + }), + ); + await vi.waitFor(() => expect(document.activeElement).toBe(cells[1])); + await vi.waitFor(() => expect(cells[1]?.getAttribute("tabindex")).toBe("0")); + }); + + it("uses Alt+Arrow as the keyboard reorder fallback and announces the move", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount({ callbacks: callbacks({ applyOps }) }); + const secondCell = view.querySelectorAll('[data-test-id="board-widget"]')[1]; + secondCell?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + altKey: true, + bubbles: true, + cancelable: true, + }), + ); + await vi.waitFor(() => + expect(applyOps).toHaveBeenCalledWith([{ kind: "widget_move", name: "beta", position: 0 }]), + ); + await vi.waitFor(() => + expect(view.querySelector(".board-announcer")?.textContent).toContain("Moved Beta chart"), + ); + }); + + it("replaces live-region content for repeated identical announcements", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount({ callbacks: callbacks({ applyOps }) }); + const secondCell = view.querySelectorAll('[data-test-id="board-widget"]')[1]; + const move = () => + secondCell?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowLeft", + altKey: true, + bubbles: true, + cancelable: true, + }), + ); + + move(); + await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(1)); + await view.updateComplete; + const firstAnnouncement = view.querySelector(".board-announcer > span"); + const cell = secondCell?.closest("openclaw-board-widget-cell"); + await vi.waitFor(() => expect(Reflect.get(cell ?? {}, "actionPending")).toBe(false)); + + move(); + await vi.waitFor(() => expect(applyOps).toHaveBeenCalledTimes(2)); + await view.updateComplete; + expect(view.querySelector(".board-announcer > span")).not.toBe(firstAnnouncement); + }); + + it("leaves arrow keys from nested widget controls untouched", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount({ callbacks: callbacks({ applyOps }) }); + const menuButton = view.querySelector(".board-widget__menu-danger"); + menuButton?.focus(); + menuButton?.dispatchEvent( + new KeyboardEvent("keydown", { + key: "ArrowRight", + altKey: true, + bubbles: true, + cancelable: true, + }), + ); + await Promise.resolve(); + expect(document.activeElement).toBe(menuButton); + expect(applyOps).not.toHaveBeenCalled(); + }); + + it("moves widgets to another tab from the kebab menu", async () => { + const applyOps = vi.fn(async () => undefined); + const view = await mount({ callbacks: callbacks({ applyOps }) }); + const moveButton = [...view.querySelectorAll("wa-dropdown-item")].find( + (button) => button.textContent?.trim() === "Operations", + ); + view.querySelector(".board-widget__menu")?.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: moveButton }, + bubbles: true, + }), + ); + await vi.waitFor(() => + expect(applyOps).toHaveBeenCalledWith([ + { kind: "widget_move", name: "alpha", tabId: "ops", position: 1 }, + ]), + ); + }); + + it("places excess tabs in an accessible overflow menu", async () => { + const source = snapshot({ + tabs: Array.from({ length: 8 }, (_entry, position) => ({ + tabId: `tab-${position}`, + title: `Tab ${position}`, + position, + chatDock: "right" as const, + })), + widgets: [], + }); + const selectTab = vi.fn(); + const view = await mount({ + snapshot: source, + activeTabId: "tab-7", + callbacks: callbacks({ selectTab }), + }); + expect(view.querySelector(".board-tabs__overflow-trigger")?.getAttribute("aria-label")).toBe( + "More dashboard tabs", + ); + expect(view.querySelectorAll(".board-tabs__overflow > wa-dropdown-item")).toHaveLength(2); + const firstOverflowTab = view.querySelector(".board-tabs__overflow > wa-dropdown-item"); + view.querySelector(".board-tabs__overflow")?.dispatchEvent( + new CustomEvent("wa-select", { + detail: { item: firstOverflowTab }, + bubbles: true, + }), + ); + expect(selectTab).toHaveBeenCalledWith(firstOverflowTab?.getAttribute("value")); + }); + + it("applies mock moves as insertion and shifts sibling positions", () => { + const moved = applyBoardFixtureOps(snapshot(), [ + { kind: "widget_move", name: "beta", position: 0 }, + ]); + expect( + moved.widgets + .filter((widget) => widget.tabId === "main") + .toSorted((left, right) => left.position - right.position) + .map((widget) => `${widget.name}:${widget.position}`), + ).toEqual(["beta:0", "alpha:1"]); + }); +}); diff --git a/ui/src/components/board/board-view.ts b/ui/src/components/board/board-view.ts new file mode 100644 index 000000000000..345afbfa47b8 --- /dev/null +++ b/ui/src/components/board/board-view.ts @@ -0,0 +1,618 @@ +import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; +import { property, state } from "lit/decorators.js"; +import { keyed } from "lit/directives/keyed.js"; +import { repeat } from "lit/directives/repeat.js"; +import { t } from "../../i18n/index.ts"; +import { + BOARD_GRID_COLUMNS, + BOARD_GRID_GAP, + BOARD_GRID_ROW_HEIGHT, + layout, + nudge, + previewDrag, + resize, + type BoardGridDirection, + type BoardGridItem, +} from "../../lib/board/grid.ts"; +import type { + BoardGrantDecision, + BoardOp, + BoardSnapshot, + BoardTab, + BoardViewCallbacks, + BoardWidget, + BoardWidgetFrameUrl, +} from "../../lib/board/view-types.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import "../../styles/board.css"; +import "../web-awesome-tabs.ts"; +import "../web-awesome.ts"; +import type { BoardWidgetCellCallbacks } from "./board-widget-cell.ts"; +import "./board-widget-cell.ts"; + +type BoardPointerGesture = { + dropValid: boolean; + mode: "move" | "resize"; + name: string; + originClientX: number; + originClientY: number; + originW: number; + originH: number; + pointerId: number; + items: BoardGridItem[]; +}; + +function orderedTabs(snapshot: BoardSnapshot): BoardTab[] { + return snapshot.tabs.toSorted( + (left, right) => left.position - right.position || left.tabId.localeCompare(right.tabId), + ); +} + +function orderedWidgets(snapshot: BoardSnapshot, tabId: string): BoardWidget[] { + return snapshot.widgets + .filter((widget) => widget.tabId === tabId) + .toSorted( + (left, right) => left.position - right.position || left.name.localeCompare(right.name), + ); +} + +function itemsForWidgets(widgets: readonly BoardWidget[]): BoardGridItem[] { + return widgets.map((widget) => ({ + name: widget.name, + w: widget.sizeW, + h: widget.sizeH, + order: widget.position, + })); +} + +class OpenClawBoardView extends OpenClawLightDomElement { + @property({ attribute: false }) snapshot?: BoardSnapshot; + @property({ attribute: false }) activeTabId = ""; + @property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl; + @property({ attribute: false }) callbacks?: BoardViewCallbacks; + + @state() private previewItems: BoardGridItem[] | null = null; + @state() private gestureName = ""; + @state() private hoverTabId = ""; + @state() private announcement = ""; + @state() private announcementRevision = 0; + @state() private actionError = ""; + @state() private focusName = ""; + @state() private mutationPending = false; + + private gesture: BoardPointerGesture | null = null; + private mutationRequestId = 0; + private stableCellOrder = new Map(); + private stableCellOrderSequence = 0; + + override willUpdate(changed: PropertyValues): void { + if (changed.has("snapshot")) { + this.actionError = ""; + const previousSnapshot = changed.get("snapshot"); + if (previousSnapshot?.sessionKey !== this.snapshot?.sessionKey) { + this.mutationRequestId += 1; + this.mutationPending = false; + this.focusName = ""; + this.stableCellOrder.clear(); + this.stableCellOrderSequence = 0; + } + } + if (changed.has("activeTabId")) { + this.focusName = ""; + } + if (this.gesture && (changed.has("snapshot") || changed.has("activeTabId"))) { + this.cancelGesture(); + } + } + + override disconnectedCallback(): void { + this.cancelGesture(); + super.disconnectedCallback(); + } + + private activeTab(tabs: readonly BoardTab[]): BoardTab | undefined { + return tabs.find((tab) => tab.tabId === this.activeTabId) ?? tabs[0]; + } + + private announce(message: string): void { + this.announcement = message; + this.announcementRevision += 1; + } + + private async applyOps(ops: BoardOp[], announcement: string): Promise { + if (!this.callbacks) { + return; + } + if (this.mutationPending) { + throw new Error(t("board.actionInProgress")); + } + const sessionKey = this.snapshot?.sessionKey; + const requestId = this.mutationRequestId + 1; + this.mutationRequestId = requestId; + this.mutationPending = true; + this.actionError = ""; + try { + await this.callbacks.applyOps(ops); + if (requestId === this.mutationRequestId && sessionKey === this.snapshot?.sessionKey) { + this.announce(announcement); + } + } catch (error) { + if (requestId === this.mutationRequestId && sessionKey === this.snapshot?.sessionKey) { + this.actionError = t("board.actionFailed"); + this.announce(this.actionError); + } + throw error; + } finally { + if (requestId === this.mutationRequestId) { + this.mutationPending = false; + } + } + } + + private nextPosition(tabId: string): number { + const positions = this.snapshot?.widgets + .filter((widget) => widget.tabId === tabId) + .map((widget) => widget.position) ?? [0]; + return Math.max(-1, ...positions) + 1; + } + + private readonly cellCallbacks: BoardWidgetCellCallbacks = { + grant: async (name: string, decision: BoardGrantDecision) => { + if (!this.callbacks) { + return; + } + const sessionKey = this.snapshot?.sessionKey; + await this.callbacks.grant(name, decision); + if (sessionKey === this.snapshot?.sessionKey) { + this.announce( + decision === "granted" + ? t("board.announcement.granted") + : t("board.announcement.rejected"), + ); + } + }, + movePointerDown: (widget, event) => this.beginGesture("move", widget, event), + resizePointerDown: (widget, event) => this.beginGesture("resize", widget, event), + moveToTab: async (widget, tabId) => { + await this.applyOps( + [ + { + kind: "widget_move", + name: widget.name, + tabId, + position: this.nextPosition(tabId), + }, + ], + t("board.announcement.moved", { title: widget.title || widget.name }), + ); + }, + resizeTo: async (widget, w, h) => { + await this.applyOps( + [{ kind: "widget_resize", name: widget.name, sizeW: w, sizeH: h }], + t("board.announcement.resized", { title: widget.title || widget.name }), + ); + }, + remove: async (widget) => { + await this.applyOps( + [{ kind: "widget_remove", name: widget.name }], + t("board.announcement.removed", { title: widget.title || widget.name }), + ); + }, + nudge: async (widget, direction) => this.nudgeWidget(widget, direction), + focus: (widget, direction) => this.focusWidget(widget, direction), + focusChanged: (name) => { + this.focusName = name; + }, + }; + + private beginGesture( + mode: BoardPointerGesture["mode"], + widget: BoardWidget, + event: PointerEvent, + ): void { + if (event.button !== 0 || this.gesture || this.mutationPending) { + return; + } + const snapshot = this.snapshot; + const tabs = snapshot ? orderedTabs(snapshot) : []; + const tab = this.activeTab(tabs); + if (!snapshot || !tab) { + return; + } + event.preventDefault(); + event.stopPropagation(); + try { + (event.currentTarget as HTMLElement | null)?.setPointerCapture?.(event.pointerId); + } catch { + // Synthetic pointers and detached test targets cannot be captured. + } + const items = itemsForWidgets(orderedWidgets(snapshot, tab.tabId)); + this.gesture = { + dropValid: false, + mode, + name: widget.name, + originClientX: event.clientX, + originClientY: event.clientY, + originW: widget.sizeW, + originH: widget.sizeH, + pointerId: event.pointerId, + items, + }; + this.previewItems = items; + this.gestureName = widget.name; + window.addEventListener("pointermove", this.handlePointerMove); + window.addEventListener("pointerup", this.handlePointerUp); + window.addEventListener("pointercancel", this.handlePointerCancel); + } + + private readonly handlePointerMove = (event: PointerEvent): void => { + const gesture = this.gesture; + if (!gesture || event.pointerId !== gesture.pointerId) { + return; + } + if (gesture.mode === "move") { + const tabTarget = document + .elementFromPoint(event.clientX, event.clientY) + ?.closest("[data-board-tab-id]"); + const candidateTabId = + tabTarget?.closest("openclaw-board-view") === this + ? (tabTarget.dataset.boardTabId ?? "") + : ""; + const candidateIsValid = + candidateTabId !== "" && + (this.snapshot?.tabs.some((tab) => tab.tabId === candidateTabId) ?? false); + const currentTabId = this.snapshot + ? this.activeTab(orderedTabs(this.snapshot))?.tabId + : this.activeTabId; + this.hoverTabId = candidateIsValid && candidateTabId !== currentTabId ? candidateTabId : ""; + if (tabTarget) { + this.previewItems = gesture.items; + gesture.dropValid = this.hoverTabId !== ""; + return; + } + const grid = this.querySelector(".board-grid"); + const pointerElement = document.elementFromPoint(event.clientX, event.clientY); + if (!grid || pointerElement?.closest(".board-grid") !== grid) { + this.hoverTabId = ""; + this.previewItems = gesture.items; + gesture.dropValid = false; + return; + } + gesture.dropValid = true; + const bounds = grid.getBoundingClientRect(); + const columnWidth = Math.max( + 1, + (bounds.width - BOARD_GRID_GAP * (BOARD_GRID_COLUMNS - 1)) / BOARD_GRID_COLUMNS, + ); + const targetCell = { + x: Math.floor((event.clientX - bounds.left) / (columnWidth + BOARD_GRID_GAP)), + y: Math.floor((event.clientY - bounds.top) / (BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP)), + }; + this.previewItems = previewDrag(gesture.items, gesture.name, targetCell).items; + return; + } + + const grid = this.querySelector(".board-grid"); + const bounds = grid?.getBoundingClientRect(); + const columnWidth = bounds + ? Math.max(1, (bounds.width - BOARD_GRID_GAP * (BOARD_GRID_COLUMNS - 1)) / BOARD_GRID_COLUMNS) + : BOARD_GRID_ROW_HEIGHT; + const deltaW = Math.round( + (event.clientX - gesture.originClientX) / (columnWidth + BOARD_GRID_GAP), + ); + const deltaH = Math.round( + (event.clientY - gesture.originClientY) / (BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP), + ); + this.previewItems = resize( + gesture.items, + gesture.name, + gesture.originW + deltaW, + gesture.originH + deltaH, + ); + }; + + private readonly handlePointerUp = (event: PointerEvent): void => { + const gesture = this.gesture; + if (!gesture || event.pointerId !== gesture.pointerId) { + return; + } + this.handlePointerMove(event); + const previewItems = this.previewItems; + const hoverTabId = this.hoverTabId; + this.cancelGesture(); + const widget = this.snapshot?.widgets.find((entry) => entry.name === gesture.name); + if (!widget) { + return; + } + if (gesture.mode === "move") { + if (!gesture.dropValid) { + return; + } + const position = hoverTabId + ? this.nextPosition(hoverTabId) + : (previewItems?.find((item) => item.name === gesture.name)?.order ?? widget.position); + if (!hoverTabId && position === widget.position) { + return; + } + void this.applyOps( + [ + { + kind: "widget_move", + name: gesture.name, + ...(hoverTabId ? { tabId: hoverTabId } : {}), + position, + }, + ], + t("board.announcement.moved", { title: widget.title || widget.name }), + ).catch(() => undefined); + return; + } + const resized = previewItems?.find((item) => item.name === gesture.name); + if (resized && (resized.w !== widget.sizeW || resized.h !== widget.sizeH)) { + void this.applyOps( + [ + { + kind: "widget_resize", + name: gesture.name, + sizeW: resized.w, + sizeH: resized.h, + }, + ], + t("board.announcement.resized", { title: widget.title || widget.name }), + ).catch(() => undefined); + } + }; + + private readonly handlePointerCancel = (event: PointerEvent): void => { + if (this.gesture && event.pointerId === this.gesture.pointerId) { + this.cancelGesture(); + } + }; + + private cancelGesture(): void { + window.removeEventListener("pointermove", this.handlePointerMove); + window.removeEventListener("pointerup", this.handlePointerUp); + window.removeEventListener("pointercancel", this.handlePointerCancel); + this.gesture = null; + this.previewItems = null; + this.gestureName = ""; + this.hoverTabId = ""; + } + + private async nudgeWidget(widget: BoardWidget, direction: BoardGridDirection): Promise { + const snapshot = this.snapshot; + if (!snapshot) { + return; + } + const items = itemsForWidgets(orderedWidgets(snapshot, widget.tabId)); + const moved = nudge(items, widget.name, direction).find((item) => item.name === widget.name); + if (!moved || moved.order === widget.position) { + return; + } + await this.applyOps( + [{ kind: "widget_move", name: widget.name, position: moved.order }], + t("board.announcement.moved", { title: widget.title || widget.name }), + ); + } + + private focusWidget(widget: BoardWidget, direction: BoardGridDirection): void { + const snapshot = this.snapshot; + if (!snapshot) { + return; + } + const widgets = orderedWidgets(snapshot, widget.tabId); + const index = widgets.findIndex((entry) => entry.name === widget.name); + if (index < 0) { + return; + } + const offset = direction === "left" || direction === "up" ? -1 : 1; + const target = widgets[Math.max(0, Math.min(index + offset, widgets.length - 1))]; + if (!target || target.name === widget.name) { + return; + } + this.focusName = target.name; + void this.updateComplete.then(() => { + const cell = [...this.querySelectorAll("openclaw-board-widget-cell")].find( + (entry) => entry.widget?.name === target.name, + ); + cell?.querySelector(".board-widget")?.focus(); + }); + } + + private readonly handleTabShow = (event: CustomEvent<{ name: string }>): void => { + const tabs = this.snapshot ? orderedTabs(this.snapshot) : []; + const currentTabId = this.activeTab(tabs)?.tabId ?? this.activeTabId; + if (event.detail.name !== currentTabId && tabs.some((tab) => tab.tabId === event.detail.name)) { + this.callbacks?.selectTab(event.detail.name); + } + }; + + private readonly handleOverflowSelect = ( + event: CustomEvent<{ item: { value?: string } }>, + ): void => { + const tabId = event.detail.item.value; + if (tabId && this.snapshot?.tabs.some((tab) => tab.tabId === tabId)) { + this.callbacks?.selectTab(tabId); + } + }; + + private renderTab(tab: BoardTab, activeTabId: string): TemplateResult { + const active = tab.tabId === activeTabId; + const dropTarget = tab.tabId === this.hoverTabId; + return html` + + ${tab.title} + + `; + } + + private renderOverflowTab(tab: BoardTab): TemplateResult { + return html` + + ${tab.title} + + `; + } + + private renderTabs( + tabs: readonly BoardTab[], + activeTabId: string, + ): TemplateResult | typeof nothing { + if (tabs.length <= 1) { + return nothing; + } + const visible = tabs.slice(0, 6); + const active = tabs.find((tab) => tab.tabId === activeTabId); + if (active && !visible.some((tab) => tab.tabId === active.tabId)) { + visible[visible.length - 1] = active; + } + const visibleIds = new Set(visible.map((tab) => tab.tabId)); + const overflow = tabs.filter((tab) => !visibleIds.has(tab.tabId)); + return html` + + `; + } + + private renderGrid( + widgets: readonly BoardWidget[], + tabs: readonly BoardTab[], + sessionKey: string, + ): TemplateResult { + if (widgets.length === 0) { + return html` +
+ + ${t("board.emptyTitle")} + ${t("board.emptyHint")} +
+ `; + } + const items = this.previewItems ?? itemsForWidgets(widgets); + const rects = layout(items); + for (const rect of rects) { + if (!this.stableCellOrder.has(rect.name)) { + this.stableCellOrder.set(rect.name, this.stableCellOrderSequence); + this.stableCellOrderSequence += 1; + } + } + const stableRects = rects.toSorted( + (left, right) => + (this.stableCellOrder.get(left.name) ?? 0) - (this.stableCellOrder.get(right.name) ?? 0) || + left.name.localeCompare(right.name), + ); + const logicalPosition = new Map(rects.map((rect, index) => [rect.name, index])); + const focusName = rects.some((rect) => rect.name === this.focusName) + ? this.focusName + : (rects[0]?.name ?? ""); + const widgetByName = new Map(widgets.map((widget) => [widget.name, widget])); + return html` +
+ ${repeat( + stableRects, + (rect) => `${sessionKey}\u0000${rect.name}`, + (rect) => { + const widget = widgetByName.get(rect.name); + if (!widget) { + return nothing; + } + return html` + + `; + }, + )} + ${this.gesture?.mode === "move" + ? html`` + : nothing} +
+ `; + } + + override render() { + const snapshot = this.snapshot; + if (!snapshot) { + return nothing; + } + const tabs = orderedTabs(snapshot); + const activeTab = this.activeTab(tabs); + const activeTabId = activeTab?.tabId ?? this.activeTabId; + const widgets = activeTab ? orderedWidgets(snapshot, activeTab.tabId) : []; + return html` +
+ ${this.renderTabs(tabs, activeTabId)} ${this.renderGrid(widgets, tabs, snapshot.sessionKey)} + ${this.actionError + ? html`` + : nothing} +
+ ${this.announcement + ? keyed( + this.announcementRevision, + html`${this.announcement}`, + ) + : nothing} +
+
+ `; + } +} + +if (!customElements.get("openclaw-board-view")) { + customElements.define("openclaw-board-view", OpenClawBoardView); +} + +declare global { + interface HTMLElementTagNameMap { + "openclaw-board-view": OpenClawBoardView; + } +} diff --git a/ui/src/components/board/board-widget-cell.ts b/ui/src/components/board/board-widget-cell.ts new file mode 100644 index 000000000000..491c201cef50 --- /dev/null +++ b/ui/src/components/board/board-widget-cell.ts @@ -0,0 +1,374 @@ +import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; +import { property, state } from "lit/decorators.js"; +import { t } from "../../i18n/index.ts"; +import type { BoardGridDirection, BoardGridRect } from "../../lib/board/grid.ts"; +import { toCssPlacement } from "../../lib/board/grid.ts"; +import type { + BoardGrantDecision, + BoardTab, + BoardWidget, + BoardWidgetFrameUrl, +} from "../../lib/board/view-types.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; +import "../web-awesome.ts"; + +const BOARD_SIZE_PRESETS = { + sm: { w: 3, h: 3 }, + md: { w: 6, h: 4 }, + lg: { w: 8, h: 6 }, + xl: { w: 12, h: 8 }, +} as const; + +export type BoardWidgetCellCallbacks = { + grant: (name: string, decision: BoardGrantDecision) => Promise; + movePointerDown: (widget: BoardWidget, event: PointerEvent) => void; + resizePointerDown: (widget: BoardWidget, event: PointerEvent) => void; + moveToTab: (widget: BoardWidget, tabId: string) => Promise; + resizeTo: (widget: BoardWidget, w: number, h: number) => Promise; + remove: (widget: BoardWidget) => Promise; + nudge: (widget: BoardWidget, direction: BoardGridDirection) => Promise; + focus: (widget: BoardWidget, direction: BoardGridDirection) => void; + focusChanged: (name: string) => void; +}; + +class OpenClawBoardWidgetCell extends OpenClawLightDomElement { + @property({ attribute: false }) widget?: BoardWidget; + @property({ attribute: false }) rect?: BoardGridRect; + @property({ attribute: false }) tabs: readonly BoardTab[] = []; + @property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl; + @property({ attribute: false }) callbacks?: BoardWidgetCellCallbacks; + @property({ type: Boolean }) dragging = false; + @property({ type: Number }) focusTabIndex = -1; + @property({ type: Number }) positionInSet = 1; + @property({ type: Number }) setSize = 1; + @property({ type: Boolean }) busy = false; + + @state() private actionError = ""; + @state() private actionPending = false; + + override willUpdate(changed: PropertyValues): void { + const previousWidget = changed.get("widget"); + if (previousWidget && previousWidget !== this.widget) { + this.actionError = ""; + } + } + + private closeMenu(): void { + const menu = this.querySelector(".board-widget__menu"); + if (menu) { + menu.open = false; + } + } + + private async runAction(action: () => Promise): Promise { + if (this.actionPending || this.busy) { + return; + } + this.actionPending = true; + this.actionError = ""; + this.closeMenu(); + try { + await action(); + } catch (error) { + this.actionError = error instanceof Error ? error.message : String(error); + } finally { + this.actionPending = false; + } + } + + private handleMenuSelect( + event: CustomEvent<{ item: { value?: string } }>, + widget: BoardWidget, + callbacks: BoardWidgetCellCallbacks, + ): void { + const value = event.detail.item.value; + if (value === "remove") { + void this.runAction(() => callbacks.remove(widget)); + return; + } + if (value?.startsWith("move:")) { + void this.runAction(() => callbacks.moveToTab(widget, value.slice("move:".length))); + return; + } + if (value?.startsWith("resize:")) { + const preset = value.slice("resize:".length) as keyof typeof BOARD_SIZE_PRESETS; + const size = BOARD_SIZE_PRESETS[preset]; + if (size) { + void this.runAction(() => callbacks.resizeTo(widget, size.w, size.h)); + } + } + } + + private renderMenu(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult { + const otherTabs = this.tabs.filter((tab) => tab.tabId !== widget.tabId); + return html` + ) => + this.handleMenuSelect(event, widget, callbacks)} + > + +
${t("board.widget.moveToTab")}
+ ${otherTabs.length > 0 + ? otherTabs.map( + (tab) => html` + + ${tab.title} + + `, + ) + : html`${t("board.widget.noOtherTabs")}`} +
${t("board.widget.resize")}
+ ${Object.entries(BOARD_SIZE_PRESETS).map( + ([label, size]) => html` + + ${label.toUpperCase()} + ${size.w}×${size.h} + + `, + )} + + + ${t("board.widget.remove")} + +
+ `; + } + + private renderPending(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult { + return html` +
+ + ${t("board.widget.needsApproval")} + ${t("board.widget.needsApprovalDetail")} +
+ + +
+ ${this.actionError ? this.renderActionError(this.actionError, true) : nothing} +
+ `; + } + + private renderRejected(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult { + return html` +
+ ${t("board.widget.rejected")} + ${t("board.widget.rejectedDetail")} + +
+ `; + } + + private renderFrame(widget: BoardWidget): TemplateResult { + if (!this.widgetFrameUrl) { + throw new Error(t("board.widget.frameResolverMissing")); + } + const src = this.widgetFrameUrl(widget.name, widget.revision); + return html` + + `; + } + + private renderBody(widget: BoardWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult { + if (widget.grantState === "pending") { + return this.renderPending(widget, callbacks); + } + if (widget.grantState === "rejected") { + return this.renderRejected(widget, callbacks); + } + return this.renderFrame(widget); + } + + private renderError(error: unknown): TemplateResult { + const message = error instanceof Error ? error.message : String(error); + return html` + + `; + } + + private renderActionError(error: string, inline = false): TemplateResult { + return html` + + `; + } + + 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` +
callbacks.focusChanged(widget.name)} + @keydown=${(event: KeyboardEvent) => this.handleKeyDown(event, widget, callbacks)} + > +
+ + ${label} + ${widget.contentKind === "mcp-app" + ? t("board.widget.kindMcp") + : t("board.widget.kindHtml")} + ${this.renderMenu(widget, callbacks)} +
+
+ ${body} + ${this.actionError && widget.grantState !== "pending" + ? html`
+ ${this.renderActionError(this.actionError)} +
` + : nothing} +
+ +
+ `; + } +} + +if (!customElements.get("openclaw-board-widget-cell")) { + customElements.define("openclaw-board-widget-cell", OpenClawBoardWidgetCell); +} + +declare global { + interface HTMLElementTagNameMap { + "openclaw-board-widget-cell": OpenClawBoardWidgetCell; + } +} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 44e2b2d4e4a7..e03e92f0773d 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2472,6 +2472,48 @@ export const en: TranslationMap = { submit: "Ask", }, }, + board: { + label: "Session dashboard", + tabsLabel: "Dashboard tabs", + moreTabs: "More dashboard tabs", + gridLabel: "Dashboard widgets", + emptyTitle: "A clear board, ready for work", + emptyHint: "Your agent can pin widgets here — try asking for a status card.", + actionFailed: "The dashboard change could not be saved.", + actionInProgress: "Another dashboard change is still being saved.", + announcement: { + moved: "Moved {title}.", + resized: "Resized {title}.", + removed: "Removed {title}.", + granted: "Widget access allowed.", + rejected: "Widget access rejected.", + }, + widget: { + cellLabel: + "Dashboard widget: {title}. Use arrow keys to navigate. Hold Alt and press an arrow key to move it.", + moveHandle: "Move {title}", + resizeHandle: "Resize {title}", + menuLabel: "Widget options", + moveToTab: "Move to tab", + noOtherTabs: "No other tabs", + resize: "Resize", + remove: "Remove", + needsApproval: "Needs approval", + needsApprovalDetail: "This widget requested additional access.", + allow: "Allow", + reject: "Reject", + rejected: "Access rejected", + rejectedDetail: "This widget stays inactive until it is removed or replaced.", + frameResolverMissing: "Widget content is unavailable.", + errorTitle: "This widget could not load", + errorDetail: "The problem is contained to this card.", + actionErrorTitle: "Widget change failed", + actionErrorDetail: "The dashboard kept the previous widget state.", + errorShow: "Show details", + kindMcp: "MCP", + kindHtml: "HTML", + }, + }, workboard: { disabledHelpStart: "Workboard is disabled. Enable", enableConfigKey: "plugins.entries.workboard.enabled = true", diff --git a/ui/src/lib/board/grid.test.ts b/ui/src/lib/board/grid.test.ts new file mode 100644 index 000000000000..9d2677c04a0d --- /dev/null +++ b/ui/src/lib/board/grid.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { + BOARD_GRID_COLUMNS, + BOARD_GRID_GAP, + BOARD_GRID_ROW_HEIGHT, + layout, + nudge, + previewDrag, + resize, + toCssPlacement, + type BoardGridItem, + type BoardGridRect, +} from "./grid.ts"; + +function item(name: string, w: number, h: number, order: number): BoardGridItem { + return { name, w, h, order }; +} + +function overlaps(left: BoardGridRect, right: BoardGridRect): boolean { + return ( + left.x < right.x + right.w && + right.x < left.x + left.w && + left.y < right.y + right.h && + right.y < left.y + left.h + ); +} + +function expectValid(rects: readonly BoardGridRect[]): void { + for (const [index, rect] of rects.entries()) { + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.x + rect.w).toBeLessThanOrEqual(BOARD_GRID_COLUMNS); + expect(rect.y).toBeGreaterThanOrEqual(0); + expect(rect.w).toBeGreaterThanOrEqual(1); + expect(rect.h).toBeGreaterThanOrEqual(1); + for (const other of rects.slice(index + 1)) { + expect(overlaps(rect, other), `${rect.name} overlaps ${other.name}`).toBe(false); + } + } +} + +function expectGravityTight(rects: readonly BoardGridRect[]): void { + for (const rect of rects) { + if (rect.y === 0) { + continue; + } + const raised = { ...rect, y: rect.y - 1 }; + expect( + rects.some((other) => other.name !== rect.name && overlaps(raised, other)), + `${rect.name} leaves a hole above itself`, + ).toBe(true); + } +} + +function propertyItems(seed: number, count: number): BoardGridItem[] { + let value = seed; + const random = () => { + value = (value * 1_664_525 + 1_013_904_223) >>> 0; + return value; + }; + return Array.from({ length: count }, (_entry, order) => + item( + `widget-${String(order).padStart(2, "0")}`, + (random() % 18) - 2, + (random() % 28) - 3, + order, + ), + ); +} + +describe("board grid layout", () => { + it("exports the shared geometry constants", () => { + expect({ + columns: BOARD_GRID_COLUMNS, + rowHeight: BOARD_GRID_ROW_HEIGHT, + gap: BOARD_GRID_GAP, + }).toEqual({ columns: 12, rowHeight: 56, gap: 12 }); + }); + + it("flows first-fit from left to right and then downward", () => { + expect(layout([item("a", 6, 2, 0), item("b", 6, 1, 1), item("c", 3, 1, 2)])).toEqual([ + { name: "a", x: 0, y: 0, w: 6, h: 2 }, + { name: "b", x: 6, y: 0, w: 6, h: 1 }, + { name: "c", x: 6, y: 1, w: 3, h: 1 }, + ]); + }); + + it("is deterministic by explicit order even when input order changes", () => { + const items = [item("third", 3, 1, 30), item("first", 4, 2, 10), item("second", 8, 1, 20)]; + expect(layout(items)).toEqual(layout(items.toReversed())); + expect(layout(items).map((rect) => rect.name)).toEqual(["first", "second", "third"]); + }); + + it("breaks duplicate order ties by name", () => { + expect(layout([item("z", 2, 1, 0), item("a", 2, 1, 0)]).map((rect) => rect.name)).toEqual([ + "a", + "z", + ]); + }); + + it("never overlaps, escapes the columns, or leaves vertical holes across generated cases", () => { + for (let seed = 1; seed <= 40; seed += 1) { + const rects = layout(propertyItems(seed, 35)); + expectValid(rects); + expectGravityTight(rects); + } + }); + + it("clamps invalid dimensions without mutating source items", () => { + const items = [item("wide", 99, 0, 0), item("tall", -3, 99, 1)]; + const before = structuredClone(items); + const rects = layout(items); + expect(rects).toEqual([ + { name: "wide", x: 0, y: 0, w: 12, h: 1 }, + { name: "tall", x: 0, y: 1, w: 1, h: 20 }, + ]); + expect(items).toEqual(before); + }); +}); + +describe("board grid drag preview", () => { + const items = [item("a", 4, 2, 0), item("b", 4, 2, 1), item("c", 4, 2, 2)]; + + it("pushes an occupied target and its followers aside", () => { + const preview = previewDrag(items, "c", { x: 1, y: 0 }); + expect(preview.items.map((entry) => [entry.name, entry.order])).toEqual([ + ["c", 0], + ["a", 1], + ["b", 2], + ]); + expect(preview.rects.map((rect) => rect.name)).toEqual(["c", "a", "b"]); + expectValid(preview.rects); + }); + + it("keeps the order while the pointer remains inside the dragged rect", () => { + expect(previewDrag(items, "b", { x: 6, y: 1 }).items.map((entry) => entry.name)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("resolves occupied targets before removing and compacting the moving item", () => { + const preview = previewDrag([item("a", 6, 1, 0), item("b", 6, 1, 1), item("c", 3, 1, 2)], "a", { + x: 6, + y: 0, + }); + expect(preview.items.map((entry) => entry.name)).toEqual(["a", "b", "c"]); + }); + + it("chooses the next empty-cell target by rendered row-major geometry", () => { + const heterogeneous = [ + item("a", 1, 4, 0), + item("b", 3, 2, 1), + item("c", 5, 4, 2), + item("d", 7, 2, 3), + item("e", 1, 4, 4), + item("f", 3, 2, 5), + item("g", 9, 4, 6), + item("h", 11, 2, 7), + ]; + expect( + previewDrag(heterogeneous, "a", { x: 10, y: 0 }).items.map((entry) => entry.name), + ).toEqual(["b", "c", "d", "e", "a", "f", "g", "h"]); + }); + + it("appends from an empty row tail or below the board", () => { + const tailItems = [item("a", 3, 2, 0), item("b", 3, 2, 1), item("c", 3, 2, 2)]; + expect(previewDrag(tailItems, "a", { x: 10, y: 0 }).items.map((entry) => entry.name)).toEqual([ + "b", + "c", + "a", + ]); + expect(previewDrag(items, "a", { x: 100, y: 100 }).items.map((entry) => entry.name)).toEqual([ + "b", + "c", + "a", + ]); + }); + + it("is deterministic and leaves inputs unchanged", () => { + const before = structuredClone(items); + expect(previewDrag(items, "c", { x: 0, y: 0 })).toEqual( + previewDrag(items, "c", { x: 0, y: 0 }), + ); + expect(items).toEqual(before); + }); + + it("preserves compaction invariants across generated drag targets", () => { + for (let seed = 1; seed <= 24; seed += 1) { + const generated = propertyItems(seed, 24); + const moving = generated[(seed * 7) % generated.length]!; + const preview = previewDrag(generated, moving.name, { + x: (seed * 5) % BOARD_GRID_COLUMNS, + y: (seed * 3) % 18, + }); + expectValid(preview.rects); + expectGravityTight(preview.rects); + expect(preview.items.map((entry) => entry.order)).toEqual( + Array.from({ length: generated.length }, (_entry, order) => order), + ); + } + }); + + it("returns a compact canonical board for an unknown item", () => { + const preview = previewDrag([item("b", 3, 1, 5), item("a", 3, 1, 2)], "missing", { + x: 0, + y: 0, + }); + expect(preview.items.map((entry) => [entry.name, entry.order])).toEqual([ + ["a", 0], + ["b", 1], + ]); + expectValid(preview.rects); + }); +}); + +describe("board grid mutations", () => { + it("resizes immutably and clamps both axes", () => { + const items = [item("a", 3, 3, 0), item("b", 4, 4, 1)]; + expect(resize(items, "a", 40, -2)).toEqual([item("a", 12, 1, 0), item("b", 4, 4, 1)]); + expect(items[0]).toEqual(item("a", 3, 3, 0)); + }); + + it.each([ + ["left", ["b", "a", "c"]], + ["up", ["b", "a", "c"]], + ["right", ["a", "c", "b"]], + ["down", ["a", "c", "b"]], + ] as const)("nudges order %s", (direction, expected) => { + const result = nudge( + [item("a", 1, 1, 0), item("b", 1, 1, 1), item("c", 1, 1, 2)], + "b", + direction, + ); + expect(result.map((entry) => entry.name)).toEqual(expected); + expect(result.map((entry) => entry.order)).toEqual([0, 1, 2]); + }); + + it("does not nudge beyond either order boundary", () => { + const items = [item("a", 1, 1, 0), item("b", 1, 1, 1)]; + expect(nudge(items, "a", "up").map((entry) => entry.name)).toEqual(["a", "b"]); + expect(nudge(items, "b", "right").map((entry) => entry.name)).toEqual(["a", "b"]); + }); + + it("serializes one-based CSS grid placement", () => { + expect(toCssPlacement({ name: "chart", x: 2, y: 4, w: 5, h: 3 })).toBe( + "grid-column: 3 / span 5; grid-row: 5 / span 3;", + ); + }); +}); diff --git a/ui/src/lib/board/grid.ts b/ui/src/lib/board/grid.ts new file mode 100644 index 000000000000..9d918ab65b67 --- /dev/null +++ b/ui/src/lib/board/grid.ts @@ -0,0 +1,199 @@ +/** Pure order-and-size layout for the session dashboard board. */ + +export const BOARD_GRID_COLUMNS = 12; +export const BOARD_GRID_ROW_HEIGHT = 56; +export const BOARD_GRID_GAP = 12; +const BOARD_GRID_MAX_HEIGHT = 20; + +export type BoardGridItem = { + name: string; + w: number; + h: number; + order: number; +}; + +export type BoardGridRect = { + name: string; + x: number; + y: number; + w: number; + h: number; +}; + +type BoardGridCell = { + x: number; + y: number; +}; + +export type BoardGridDirection = "left" | "right" | "up" | "down"; + +type BoardGridPreview = { + items: BoardGridItem[]; + rects: BoardGridRect[]; +}; + +function clampInteger(value: number, minimum: number, maximum: number): number { + const integer = Number.isFinite(value) ? Math.round(value) : minimum; + return Math.min(maximum, Math.max(minimum, integer)); +} + +function withOrder(item: BoardGridItem, order: number): BoardGridItem { + return { name: item.name, w: item.w, h: item.h, order }; +} + +function canonicalItems(items: readonly BoardGridItem[]): BoardGridItem[] { + return items + .map((item) => ({ + name: item.name, + w: clampInteger(item.w, 1, BOARD_GRID_COLUMNS), + h: clampInteger(item.h, 1, BOARD_GRID_MAX_HEIGHT), + order: Number.isFinite(item.order) ? item.order : 0, + })) + .toSorted((left, right) => left.order - right.order || left.name.localeCompare(right.name)) + .map(withOrder); +} + +function fits(occupied: readonly boolean[][], x: number, y: number, w: number, h: number): boolean { + for (let row = y; row < y + h; row += 1) { + for (let column = x; column < x + w; column += 1) { + if (occupied[row]?.[column]) { + return false; + } + } + } + return true; +} + +function occupy(occupied: boolean[][], rect: BoardGridRect): void { + for (let row = rect.y; row < rect.y + rect.h; row += 1) { + const cells = occupied[row] ?? Array.from({ length: BOARD_GRID_COLUMNS }, () => false); + occupied[row] = cells; + for (let column = rect.x; column < rect.x + rect.w; column += 1) { + cells[column] = true; + } + } +} + +function firstFit(occupied: readonly boolean[][], item: BoardGridItem): BoardGridRect { + for (let y = 0; ; y += 1) { + for (let x = 0; x <= BOARD_GRID_COLUMNS - item.w; x += 1) { + if (fits(occupied, x, y, item.w, item.h)) { + return { name: item.name, x, y, w: item.w, h: item.h }; + } + } + } +} + +/** + * Places canonical-order items at the first available row-major cell. Earlier + * items therefore keep priority while every later item is gravity-tight. + */ +export function layout(items: readonly BoardGridItem[]): BoardGridRect[] { + const occupied: boolean[][] = []; + const rects: BoardGridRect[] = []; + for (const item of canonicalItems(items)) { + const placed = firstFit(occupied, item); + occupy(occupied, placed); + rects.push(placed); + } + return rects; +} + +function contains(rect: BoardGridRect, cell: BoardGridCell): boolean { + return ( + cell.x >= rect.x && cell.x < rect.x + rect.w && cell.y >= rect.y && cell.y < rect.y + rect.h + ); +} + +/** + * Reorders one item around the target cell, then fully reflows the board. + * Occupied targets insert before their occupant: that item and its followers + * are pushed aside by the normal first-fit pass. + */ +export function previewDrag( + items: readonly BoardGridItem[], + name: string, + targetCell: BoardGridCell, +): BoardGridPreview { + const canonical = canonicalItems(items); + const movingIndex = canonical.findIndex((item) => item.name === name); + if (movingIndex < 0) { + return { items: canonical, rects: layout(canonical) }; + } + + const currentRects = layout(canonical); + const cell = { + x: clampInteger(targetCell.x, 0, BOARD_GRID_COLUMNS - 1), + y: Math.max(0, Number.isFinite(targetCell.y) ? Math.floor(targetCell.y) : 0), + }; + const currentRect = currentRects.find((rect) => rect.name === name); + if (currentRect && contains(currentRect, cell)) { + return { items: canonical, rects: currentRects }; + } + + const [moving] = canonical.splice(movingIndex, 1); + if (!moving) { + return { items: canonical, rects: layout(canonical) }; + } + const occupiedTarget = currentRects.find((rect) => rect.name !== name && contains(rect, cell)); + const nextRect = + occupiedTarget ?? + currentRects + .filter( + (rect) => + rect.name !== name && (rect.y > cell.y || (rect.y === cell.y && rect.x >= cell.x)), + ) + .toSorted((left, right) => left.y - right.y || left.x - right.x)[0]; + const insertionIndex = nextRect + ? canonical.findIndex((item) => item.name === nextRect.name) + : canonical.length; + canonical.splice(Math.max(0, insertionIndex), 0, moving); + const reordered = canonical.map(withOrder); + return { items: reordered, rects: layout(reordered) }; +} + +/** Returns a new canonical item list with one clamped size change. */ +export function resize( + items: readonly BoardGridItem[], + name: string, + w: number, + h: number, +): BoardGridItem[] { + return canonicalItems(items).map((item) => + item.name === name + ? { + name: item.name, + w: clampInteger(w, 1, BOARD_GRID_COLUMNS), + h: clampInteger(h, 1, BOARD_GRID_MAX_HEIGHT), + order: item.order, + } + : item, + ); +} + +/** Arrow-key fallback: left/up move earlier; right/down move later. */ +export function nudge( + items: readonly BoardGridItem[], + name: string, + direction: BoardGridDirection, +): BoardGridItem[] { + const canonical = canonicalItems(items); + const index = canonical.findIndex((item) => item.name === name); + if (index < 0) { + return canonical; + } + const delta = direction === "left" || direction === "up" ? -1 : 1; + const target = Math.min(canonical.length - 1, Math.max(0, index + delta)); + if (target !== index) { + const [moving] = canonical.splice(index, 1); + if (moving) { + canonical.splice(target, 0, moving); + } + } + return canonical.map(withOrder); +} + +/** CSS grid lines are one-based; engine cells are zero-based. */ +export function toCssPlacement(rect: BoardGridRect): string { + return `grid-column: ${rect.x + 1} / span ${rect.w}; grid-row: ${rect.y + 1} / span ${rect.h};`; +} diff --git a/ui/src/lib/board/view-types.ts b/ui/src/lib/board/view-types.ts new file mode 100644 index 000000000000..5596b62f3274 --- /dev/null +++ b/ui/src/lib/board/view-types.ts @@ -0,0 +1,55 @@ +/** Temporary board view contract. A stitch commit will point these at gateway-protocol. */ + +type BoardChatDock = "left" | "right" | "bottom" | "hidden"; +type BoardGrantState = "none" | "pending" | "granted" | "rejected"; +export type BoardGrantDecision = "granted" | "rejected"; + +export type BoardTab = { + tabId: string; + title: string; + position: number; + chatDock: BoardChatDock; +}; + +export type BoardWidget = { + name: string; + tabId: string; + title?: string; + contentKind: "html" | "mcp-app"; + sizeW: number; + sizeH: number; + position: number; + grantState: BoardGrantState; + revision: number; +}; + +export type BoardSnapshot = { + sessionKey: string; + revision: number; + tabs: BoardTab[]; + widgets: BoardWidget[]; +}; + +export type BoardOp = + | { kind: "tab_create"; tabId: string; title: string; chatDock?: BoardChatDock } + | { + kind: "tab_update"; + tabId: string; + title?: string; + chatDock?: BoardChatDock; + position?: number; + } + | { kind: "tab_delete"; tabId: string } + | { kind: "tabs_reorder"; tabIds: string[] } + | { kind: "widget_move"; name: string; tabId?: string; position?: number; after?: string } + | { kind: "widget_resize"; name: string; sizeW: number; sizeH: number } + | { kind: "widget_remove"; name: string }; + +export type BoardViewCallbacks = { + applyOps: (ops: BoardOp[]) => Promise; + grant: (name: string, decision: BoardGrantDecision) => Promise; + selectTab: (tabId: string) => void; + pinRequest?: never; +}; + +export type BoardWidgetFrameUrl = (name: string, revision: number) => string; diff --git a/ui/src/main.ts b/ui/src/main.ts index 33b27250302e..68b35f937517 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,6 +1,7 @@ // Control UI module implements main behavior. import "./styles.css"; import "./app/app-host.ts"; +import "./components/board/board-view.ts"; import { inferControlUiPublicAssetPath } from "./app/public-assets.ts"; import { installMissingStylesheetRecovery, diff --git a/ui/src/styles/board.css b/ui/src/styles/board.css new file mode 100644 index 000000000000..3aa8f6d3f16a --- /dev/null +++ b/ui/src/styles/board.css @@ -0,0 +1,448 @@ +openclaw-board-view { + display: block; + min-width: 0; + width: 100%; +} + +.board-view { + --board-line: color-mix(in srgb, var(--border, #2c313a) 84%, transparent); + --board-surface: color-mix(in srgb, var(--panel, #171a20) 94%, transparent); + display: grid; + gap: 12px; + min-width: 0; + position: relative; +} + +.board-view__error { + background: color-mix(in srgb, var(--danger, #ff6b6b) 8%, var(--panel, #171a20)); + border: 1px solid color-mix(in srgb, var(--danger, #ff6b6b) 42%, transparent); + border-radius: 9px; + color: var(--danger, #ff6b6b); + font-size: 12px; + padding: 9px 12px; +} + +.board-tabs { + align-items: center; + border-bottom: 1px solid var(--board-line); + display: flex; + min-width: 0; +} + +.board-tabs__track { + --track-width: 0; + display: block; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.board-tabs__track::part(nav) { + display: flex; +} + +.board-tabs__track::part(body) { + display: none; +} + +.board-tabs__track::-webkit-scrollbar { + display: none; +} + +.board-tabs__tab { + color: var(--muted, #8a919e); + flex: 0 0 auto; +} + +.board-tabs__tab::part(base) { + border-bottom: 2px solid transparent; + font-size: 12.5px; + min-height: 38px; + padding: 0 13px; + transition: + background 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +.board-tabs__tab:hover::part(base), +.board-tabs__tab:focus-visible::part(base) { + background: color-mix(in srgb, var(--text, #d7dae0) 6%, transparent); + color: var(--text, #d7dae0); +} + +.board-tabs__tab--active::part(base) { + border-bottom-color: var(--accent, #ff5c5c); + color: var(--text, #d7dae0); +} + +.board-tabs__tab--drop::part(base) { + background: color-mix(in srgb, var(--accent, #ff5c5c) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #ff5c5c) 70%, transparent); +} + +.board-tabs__overflow, +.board-widget__menu { + margin-left: auto; + position: relative; +} + +.board-tabs__overflow-trigger, +.board-widget__menu-trigger { + align-items: center; + background: transparent; + border: 0; + border-radius: 7px; + color: var(--muted, #8a919e); + cursor: pointer; + display: inline-flex; + justify-content: center; + user-select: none; +} + +.board-tabs__overflow-trigger { + height: 30px; + width: 34px; +} + +.board-tabs__overflow-trigger:hover, +.board-widget__menu-trigger:hover { + background: color-mix(in srgb, var(--text, #d7dae0) 10%, transparent); + color: var(--text, #d7dae0); +} + +.board-tabs__overflow::part(menu), +.board-widget__menu::part(menu) { + background: var(--panel, #171a20); + border: 1px solid var(--board-line); + border-radius: 10px; + box-shadow: 0 18px 48px rgb(0 0 0 / 32%); + min-width: 190px; + padding: 6px; +} + +.board-tabs__overflow-item { + min-width: 180px; +} + +.board-grid { + display: grid; + gap: 12px; + grid-auto-rows: 56px; + grid-template-columns: repeat(12, minmax(0, 1fr)); + min-width: 0; + position: relative; +} + +.board-grid__append-zone { + border-top: 1px dashed color-mix(in srgb, var(--accent, #ff5c5c) 48%, transparent); + bottom: -42px; + height: 38px; + left: 0; + position: absolute; + right: 0; + z-index: 7; +} + +openclaw-board-widget-cell { + display: contents; +} + +.board-widget { + background: var(--board-surface); + border: 1px solid var(--board-line); + border-radius: 12px; + box-shadow: 0 1px 1px rgb(0 0 0 / 12%); + display: grid; + grid-template-rows: 38px minmax(0, 1fr); + min-height: 0; + min-width: 0; + overflow: visible; + position: relative; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + transform 120ms ease; +} + +.board-widget:focus-visible { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #ff5c5c) 70%, transparent); + outline: none; +} + +.board-widget--dragging { + border-color: color-mix(in srgb, var(--accent, #ff5c5c) 72%, transparent); + box-shadow: 0 16px 36px rgb(0 0 0 / 28%); + opacity: 0.88; + transform: scale(0.995); + z-index: 8; +} + +.board-widget__bar { + align-items: center; + background: color-mix(in srgb, var(--text, #d7dae0) 3%, transparent); + border-bottom: 1px solid var(--board-line); + border-radius: 11px 11px 0 0; + display: flex; + gap: 7px; + min-width: 0; + padding: 0 6px; +} + +.board-widget__drag-handle, +.board-widget__resize-handle { + background: transparent; + border: 0; + color: var(--muted, #8a919e); +} + +.board-widget__drag-handle { + align-items: center; + border-radius: 6px; + cursor: grab; + display: inline-flex; + font-size: 17px; + height: 27px; + justify-content: center; + padding: 0; + touch-action: none; + width: 25px; +} + +.board-widget__drag-handle:hover { + background: color-mix(in srgb, var(--text, #d7dae0) 9%, transparent); + color: var(--text, #d7dae0); +} + +.board-widget__title { + color: var(--text, #d7dae0); + font-size: 12.5px; + font-weight: 590; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.board-widget__kind { + border: 1px solid var(--board-line); + border-radius: 999px; + color: var(--muted, #8a919e); + font-size: 8px; + letter-spacing: 0.08em; + line-height: 15px; + margin-left: auto; + padding: 0 5px; +} + +.board-widget__menu { + margin-left: 0; +} + +.board-widget__menu-trigger { + font-size: 21px; + height: 27px; + line-height: 1; + width: 27px; +} + +.board-widget__menu-heading { + color: var(--muted, #8a919e); + font-size: 9px; + letter-spacing: 0.08em; + padding: 7px 9px 3px; + text-transform: uppercase; +} + +.board-widget__menu-empty { + color: var(--muted, #8a919e); + font-size: 11px; + padding: 5px 9px; +} + +.board-widget__preset { + font-size: 11px; +} + +.board-widget__menu-separator { + border-top: 1px solid var(--board-line); + margin: 5px 3px; +} + +.board-widget__menu-danger { + color: var(--danger, #ff6b6b); +} + +.board-widget__body { + min-height: 0; + overflow: hidden; + position: relative; +} + +.board-widget__body--scrollable { + overflow: auto; +} + +.board-widget__body--scrollable .board-widget__grant { + box-sizing: border-box; + height: auto; + min-height: 100%; +} + +.board-widget__error-overlay { + inset: 0; + position: absolute; +} + +.board-widget__frame { + background: transparent; + border: 0; + display: block; + height: 100%; + width: 100%; +} + +.board-widget__resize-handle { + bottom: 1px; + cursor: nwse-resize; + height: 20px; + position: absolute; + right: 1px; + touch-action: none; + width: 20px; +} + +.board-widget__resize-handle::after { + border-bottom: 2px solid currentcolor; + border-right: 2px solid currentcolor; + bottom: 5px; + content: ""; + height: 6px; + position: absolute; + right: 5px; + width: 6px; +} + +.board-widget__grant, +.board-widget__error { + align-content: center; + display: grid; + gap: 7px; + height: 100%; + justify-items: center; + padding: 16px; + text-align: center; +} + +.board-widget__grant strong, +.board-widget__error strong { + color: var(--text, #d7dae0); + font-size: 13px; +} + +.board-widget__grant > span, +.board-widget__error > span { + color: var(--muted, #8a919e); + font-size: 11px; + line-height: 1.45; + max-width: 32ch; +} + +.board-widget__grant-mark { + align-items: center; + background: color-mix(in srgb, var(--warning, #e6ad55) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--warning, #e6ad55) 55%, transparent); + border-radius: 50%; + color: var(--warning, #e6ad55); + display: flex; + font-size: 13px; + font-weight: 700; + height: 25px; + justify-content: center; + width: 25px; +} + +.board-widget__grant-actions { + display: flex; + gap: 7px; +} + +.board-widget__grant-actions button:disabled { + cursor: progress; + opacity: 0.55; +} + +.board-widget__grant--rejected { + filter: saturate(0.35); + opacity: 0.74; +} + +.board-widget__error { + background: color-mix(in srgb, var(--danger, #ff6b6b) 7%, transparent); +} + +.board-widget__error--inline { + height: auto; + margin-top: 4px; + padding: 8px; + width: min(100%, 320px); +} + +.board-widget__error details { + color: var(--muted, #8a919e); + font-size: 10px; + max-width: 100%; +} + +.board-widget__error code { + display: block; + margin-top: 5px; + max-width: 100%; + overflow-wrap: anywhere; +} + +.board-empty { + align-items: center; + border: 1px dashed color-mix(in srgb, var(--muted, #8a919e) 38%, transparent); + border-radius: 14px; + color: var(--muted, #8a919e); + display: flex; + flex-direction: column; + gap: 7px; + justify-content: center; + min-height: 260px; + padding: 32px; + text-align: center; +} + +.board-empty strong { + color: var(--text, #d7dae0); + font-size: 14px; +} + +.board-empty span:last-child { + font-size: 12px; +} + +.board-empty__mark { + color: var(--accent, #ff5c5c); + font-size: 24px; +} + +.board-announcer { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +@media (prefers-reduced-motion: reduce) { + .board-widget, + .board-tabs__tab { + transition: none; + } +} diff --git a/ui/src/test-helpers/board-fixture.ts b/ui/src/test-helpers/board-fixture.ts new file mode 100644 index 000000000000..5d4a8dce33c9 --- /dev/null +++ b/ui/src/test-helpers/board-fixture.ts @@ -0,0 +1,226 @@ +import { html } from "lit"; +import { state } from "lit/decorators.js"; +import type { BoardOp, BoardSnapshot } from "../lib/board/view-types.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import "../components/board/board-view.ts"; + +const initialSnapshot: BoardSnapshot = { + sessionKey: "agent:main:board-fixture", + revision: 7, + tabs: [ + { tabId: "overview", title: "Overview", position: 0, chatDock: "right" }, + { tabId: "operations", title: "Operations", position: 1, chatDock: "bottom" }, + ], + widgets: [ + { + name: "service-pulse", + tabId: "overview", + title: "Service pulse", + contentKind: "html", + sizeW: 6, + sizeH: 4, + position: 0, + grantState: "none", + revision: 3, + }, + { + name: "deploy-window", + tabId: "overview", + title: "Deploy window", + contentKind: "mcp-app", + sizeW: 6, + sizeH: 4, + position: 1, + grantState: "granted", + revision: 2, + }, + { + name: "latency-bands", + tabId: "overview", + title: "Latency bands", + contentKind: "html", + sizeW: 4, + sizeH: 5, + position: 2, + grantState: "none", + revision: 1, + }, + { + name: "incident-tools", + tabId: "overview", + title: "Incident tools", + contentKind: "html", + sizeW: 4, + sizeH: 5, + position: 3, + grantState: "pending", + revision: 1, + }, + { + name: "retired-report", + tabId: "overview", + title: "Retired report", + contentKind: "html", + sizeW: 4, + sizeH: 5, + position: 4, + grantState: "rejected", + revision: 4, + }, + { + name: "worker-queue", + tabId: "operations", + title: "Worker queue", + contentKind: "html", + sizeW: 12, + sizeH: 6, + position: 0, + grantState: "none", + revision: 5, + }, + ], +}; + +const frameCopy: Record = { + "service-pulse": { eyebrow: "UPTIME / 24H", value: "99.982%", detail: "All regions nominal" }, + "deploy-window": { eyebrow: "NEXT WINDOW", value: "14:30 UTC", detail: "3 changes queued" }, + "latency-bands": { eyebrow: "P95 LATENCY", value: "184 ms", detail: "−12 ms since last hour" }, + "worker-queue": { eyebrow: "QUEUE DEPTH", value: "38", detail: "12 active · 4 waiting" }, +}; + +function frameUrl(name: string): string { + const copy = frameCopy[name] ?? { eyebrow: "WIDGET", value: name, detail: "Mock fixture" }; + const document = `
${copy.eyebrow}
${copy.value}
+
${copy.detail}
${[28, 52, 37, 68, 44, 81, 59, 72] + .map((height) => ``) + .join("")}
`; + return `data:text/html;charset=utf-8,${encodeURIComponent(document)}`; +} + +function normalizePositions(snapshot: BoardSnapshot): BoardSnapshot { + const widgets = snapshot.widgets.map((widget) => ({ ...widget })); + for (const tab of snapshot.tabs) { + widgets + .filter((widget) => widget.tabId === tab.tabId) + .toSorted( + (left, right) => left.position - right.position || left.name.localeCompare(right.name), + ) + .forEach((widget, position) => { + widget.position = position; + }); + } + return { ...snapshot, widgets }; +} + +function moveFixtureWidget( + snapshot: BoardSnapshot, + op: Extract, +): BoardSnapshot { + const moving = snapshot.widgets.find((widget) => widget.name === op.name); + if (!moving) { + return snapshot; + } + const tabId = op.tabId ?? moving.tabId; + const remaining = snapshot.widgets.filter((widget) => widget.name !== op.name); + const destination = remaining + .filter((widget) => widget.tabId === tabId) + .toSorted( + (left, right) => left.position - right.position || left.name.localeCompare(right.name), + ); + const afterIndex = op.after ? destination.findIndex((widget) => widget.name === op.after) : -1; + const requestedPosition = + afterIndex >= 0 + ? afterIndex + 1 + : (op.position ?? (tabId === moving.tabId ? moving.position : destination.length)); + const position = Math.max(0, Math.min(Math.trunc(requestedPosition), destination.length)); + destination.splice(position, 0, { ...moving, tabId, position }); + destination.forEach((widget, nextPosition) => { + widget.position = nextPosition; + }); + return normalizePositions({ + ...snapshot, + widgets: [...remaining.filter((widget) => widget.tabId !== tabId), ...destination], + }); +} + +export function applyBoardFixtureOps( + snapshot: BoardSnapshot, + ops: readonly BoardOp[], +): BoardSnapshot { + let next = structuredClone(snapshot); + for (const op of ops) { + if (op.kind === "widget_remove") { + next.widgets = next.widgets.filter((widget) => widget.name !== op.name); + } else if (op.kind === "widget_resize") { + next.widgets = next.widgets.map((widget) => { + if (widget.name !== op.name || (widget.sizeW === op.sizeW && widget.sizeH === op.sizeH)) { + return widget; + } + return { ...widget, sizeW: op.sizeW, sizeH: op.sizeH, revision: widget.revision + 1 }; + }); + } else if (op.kind === "widget_move") { + next = moveFixtureWidget(next, op); + } + } + next = normalizePositions(next); + return { ...next, revision: next.revision + 1 }; +} + +class BoardFixture extends OpenClawLightDomElement { + @state() private snapshot = structuredClone(initialSnapshot); + @state() private activeTabId = "overview"; + + private async applyOps(ops: BoardOp[]): Promise { + this.snapshot = applyBoardFixtureOps(this.snapshot, ops); + } + + private async grant(name: string, decision: "granted" | "rejected"): Promise { + this.snapshot = { + ...this.snapshot, + revision: this.snapshot.revision + 1, + widgets: this.snapshot.widgets.map((widget) => + widget.name === name ? { ...widget, grantState: decision } : widget, + ), + }; + } + + override render() { + return html` +
+
+
+ SESSION DASHBOARD / MOCK +

Launch control

+
+
fixture online
+
+ this.applyOps(ops), + grant: (name: string, decision: "granted" | "rejected") => this.grant(name, decision), + selectTab: (tabId: string) => { + this.activeTabId = tabId; + }, + }} + > +
+ `; + } +} + +if (!customElements.get("openclaw-board-fixture")) { + customElements.define("openclaw-board-fixture", BoardFixture); +} + +document.querySelector("#app")?.append(document.createElement("openclaw-board-fixture"));