From 123c58d3ab18fabe4f74a681bc8ec561bd42d72d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 19:27:24 -0700 Subject: [PATCH] feat(workboard): stitch cards to session dashboards (#111989) * feat(ui): stitch Workboard cards to session dashboards * fix(ui): lazy-load workboard dashboard stitch * chore(ui): drop dead live-refresh re-export * test(ui): import live-refresh stop from its owner --- ui/src/e2e/session-dashboard.e2e.test.ts | 210 ++++++++++++ ui/src/i18n/locales/en.ts | 3 + ui/src/lib/workboard/capability.ts | 4 +- ui/src/lib/workboard/index.ts | 2 +- ui/src/lib/workboard/live-refresh.test.ts | 2 +- ui/src/lib/workboard/live-refresh.ts | 23 +- ui/src/lib/workboard/runtime.ts | 19 ++ .../lib/workboard/session-card-lookup.test.ts | 213 ++++++++++++ ui/src/lib/workboard/session-card-lookup.ts | 308 ++++++++++++++++++ .../pages/chat/board-session-surface.test.ts | 49 +++ ui/src/pages/chat/board-session-surface.ts | 25 ++ ui/src/pages/chat/chat-pane.ts | 32 ++ .../chat/workboard-card-chip.runtime.test.ts | 67 ++++ .../pages/chat/workboard-card-chip.runtime.ts | 106 ++++++ ui/src/pages/workboard/view.test.ts | 91 ++++++ ui/src/pages/workboard/view.ts | 23 ++ .../workboard-card-dashboard.test.ts | 145 +++++++++ .../workboard/workboard-card-dashboard.ts | 188 +++++++++++ ui/src/pages/workboard/workboard-page.ts | 7 +- ui/src/styles/chat/board.css | 62 ++++ ui/src/styles/workboard.css | 84 +++++ 21 files changed, 1636 insertions(+), 27 deletions(-) create mode 100644 ui/src/lib/workboard/session-card-lookup.test.ts create mode 100644 ui/src/lib/workboard/session-card-lookup.ts create mode 100644 ui/src/pages/chat/workboard-card-chip.runtime.test.ts create mode 100644 ui/src/pages/chat/workboard-card-chip.runtime.ts create mode 100644 ui/src/pages/workboard/workboard-card-dashboard.test.ts create mode 100644 ui/src/pages/workboard/workboard-card-dashboard.ts diff --git a/ui/src/e2e/session-dashboard.e2e.test.ts b/ui/src/e2e/session-dashboard.e2e.test.ts index f117cdc9b59f..62aacd14b65c 100644 --- a/ui/src/e2e/session-dashboard.e2e.test.ts +++ b/ui/src/e2e/session-dashboard.e2e.test.ts @@ -1,4 +1,6 @@ // Control UI E2E covers the real session-dashboard provider and transcript bridge. +import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { chromium, type Browser, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { GATEWAY_SERVER_CAPS } from "../../../packages/gateway-protocol/src/index.js"; @@ -16,6 +18,10 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium. const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; +const cardboardProofDir = path.resolve( + process.cwd(), + ".artifacts/control-ui-e2e/workboard-cardboard", +); let browser: Browser; let server: ControlUiE2eServer; @@ -108,6 +114,18 @@ async function showDashboard(page: Page): Promise { }, sessionKey); } +function workboardConfigSnapshot(enabled = true) { + const config = { plugins: { entries: { workboard: { enabled } } } }; + return { + config, + hash: "workboard-cardboard-e2e", + path: "/tmp/openclaw-e2e/openclaw.json", + raw: JSON.stringify(config), + resolved: config, + sourceConfig: config, + }; +} + describeControlUiE2e("Control UI session dashboard stitch", () => { beforeAll(async () => { server = await startControlUiE2eServer(); @@ -366,4 +384,196 @@ describeControlUiE2e("Control UI session dashboard stitch", () => { .toBe(true); await context.close(); }); + + it("links a dispatched Workboard card and its live session dashboard in both directions", async () => { + const recordProof = process.env.OPENCLAW_UI_E2E_RECORD === "1"; + if (recordProof) { + await mkdir(cardboardProofDir, { recursive: true }); + } + const context = await browser.newContext({ + viewport: { height: 900, width: 1280 }, + ...(recordProof + ? { recordVideo: { dir: cardboardProofDir, size: { height: 900, width: 1280 } } } + : {}), + }); + const page = await context.newPage(); + const card = { + id: "card-dashboard-stitch", + title: "Ship dashboard stitch", + status: "running", + priority: "high", + labels: ["ui"], + position: 1000, + createdAt: 1, + updatedAt: 2, + sessionKey, + runId: "run-dashboard-stitch", + metadata: { automation: { boardId: "platform" } }, + }; + const gateway = await installMockGateway(page, { + sessionKey, + featureMethods: [ + "board.get", + "chat.metadata", + "chat.startup", + "config.get", + "sessions.list", + "tasks.list", + "workboard.cards.list", + ], + methodResponses: { + "board.get": boardSnapshot, + "config.get": workboardConfigSnapshot(), + "tasks.list": { nextCursor: null, tasks: [] }, + "workboard.cards.list": { cards: [card], statuses: ["running", "done"] }, + }, + }); + await showDashboard(page); + + try { + await page.goto(`${server.baseUrl}chat`); + const chip = page.locator(".board-session-surface__workboard-chip"); + await chip.waitFor(); + await expect.poll(() => chip.textContent()).toContain("Ship dashboard stitch"); + await expect.poll(() => chip.textContent()).toContain("Running"); + expect(await chip.getAttribute("href")).toBe("/workboard?board=platform"); + if (recordProof) { + await page.screenshot({ path: path.join(cardboardProofDir, "01-dashboard-card-chip.png") }); + } + + const completedCard = { ...card, status: "done", updatedAt: 3 }; + await gateway.setMethodResponse("workboard.cards.list", { + cards: [completedCard], + statuses: ["running", "done"], + }); + await gateway.emitGatewayEvent("plugin.workboard.changed", { + epoch: "cardboard-e2e", + revision: 2, + }); + await expect.poll(() => chip.textContent()).toContain("Done"); + + await gateway.setMethodResponse("workboard.cards.list", { + cards: [], + statuses: ["running", "done"], + }); + await gateway.emitGatewayEvent("plugin.workboard.changed", { + epoch: "cardboard-e2e", + revision: 3, + }); + await expect.poll(() => chip.count()).toBe(0); + + await gateway.setMethodResponse("workboard.cards.list", { + cards: [completedCard], + statuses: ["running", "done"], + }); + await gateway.emitGatewayEvent("plugin.workboard.changed", { + epoch: "cardboard-e2e", + revision: 4, + }); + await chip.waitFor(); + + await chip.click(); + await page.waitForURL(/\/workboard\?board=platform$/u); + const workboardCard = page.locator(".workboard-card", { + hasText: "Ship dashboard stitch", + }); + await workboardCard.waitFor(); + await workboardCard.click(); + const cardDashboard = page.locator("openclaw-workboard-card-dashboard"); + await cardDashboard.waitFor(); + await expect + .poll(() => + cardDashboard.locator(".workboard-card-dashboard__toggle").getAttribute("aria-expanded"), + ) + .toBe("true"); + await cardDashboard.locator("openclaw-board-view").waitFor(); + if (recordProof) { + await page.screenshot({ + path: path.join(cardboardProofDir, "02-workboard-card-dashboard.png"), + }); + } + + await gateway.setMethodResponse("board.get", { + sessionKey, + revision: 3, + tabs: [], + widgets: [], + }); + await gateway.emitGatewayEvent("board.changed", { sessionKey }); + await cardDashboard + .getByText("No dashboard yet — the working agent can pin widgets.") + .waitFor(); + } finally { + const video = page.video(); + await context.close(); + if (recordProof && video) { + await video.saveAs(path.join(cardboardProofDir, "workboard-cardboard.webm")); + } + } + }); + + it("omits the Workboard breadcrumb when its plugin or the session board is unavailable", async () => { + const cases = [ + { + name: "plugin disabled", + board: boardSnapshot, + config: workboardConfigSnapshot(false), + }, + { + name: "board empty", + board: { sessionKey, revision: 1, tabs: [], widgets: [] }, + config: workboardConfigSnapshot(), + }, + ]; + + for (const testCase of cases) { + const context = await browser.newContext({ viewport: { height: 900, width: 1280 } }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + sessionKey, + featureMethods: [ + "board.get", + "chat.metadata", + "chat.startup", + "config.get", + "workboard.cards.list", + ], + methodResponses: { + "board.get": testCase.board, + "config.get": testCase.config, + "workboard.cards.list": { + cards: [ + { + id: `card-${testCase.name.replaceAll(" ", "-")}`, + title: testCase.name, + status: "running", + priority: "normal", + labels: [], + position: 1, + createdAt: 1, + updatedAt: 2, + sessionKey, + metadata: { automation: { boardId: "platform" } }, + }, + ], + statuses: ["running"], + }, + }, + }); + await showDashboard(page); + + try { + await page.goto(`${server.baseUrl}chat`); + await expect + .poll(async () => (await gateway.getRequests("board.get")).length) + .toBeGreaterThan(0); + await expect + .poll(() => page.locator(".board-session-surface__workboard-chip").count()) + .toBe(0); + expect(await gateway.getRequests("workboard.cards.list")).toHaveLength(0); + } finally { + await context.close(); + } + } + }); }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 9f41ef82c0bc..177f952527c6 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2691,6 +2691,8 @@ export const en: TranslationMap = { detailNoNotes: "No operator notes yet.", detailNotePlaceholder: "Add a decision, blocker, or proof note...", detailAddNote: "Add note", + dashboardTitle: "Dashboard", + dashboardEmpty: "No dashboard yet — the working agent can pin widgets.", openSession: "Open thread", openLinkedSession: "Open linked thread", defaultAgent: "Default agent", @@ -3666,6 +3668,7 @@ export const en: TranslationMap = { dockHidden: "Hide chat", resizeDock: "Resize chat dock", reopenChat: "Show chat", + workboardCard: "Workboard card: {title}, {status}", defaultTab: "Main", mockPlaceholder: "Board view seam · {tabs} tabs · {widgets} widgets", mockOverview: "Overview", diff --git a/ui/src/lib/workboard/capability.ts b/ui/src/lib/workboard/capability.ts index 2e209106c9aa..57598c1c3c06 100644 --- a/ui/src/lib/workboard/capability.ts +++ b/ui/src/lib/workboard/capability.ts @@ -2,8 +2,8 @@ import { getWorkboardState, stopWorkboardLifecycleRefresh, stopWorkboardLiveRefresh, - type WorkboardUiState, -} from "./index.ts"; +} from "./runtime.ts"; +import type { WorkboardUiState } from "./types.ts"; export type WorkboardCapability = { readonly state: WorkboardUiState; diff --git a/ui/src/lib/workboard/index.ts b/ui/src/lib/workboard/index.ts index 6904536e4615..c2b58d5085b0 100644 --- a/ui/src/lib/workboard/index.ts +++ b/ui/src/lib/workboard/index.ts @@ -29,7 +29,6 @@ export { configureWorkboardLiveRefresh, handleWorkboardChanged, resumeWorkboardLiveRefresh, - stopWorkboardLiveRefresh, } from "./live-refresh.ts"; export { findWorkboardSession, getWorkboardLifecycle } from "./lifecycle.ts"; export { syncWorkboardLifecycle } from "./lifecycle-reconciliation.ts"; @@ -45,6 +44,7 @@ export { startWorkboardCard, stopWorkboardCard } from "./execution.ts"; export { getWorkboardState, stopWorkboardLifecycleRefresh, + stopWorkboardLiveRefresh, workboardHasActiveWrites, workboardMutationsReady, } from "./runtime.ts"; diff --git a/ui/src/lib/workboard/live-refresh.test.ts b/ui/src/lib/workboard/live-refresh.test.ts index 10b968376856..c2cbe81e604d 100644 --- a/ui/src/lib/workboard/live-refresh.test.ts +++ b/ui/src/lib/workboard/live-refresh.test.ts @@ -5,9 +5,9 @@ import { configureWorkboardLiveRefresh, handleWorkboardChanged, resumeWorkboardLiveRefresh, - stopWorkboardLiveRefresh, } from "./live-refresh.ts"; import { loadWorkboard } from "./loading.ts"; +import { stopWorkboardLiveRefresh } from "./runtime.ts"; import { getWorkboardState } from "./runtime.ts"; function createDeferred() { diff --git a/ui/src/lib/workboard/live-refresh.ts b/ui/src/lib/workboard/live-refresh.ts index a6d5e92b0d1f..e03b5b12c838 100644 --- a/ui/src/lib/workboard/live-refresh.ts +++ b/ui/src/lib/workboard/live-refresh.ts @@ -1,12 +1,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { normalizeWorkboardChange } from "./change-payload.ts"; import { refreshWorkboard, shouldDeferWorkboardLiveRefresh } from "./loading.ts"; -import { - getWorkboardRuntime, - getWorkboardState, - invalidateWorkboardLoads, - type WorkboardHost, -} from "./runtime.ts"; +import { getWorkboardRuntime, getWorkboardState, type WorkboardHost } from "./runtime.ts"; const WORKBOARD_LIVE_REFRESH_RETRY_MS = 1000; @@ -139,19 +134,3 @@ export function resumeWorkboardLiveRefresh(host: WorkboardHost): void { void runPendingRefresh(host); } } - -export function stopWorkboardLiveRefresh(host: WorkboardHost): void { - const runtime = getWorkboardRuntime(host); - const loadInFlight = Boolean(runtime.loadPromise); - runtime.liveRefreshGeneration = (runtime.liveRefreshGeneration ?? 0) + 1; - clearRetry(host); - delete runtime.liveRefreshEntry; - delete runtime.liveRefreshPromise; - delete runtime.liveChangeEpoch; - delete runtime.liveHighestSeenRevision; - delete runtime.liveAppliedRevision; - delete runtime.liveRefreshPending; - if (loadInFlight) { - invalidateWorkboardLoads(host); - } -} diff --git a/ui/src/lib/workboard/runtime.ts b/ui/src/lib/workboard/runtime.ts index 0965efa99523..9f835288356b 100644 --- a/ui/src/lib/workboard/runtime.ts +++ b/ui/src/lib/workboard/runtime.ts @@ -98,6 +98,25 @@ export function invalidateWorkboardLoads(host: WorkboardHost) { nextWorkboardLifecycleReconciliationEpoch(host); } +export function stopWorkboardLiveRefresh(host: WorkboardHost): void { + const runtime = getWorkboardRuntime(host); + const loadInFlight = Boolean(runtime.loadPromise); + runtime.liveRefreshGeneration = (runtime.liveRefreshGeneration ?? 0) + 1; + if (runtime.liveRefreshRetryTimer) { + clearTimeout(runtime.liveRefreshRetryTimer); + delete runtime.liveRefreshRetryTimer; + } + delete runtime.liveRefreshEntry; + delete runtime.liveRefreshPromise; + delete runtime.liveChangeEpoch; + delete runtime.liveHighestSeenRevision; + delete runtime.liveAppliedRevision; + delete runtime.liveRefreshPending; + if (loadInFlight) { + invalidateWorkboardLoads(host); + } +} + function clearWorkboardLifecycleTaskPreparedTimer(host: WorkboardHost) { const runtime = getWorkboardRuntime(host); const timer = runtime.lifecycleTaskPreparedTimer; diff --git a/ui/src/lib/workboard/session-card-lookup.test.ts b/ui/src/lib/workboard/session-card-lookup.test.ts new file mode 100644 index 000000000000..3d11defc02f7 --- /dev/null +++ b/ui/src/lib/workboard/session-card-lookup.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { acquireWorkboardSessionCardLookup } from "./session-card-lookup.ts"; + +function createClient(responses: unknown[]) { + let gatewayListener: ((event: { event: string; payload?: unknown }) => void) | undefined; + const removeListener = vi.fn(); + const request = vi.fn(async (method: string) => { + if (method !== "workboard.cards.list") { + throw new Error(`unexpected request: ${method}`); + } + return responses.shift() ?? { cards: [] }; + }); + const client = { + request, + addEventListener: vi.fn((listener: typeof gatewayListener) => { + gatewayListener = listener; + return removeListener; + }), + } as unknown as GatewayBrowserClient; + return { + client, + request, + removeListener, + emitChanged: () => gatewayListener?.({ event: "plugin.workboard.changed" }), + }; +} + +function card(overrides: Record = {}) { + return { + id: "card-1", + title: "Ship dashboard stitch", + status: "running", + priority: "normal", + labels: [], + position: 1, + createdAt: 1, + updatedAt: 2, + sessionKey: "agent:main:workboard-card", + metadata: { automation: { boardId: "platform" } }, + ...overrides, + }; +} + +describe("Workboard session card lookup", () => { + it("coalesces subscribers and refreshes cached matches after Workboard changes", async () => { + const { client, request, removeListener, emitChanged } = createClient([ + { cards: [card()] }, + { cards: [card({ status: "review", updatedAt: 3 })] }, + ]); + const firstLease = acquireWorkboardSessionCardLookup(client); + const secondLease = acquireWorkboardSessionCardLookup(client); + const first = vi.fn(); + const second = vi.fn(); + const unsubscribeFirst = firstLease.subscribe("agent:main:workboard-card", first); + const unsubscribeSecond = secondLease.subscribe("agent:main:workboard-card", second); + + await vi.waitFor(() => + expect(first).toHaveBeenCalledWith(expect.objectContaining({ status: "running" })), + ); + expect(second).toHaveBeenCalledWith(expect.objectContaining({ cardId: "card-1" })); + expect(request).toHaveBeenCalledTimes(1); + + emitChanged(); + await vi.waitFor(() => + expect(first).toHaveBeenCalledWith(expect.objectContaining({ status: "review" })), + ); + expect(request).toHaveBeenCalledTimes(2); + + unsubscribeFirst(); + unsubscribeSecond(); + firstLease.release(); + expect(removeListener).not.toHaveBeenCalled(); + secondLease.release(); + expect(removeListener).toHaveBeenCalledOnce(); + }); + + it("indexes historical attempt sessions and returns no match for unrelated sessions", async () => { + const { client } = createClient([ + { + cards: [ + card({ + sessionKey: "agent:main:newest", + metadata: { + automation: { boardId: "quality" }, + attempts: [ + { id: "attempt-1", status: "failed", startedAt: 1, sessionKey: "agent:main:older" }, + ], + }, + }), + ], + }, + ]); + const lease = acquireWorkboardSessionCardLookup(client); + const historical = vi.fn(); + const unrelated = vi.fn(); + const unsubscribeHistorical = lease.subscribe("agent:main:older", historical); + const unsubscribeUnrelated = lease.subscribe("agent:main:unrelated", unrelated); + + await vi.waitFor(() => + expect(historical).toHaveBeenCalledWith(expect.objectContaining({ boardId: "quality" })), + ); + expect(unrelated).toHaveBeenCalledWith(null); + + unsubscribeHistorical(); + unsubscribeUnrelated(); + lease.release(); + }); + + it("scans omitted run history sequentially for subscribers added after a warm cache", async () => { + const request = vi.fn(async (method: string, params?: { id?: string }) => { + if (method === "workboard.cards.list") { + return { + cards: [ + card({ + id: "card-new", + sessionKey: "agent:main:direct", + runId: "run-new", + metadata: undefined, + updatedAt: 3, + }), + card({ + id: "card-old", + sessionKey: undefined, + runId: "run-old", + metadata: undefined, + updatedAt: 2, + }), + ], + }; + } + if (method === "workboard.cards.runs" && params?.id === "card-new") { + return { + attempts: [ + { + id: "attempt-1", + status: "running", + startedAt: 1, + sessionKey: "agent:main:runs-fallback", + }, + ], + }; + } + throw new Error(`unexpected request: ${method}`); + }); + const client = { + request, + addEventListener: vi.fn(() => () => {}), + } as unknown as GatewayBrowserClient; + const lease = acquireWorkboardSessionCardLookup(client); + const direct = vi.fn(); + const unsubscribeDirect = lease.subscribe("agent:main:direct", direct); + + await vi.waitFor(() => + expect(direct).toHaveBeenCalledWith(expect.objectContaining({ cardId: "card-new" })), + ); + expect(request.mock.calls.filter(([method]) => method === "workboard.cards.runs")).toHaveLength( + 0, + ); + + const historical = vi.fn(); + const unsubscribeHistorical = lease.subscribe("agent:main:runs-fallback", historical); + + await vi.waitFor(() => + expect(historical).toHaveBeenCalledWith(expect.objectContaining({ cardId: "card-new" })), + ); + expect(request.mock.calls.filter(([method]) => method === "workboard.cards.runs")).toEqual([ + ["workboard.cards.runs", { id: "card-new" }], + ]); + + unsubscribeDirect(); + unsubscribeHistorical(); + lease.release(); + }); + + it("bounds an unmatched older-gateway run scan to the most recent cards", async () => { + const cards = Array.from({ length: 24 }, (_, index) => + card({ + id: `card-${index}`, + sessionKey: undefined, + runId: `run-${index}`, + metadata: undefined, + updatedAt: 100 - index, + }), + ); + const request = vi.fn(async (method: string) => { + if (method === "workboard.cards.list") { + return { cards }; + } + if (method === "workboard.cards.runs") { + return { attempts: [] }; + } + throw new Error(`unexpected request: ${method}`); + }); + const client = { + request, + addEventListener: vi.fn(() => () => {}), + } as unknown as GatewayBrowserClient; + const lease = acquireWorkboardSessionCardLookup(client); + const listener = vi.fn(); + const unsubscribe = lease.subscribe("agent:main:not-a-workboard-run", listener); + + await vi.waitFor(() => + expect( + request.mock.calls.filter(([method]) => method === "workboard.cards.runs"), + ).toHaveLength(16), + ); + expect(listener).toHaveBeenCalledWith(null); + + unsubscribe(); + lease.release(); + }); +}); diff --git a/ui/src/lib/workboard/session-card-lookup.ts b/ui/src/lib/workboard/session-card-lookup.ts new file mode 100644 index 000000000000..0835a043ce52 --- /dev/null +++ b/ui/src/lib/workboard/session-card-lookup.ts @@ -0,0 +1,308 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { normalizeSessionKeyForUiComparison } from "../sessions/session-key.ts"; +import { normalizeCardsPayload } from "./normalization.ts"; +import { WORKBOARD_CHANGED_EVENT, type WorkboardCard, type WorkboardStatus } from "./types.ts"; + +type WorkboardLookupClient = Pick; +const WORKBOARD_SESSION_RUN_LOOKUP_LIMIT = 16; + +export type WorkboardSessionCardMatch = { + cardId: string; + title: string; + status: WorkboardStatus; + boardId: string; +}; + +type WorkboardSessionCardListener = (match: WorkboardSessionCardMatch | null) => void; + +type WorkboardLookupSnapshot = { + matches: Map; + runCandidates: WorkboardCard[]; +}; + +function normalizeLookupSessionKey(sessionKey: string): string { + return normalizeSessionKeyForUiComparison(sessionKey.trim()); +} + +function cardSessionKeys(card: WorkboardCard): string[] { + return [ + card.sessionKey, + card.execution?.sessionKey, + ...(card.metadata?.attempts?.map((attempt) => attempt.sessionKey) ?? []), + ...(card.events?.map((event) => event.sessionKey) ?? []), + ] + .filter((sessionKey): sessionKey is string => typeof sessionKey === "string") + .map(normalizeLookupSessionKey) + .filter(Boolean); +} + +function cardMatch(card: WorkboardCard): WorkboardSessionCardMatch { + return { + cardId: card.id, + title: card.title, + status: card.status, + boardId: card.metadata?.automation?.boardId?.trim() || "default", + }; +} + +function indexCards(cards: readonly WorkboardCard[]): Map { + const matches = new Map(); + for (const card of cards.toSorted((left, right) => right.updatedAt - left.updatedAt)) { + const match = cardMatch(card); + for (const sessionKey of cardSessionKeys(card)) { + if (!matches.has(sessionKey)) { + matches.set(sessionKey, match); + } + } + } + return matches; +} + +function runSessionKeys(payload: unknown): string[] { + if (!payload || typeof payload !== "object" || !("attempts" in payload)) { + return []; + } + const attempts = (payload as { attempts?: unknown }).attempts; + if (!Array.isArray(attempts)) { + return []; + } + return attempts.flatMap((attempt) => { + if (!attempt || typeof attempt !== "object" || !("sessionKey" in attempt)) { + return []; + } + const sessionKey = (attempt as { sessionKey?: unknown }).sessionKey; + return typeof sessionKey === "string" ? [normalizeLookupSessionKey(sessionKey)] : []; + }); +} + +class WorkboardSessionCardLookup { + private readonly listeners = new Map>(); + private readonly unsubscribeGateway: () => void; + private matches = new Map(); + private refreshPromise: Promise | undefined; + private runCandidates: WorkboardCard[] = []; + private runCandidateIndex = 0; + private runScanPromise: Promise | undefined; + private generation = 0; + private loaded = false; + + constructor(private readonly client: WorkboardLookupClient) { + this.unsubscribeGateway = client.addEventListener((event) => { + if (event.event === WORKBOARD_CHANGED_EVENT) { + this.invalidate(); + } + }); + } + + subscribe(sessionKey: string, listener: WorkboardSessionCardListener): () => void { + const key = normalizeLookupSessionKey(sessionKey); + let listeners = this.listeners.get(key); + if (!listeners) { + listeners = new Set(); + this.listeners.set(key, listeners); + } + listeners.add(listener); + if (this.loaded) { + const match = this.matches.get(key) ?? null; + listener(match); + if (!match) { + void this.scanMissingRunMatches(); + } + } + if (!this.loaded) { + void this.refresh(); + } + return () => { + listeners?.delete(listener); + if (listeners?.size === 0) { + this.listeners.delete(key); + } + }; + } + + dispose(): void { + this.generation += 1; + this.listeners.clear(); + this.matches.clear(); + this.refreshPromise = undefined; + this.runCandidates = []; + this.runCandidateIndex = 0; + this.runScanPromise = undefined; + this.loaded = false; + this.unsubscribeGateway(); + } + + private invalidate(): void { + this.generation += 1; + this.matches.clear(); + this.refreshPromise = undefined; + this.runCandidates = []; + this.runCandidateIndex = 0; + this.runScanPromise = undefined; + this.loaded = false; + if (this.listeners.size > 0) { + void this.refresh(); + } + } + + private refresh(): Promise { + if (this.loaded) { + return Promise.resolve(); + } + if (this.refreshPromise) { + return this.refreshPromise; + } + const generation = this.generation; + const promise = this.loadMatches() + .then((snapshot) => { + if (generation !== this.generation) { + return; + } + this.matches = snapshot.matches; + this.runCandidates = snapshot.runCandidates; + this.runCandidateIndex = 0; + this.loaded = true; + for (const [sessionKey, listeners] of this.listeners) { + const match = snapshot.matches.get(sessionKey) ?? null; + for (const listener of listeners) { + listener(match); + } + } + void this.scanMissingRunMatches(); + }) + .catch(() => { + if (generation !== this.generation) { + return; + } + for (const listeners of this.listeners.values()) { + for (const listener of listeners) { + listener(null); + } + } + }); + this.refreshPromise = promise; + void promise.finally(() => { + if (this.refreshPromise === promise) { + this.refreshPromise = undefined; + } + }); + return promise; + } + + private async loadMatches(): Promise { + const payload = await this.client.request("workboard.cards.list", {}); + const cards = normalizeCardsPayload(payload).cards; + const matches = indexCards(cards); + const runCandidates = cards + .toSorted((left, right) => right.updatedAt - left.updatedAt) + .filter( + (card) => card.metadata?.attempts === undefined && (card.runId || card.execution?.runId), + ) + // Canonical list responses already carry attempt sessions. Keep the + // older-gateway fallback bounded so an unrelated dashboard cannot scan a whole board. + .slice(0, WORKBOARD_SESSION_RUN_LOOKUP_LIMIT); + return { matches, runCandidates }; + } + + private missingSessionKeys(): string[] { + return [...this.listeners.keys()].filter((sessionKey) => !this.matches.has(sessionKey)); + } + + private scanMissingRunMatches(): Promise { + if (!this.loaded || this.runScanPromise || this.missingSessionKeys().length === 0) { + return this.runScanPromise ?? Promise.resolve(); + } + const generation = this.generation; + const promise = (async () => { + while ( + generation === this.generation && + this.runCandidateIndex < this.runCandidates.length && + this.missingSessionKeys().length > 0 + ) { + const card = this.runCandidates[this.runCandidateIndex]; + this.runCandidateIndex += 1; + if (!card) { + return; + } + let runs: unknown; + try { + runs = await this.client.request("workboard.cards.runs", { id: card.id }); + } catch { + // A card can disappear between list and runs; continue the bounded sequential scan. + continue; + } + if (generation !== this.generation) { + return; + } + const match = cardMatch(card); + for (const sessionKey of runSessionKeys(runs)) { + if (this.matches.has(sessionKey)) { + continue; + } + this.matches.set(sessionKey, match); + for (const listener of this.listeners.get(sessionKey) ?? []) { + listener(match); + } + } + } + })(); + this.runScanPromise = promise; + void promise.finally(() => { + if (this.runScanPromise !== promise) { + return; + } + this.runScanPromise = undefined; + if ( + generation === this.generation && + this.runCandidateIndex < this.runCandidates.length && + this.missingSessionKeys().length > 0 + ) { + void this.scanMissingRunMatches(); + } + }); + return promise; + } +} + +type LookupEntry = { + lookup: WorkboardSessionCardLookup; + consumers: number; +}; + +const lookups = new WeakMap(); + +export type WorkboardSessionCardLookupLease = { + subscribe: (sessionKey: string, listener: WorkboardSessionCardListener) => () => void; + release: () => void; +}; + +export function acquireWorkboardSessionCardLookup( + client: WorkboardLookupClient, +): WorkboardSessionCardLookupLease { + let entry = lookups.get(client); + if (!entry) { + entry = { lookup: new WorkboardSessionCardLookup(client), consumers: 0 }; + lookups.set(client, entry); + } + const acquiredEntry = entry; + acquiredEntry.consumers += 1; + let released = false; + return { + subscribe: (sessionKey, listener) => acquiredEntry.lookup.subscribe(sessionKey, listener), + release: () => { + if (released) { + return; + } + released = true; + const current = lookups.get(client); + if (!current || current !== acquiredEntry) { + return; + } + current.consumers -= 1; + if (current.consumers === 0) { + lookups.delete(client); + current.lookup.dispose(); + } + }, + }; +} diff --git a/ui/src/pages/chat/board-session-surface.test.ts b/ui/src/pages/chat/board-session-surface.test.ts index 9abff5c33530..18e8032b2340 100644 --- a/ui/src/pages/chat/board-session-surface.test.ts +++ b/ui/src/pages/chat/board-session-surface.test.ts @@ -26,6 +26,55 @@ beforeEach(() => { }); describe("board session shell", () => { + it("delegates the optional Workboard chip to its lazy element", () => { + const linked = createContainer(); + const unlinked = createContainer(); + const provider = boardProviderForSession("agent:main:workboard-link"); + const client = { + request: vi.fn(async () => ({ cards: [] })), + addEventListener: vi.fn(() => () => {}), + } as never; + const props = { + snapshot: provider.snapshot$.value, + sessions: [], + activeTabId: "main", + dock: "right" as const, + reopenDock: "right" as const, + dockSize: { height: 300, width: 420 }, + chat: html`
chat
`, + divider: html`
`, + canMutate: true, + canGrant: true, + callbacks: { + applyOps: (ops: Parameters[0]) => provider.applyOps(ops), + grant: (...args: Parameters) => provider.grant(...args), + selectTab: () => {}, + }, + widgetFrameUrl: (name: string, revision: number) => provider.widgetFrameUrl(name, revision), + onDockChange: () => {}, + }; + + render( + renderBoardSessionSurface({ + ...props, + workboardCardChip: { + basePath: "", + client, + sessionKey: "agent:main:workboard-link", + }, + }), + linked, + ); + render(renderBoardSessionSurface(props), unlinked); + + const chip = linked.querySelector( + "openclaw-workboard-card-chip", + ); + expect(chip?.sessionKey).toBe("agent:main:workboard-link"); + expect(chip?.client).toBe(client); + expect(unlinked.querySelector("openclaw-workboard-card-chip")).toBeNull(); + }); + it("shows the face toggle only when a board exists", () => { const withoutBoard = createContainer(); const withBoard = createContainer(); diff --git a/ui/src/pages/chat/board-session-surface.ts b/ui/src/pages/chat/board-session-surface.ts index 71180e3a5af3..b831365b44ad 100644 --- a/ui/src/pages/chat/board-session-surface.ts +++ b/ui/src/pages/chat/board-session-surface.ts @@ -1,5 +1,7 @@ import { html, nothing, type TemplateResult } from "lit"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow } from "../../api/types.ts"; +import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts"; import { icons } from "../../components/icons.ts"; import { renderSettingsSegmented } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; @@ -13,6 +15,12 @@ export type BoardChatDockSize = { width: number; }; +export type WorkboardCardChipProps = { + basePath: string; + client: GatewayBrowserClient; + sessionKey: string; +}; + type BoardSessionSurfaceProps = { snapshot: BoardViewSnapshot; sessions: readonly GatewaySessionRow[]; @@ -26,11 +34,19 @@ type BoardSessionSurfaceProps = { canGrant: boolean; callbacks: BoardViewCallbacks; widgetFrameUrl: BoardWidgetFrameUrl; + workboardCardChip?: WorkboardCardChipProps | null; onDockChange: (dock: BoardTab["chatDock"]) => void; }; let boardViewLoad: Promise | null = null; +export function ensureWorkboardCardChipElement(): Promise { + return ensureCustomElementDefined( + "openclaw-workboard-card-chip", + () => import("./workboard-card-chip.runtime.ts"), + ); +} + export async function ensureBoardViewElement(): Promise { if (customElements.get("openclaw-board-view")) { return false; @@ -135,6 +151,15 @@ export function renderBoardDockMenu( function renderBoardView(props: BoardSessionSurfaceProps) { return html`
+ ${props.workboardCardChip + ? html` + + ` + : nothing} this.context?.runtimeConfig, + (runtimeConfig, notify) => runtimeConfig.subscribe(notify), + ) .watch( () => this.resolveBoardProvider(), (provider, notify) => @@ -1608,6 +1615,26 @@ class ChatPane extends OpenClawLightDomElement { this.boardProviderLease = undefined; } + private resolveWorkboardCardChip(board: ResolvedBoardView): WorkboardCardChipProps | null { + const gateway = this.context?.gateway.snapshot; + const enabled = isWorkboardEnabledInConfigSnapshot( + this.context?.runtimeConfig?.state.configSnapshot, + ); + if (!board.hasBoard || board.face !== "dashboard" || !enabled || !gateway?.connected) { + return null; + } + const client = gateway.client; + const state = this.state; + if (!client || !state) { + return null; + } + return { + basePath: state.basePath, + client, + sessionKey: this.resolveBoardSessionKey(board.snapshot.sessionKey), + }; + } + private resolveBoardSessionKey(snapshotSessionKey = ""): string { const resolved = resolveSessionKey( snapshotSessionKey || this.state?.sessionKey || this.sessionKey, @@ -2313,6 +2340,9 @@ class ChatPane extends OpenClawLightDomElement { this.cancelResetConfirmationForSessionChange(); this.syncHistoryObserver(); const board = this.resolveBoardView(); + if (this.resolveWorkboardCardChip(board)) { + void ensureWorkboardCardChipElement().catch(() => undefined); + } if ( board.hasBoard && board.face === "dashboard" && @@ -3492,6 +3522,7 @@ class ChatPane extends OpenClawLightDomElement { gatewayUrl: state.settings.gatewayUrl, }; const chat = renderChat(props); + const workboardCardChip = this.resolveWorkboardCardChip(board); const content = board.hasBoard && board.face === "dashboard" ? renderBoardSessionSurface({ @@ -3520,6 +3551,7 @@ class ChatPane extends OpenClawLightDomElement { board.provider.refreshWidgetAppView(name, revision), } satisfies BoardViewCallbacks, widgetFrameUrl: (name, revision) => board.provider.widgetFrameUrl(name, revision), + workboardCardChip, onDockChange: (dock) => this.handleBoardDockChange(dock), }) : chat; diff --git a/ui/src/pages/chat/workboard-card-chip.runtime.test.ts b/ui/src/pages/chat/workboard-card-chip.runtime.test.ts new file mode 100644 index 000000000000..a711985242bc --- /dev/null +++ b/ui/src/pages/chat/workboard-card-chip.runtime.test.ts @@ -0,0 +1,67 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import "./workboard-card-chip.runtime.ts"; + +type WorkboardCardChipElement = HTMLElementTagNameMap["openclaw-workboard-card-chip"] & { + updateComplete: Promise; +}; + +const mounted: WorkboardCardChipElement[] = []; + +afterEach(() => { + for (const element of mounted.splice(0)) { + element.remove(); + } +}); + +describe("Workboard card chip", () => { + it("loads the matching card and releases its shared lookup lease", async () => { + const removeListener = vi.fn(); + const request = vi.fn(async () => ({ + cards: [ + { + id: "card-1", + title: "Ship dashboard stitch", + status: "review", + priority: "normal", + labels: [], + position: 1, + createdAt: 1, + updatedAt: 2, + sessionKey: "agent:main:workboard-card", + metadata: { automation: { boardId: "platform" } }, + }, + ], + })); + const addEventListener = vi.fn(() => removeListener); + const client = { + request, + addEventListener, + } as unknown as GatewayBrowserClient; + const element = document.createElement("openclaw-workboard-card-chip"); + element.basePath = "/control"; + element.client = client; + element.sessionKey = "agent:main:workboard-card"; + document.body.append(element); + mounted.push(element); + + await vi.waitFor(() => + expect(element.querySelector(".board-session-surface__workboard-chip")).not.toBeNull(), + ); + const link = element.querySelector(".board-session-surface__workboard-chip"); + expect(link?.getAttribute("href")).toBe("/control/workboard?board=platform"); + expect(link?.textContent).toContain("Ship dashboard stitch"); + expect(link?.textContent).toContain("Review"); + expect(request).toHaveBeenCalledWith("workboard.cards.list", {}); + + element.remove(); + await element.updateComplete; + expect(removeListener).toHaveBeenCalledOnce(); + expect(addEventListener).toHaveBeenCalledOnce(); + + document.body.append(element); + await vi.waitFor(() => expect(addEventListener).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/ui/src/pages/chat/workboard-card-chip.runtime.ts b/ui/src/pages/chat/workboard-card-chip.runtime.ts new file mode 100644 index 000000000000..7e52ff0d9269 --- /dev/null +++ b/ui/src/pages/chat/workboard-card-chip.runtime.ts @@ -0,0 +1,106 @@ +import { html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { pathForRoute } from "../../app-route-paths.ts"; +import { icons } from "../../components/icons.ts"; +import { t } from "../../i18n/index.ts"; +import { + acquireWorkboardSessionCardLookup, + type WorkboardSessionCardLookupLease, + type WorkboardSessionCardMatch, +} from "../../lib/workboard/session-card-lookup.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; + +class WorkboardCardChip extends OpenClawLightDomElement { + @property({ attribute: false }) basePath = ""; + @property({ attribute: false }) client: GatewayBrowserClient | null = null; + @property({ attribute: false }) sessionKey = ""; + + @state() private match: WorkboardSessionCardMatch | null = null; + private lease: (WorkboardSessionCardLookupLease & { client: GatewayBrowserClient }) | null = null; + private observedSessionKey = ""; + private unsubscribe: (() => void) | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.synchronizeLookup(); + } + + override updated(): void { + if (this.isConnected) { + this.synchronizeLookup(); + } + } + + override disconnectedCallback(): void { + this.releaseLookup(); + super.disconnectedCallback(); + } + + private synchronizeLookup(): void { + const client = this.client; + const sessionKey = this.sessionKey.trim(); + if (!client || !sessionKey) { + this.releaseLookup(); + return; + } + let lease = this.lease; + if (lease?.client !== client) { + this.releaseLookup(); + lease = { ...acquireWorkboardSessionCardLookup(client), client }; + this.lease = lease; + } + if (this.observedSessionKey === sessionKey) { + return; + } + this.unsubscribe?.(); + this.observedSessionKey = sessionKey; + this.match = null; + this.unsubscribe = lease.subscribe(sessionKey, (match) => { + if (this.lease === lease && this.observedSessionKey === sessionKey) { + this.match = match; + } + }); + } + + private releaseLookup(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.lease?.release(); + this.lease = null; + this.observedSessionKey = ""; + this.match = null; + } + + override render() { + const match = this.match; + if (!match) { + return nothing; + } + const status = t(`workboard.status.${match.status}`); + const href = `${pathForRoute("workboard", this.basePath)}?${new URLSearchParams({ + board: match.boardId, + })}`; + return html` + + ${icons.kanban} + ${match.title} + ${status} + + `; + } +} + +if (!customElements.get("openclaw-workboard-card-chip")) { + customElements.define("openclaw-workboard-card-chip", WorkboardCardChip); +} + +declare global { + interface HTMLElementTagNameMap { + "openclaw-workboard-card-chip": WorkboardCardChip; + } +} diff --git a/ui/src/pages/workboard/view.test.ts b/ui/src/pages/workboard/view.test.ts index 16ca7c71fee2..2d92ee51f307 100644 --- a/ui/src/pages/workboard/view.test.ts +++ b/ui/src/pages/workboard/view.test.ts @@ -56,6 +56,97 @@ function changeWorkboardSelect(select: Element | null | undefined, value: string } describe("renderWorkboard", () => { + it("shows a card dashboard only for linked cards while the plugin is active", () => { + const host = {}; + const state = getWorkboardState(host); + state.loaded = true; + state.detailCardId = "card-1"; + state.cards = [ + { + id: "card-1", + title: "Dashboard-aware card", + status: "running", + priority: "normal", + labels: [], + position: 1, + createdAt: 1, + updatedAt: 1, + }, + ]; + const container = document.createElement("div"); + const props: WorkboardRenderProps = { + host, + client: null, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + }; + + renderInto(container, props); + expect(container.querySelector("openclaw-workboard-card-dashboard")).toBeNull(); + + state.cards = [{ ...state.cards[0]!, sessionKey: "agent:main:dashboard-aware" }]; + renderInto(container, props); + expect(container.querySelector("openclaw-workboard-card-dashboard")).not.toBeNull(); + + renderInto(container, { ...props, pluginEnabled: false }); + expect(container.querySelector("openclaw-workboard-card-dashboard")).toBeNull(); + }); + + it("releases the card dashboard provider when the details panel closes", async () => { + const host = {}; + const state = getWorkboardState(host); + const sessionKey = "agent:main:dashboard-panel-close"; + state.loaded = true; + state.detailCardId = "card-1"; + state.cards = [ + { + id: "card-1", + title: "Disposable dashboard card", + status: "running", + priority: "normal", + labels: [], + position: 1, + createdAt: 1, + updatedAt: 1, + sessionKey, + }, + ]; + const removeListener = vi.fn(); + const request = vi.fn(async () => ({ + sessionKey, + revision: 0, + tabs: [], + widgets: [], + })); + const container = document.createElement("div"); + document.body.append(container); + const props: WorkboardRenderProps = { + host, + client: { + request, + addEventListener: vi.fn(() => removeListener), + } as unknown as GatewayBrowserClient, + connected: true, + pluginEnabled: true, + agentsList: null, + sessions: [], + onOpenSession: () => undefined, + }; + + renderInto(container, props); + await vi.waitFor(() => expect(request).toHaveBeenCalledWith("board.get", { sessionKey })); + + state.detailCardId = null; + renderInto(container, props); + await nextFrame(); + + expect(removeListener).toHaveBeenCalledOnce(); + container.remove(); + }); + it("keeps manual recovery refresh visible while data is loading", () => { const host = {}; const state = getWorkboardState(host); diff --git a/ui/src/pages/workboard/view.ts b/ui/src/pages/workboard/view.ts index 64fe99021c84..43261f548118 100644 --- a/ui/src/pages/workboard/view.ts +++ b/ui/src/pages/workboard/view.ts @@ -4,6 +4,7 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { html, nothing, type TemplateResult } from "lit"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { AgentsListResult, GatewaySessionRow } from "../../api/types.ts"; +import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts"; import { icons } from "../../components/icons.ts"; import "../../components/modal-dialog.ts"; import "../../components/tooltip.ts"; @@ -62,11 +63,19 @@ import { } from "./board-filter.ts"; import { renderWorkboardSelect, type WorkboardSelectOption } from "./workboard-select.ts"; +function ensureWorkboardCardDashboardElement(): Promise { + return ensureCustomElementDefined( + "openclaw-workboard-card-dashboard", + () => import("./workboard-card-dashboard.ts"), + ); +} + type WorkboardProps = { host: object; client: GatewayBrowserClient | null; connected: boolean; canWrite?: boolean; + canGrant?: boolean; canModelOverride?: boolean; pluginEnabled: boolean | null; pluginEnablementError?: string | null; @@ -1433,6 +1442,9 @@ function renderCardDetailsPanel(props: WorkboardProps) { const cardActions = getCardActionState(props, card); const { task, busy, activeTask, live, linkedSessionKey, writable, showStartControls, archived } = cardActions; + if (linkedSessionKey) { + void ensureWorkboardCardDashboardElement().catch(() => undefined); + } const lifecycle = getWorkboardLifecycle(card, props.sessions, task); const formatted = formatLifecycle(lifecycle); const taskIsAuthoritative = task ? taskMatchesLifecycle(task, lifecycle) : false; @@ -1517,6 +1529,17 @@ function renderCardDetailsPanel(props: WorkboardProps) { ` : nothing} + ${linkedSessionKey + ? html` + + ` + : nothing} ${renderDependencyDetailList(dependencies)} ${renderDetailList(t("workboard.fieldLabels"), card.labels)} ${renderDetailList( diff --git a/ui/src/pages/workboard/workboard-card-dashboard.test.ts b/ui/src/pages/workboard/workboard-card-dashboard.test.ts new file mode 100644 index 000000000000..cadeb593a914 --- /dev/null +++ b/ui/src/pages/workboard/workboard-card-dashboard.test.ts @@ -0,0 +1,145 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import "./workboard-card-dashboard.ts"; + +type DashboardElement = HTMLElementTagNameMap["openclaw-workboard-card-dashboard"] & { + updateComplete: Promise; +}; + +const mounted: DashboardElement[] = []; + +function createClient( + widgets: unknown[] = [], + tabs: unknown[] = widgets.length + ? [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }] + : [], +) { + const removeListener = vi.fn(); + const request = vi.fn(async (_method: string, params?: { sessionKey?: string }) => ({ + sessionKey: params?.sessionKey ?? "agent:main:unknown", + revision: 1, + tabs, + widgets, + })); + return { + client: { + request, + addEventListener: vi.fn(() => removeListener), + } as unknown as GatewayBrowserClient, + request, + removeListener, + }; +} + +async function mountDashboard( + sessionKey: string, + client: GatewayBrowserClient, +): Promise { + const element = document.createElement("openclaw-workboard-card-dashboard"); + element.sessionKey = sessionKey; + element.client = client; + element.connected = true; + document.body.append(element); + mounted.push(element); + await vi.waitFor(() => + expect(element.querySelector(".workboard-card-dashboard__toggle")).not.toBeNull(), + ); + return element; +} + +afterEach(() => { + for (const element of mounted.splice(0)) { + element.remove(); + } +}); + +describe("Workboard card dashboard", () => { + it("expands a non-empty live dashboard by default", async () => { + const { client, request } = createClient([ + { + name: "status", + tabId: "main", + title: "Status", + contentKind: "html", + sizeW: 12, + sizeH: 2, + position: 0, + grantState: "none", + revision: 1, + }, + ]); + const element = await mountDashboard("agent:main:workboard-non-empty", client); + + await vi.waitFor(() => expect(request).toHaveBeenCalledWith("board.get", expect.anything())); + await vi.waitFor(() => + expect( + element.querySelector(".workboard-card-dashboard__toggle")?.getAttribute("aria-expanded"), + ).toBe("true"), + ); + expect(element.querySelector("openclaw-board-view")).not.toBeNull(); + }); + + it("keeps an empty dashboard compact until the operator expands its hint", async () => { + const { client, request } = createClient(); + const element = await mountDashboard("agent:main:workboard-empty", client); + + await vi.waitFor(() => expect(request).toHaveBeenCalledWith("board.get", expect.anything())); + await vi.waitFor(() => expect(element.textContent).toContain("No dashboard yet")); + expect( + element.querySelector(".workboard-card-dashboard__toggle")?.getAttribute("aria-expanded"), + ).toBe("false"); + + element.querySelector(".workboard-card-dashboard__toggle")?.click(); + await element.updateComplete; + expect(element.querySelector(".workboard-card-dashboard__body")?.textContent).toContain( + "the working agent can pin widgets", + ); + }); + + it("reacts when the embedded board selects another tab", async () => { + const tabs = [ + { tabId: "main", title: "Main", position: 0, chatDock: "right" }, + { tabId: "research", title: "Research", position: 1, chatDock: "right" }, + ]; + const widgets = tabs.map((tab, position) => ({ + name: `${tab.tabId}-status`, + tabId: tab.tabId, + title: `${tab.title} status`, + contentKind: "html", + sizeW: 12, + sizeH: 2, + position, + grantState: "none", + revision: 1, + })); + const { client } = createClient(widgets, tabs); + const element = await mountDashboard("agent:main:workboard-tabs", client); + await vi.waitFor(() => expect(element.querySelector("wa-tab-group")).not.toBeNull()); + + element + .querySelector("wa-tab-group") + ?.dispatchEvent( + new CustomEvent("wa-tab-show", { bubbles: true, detail: { name: "research" } }), + ); + + await vi.waitFor(() => + expect( + element.querySelector('[data-board-tab-id="research"]')?.getAttribute("active"), + ).not.toBeNull(), + ); + expect(element.querySelector('[data-board-tab-id="main"]')?.getAttribute("active")).toBeNull(); + }); + + it("releases its shared provider lease when removed", async () => { + const { client, request, removeListener } = createClient(); + const element = await mountDashboard("agent:main:workboard-disposal", client); + await vi.waitFor(() => expect(request).toHaveBeenCalled()); + + element.remove(); + await Promise.resolve(); + + expect(removeListener).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/src/pages/workboard/workboard-card-dashboard.ts b/ui/src/pages/workboard/workboard-card-dashboard.ts new file mode 100644 index 000000000000..ccd82e3b7549 --- /dev/null +++ b/ui/src/pages/workboard/workboard-card-dashboard.ts @@ -0,0 +1,188 @@ +import { html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts"; +import { icons } from "../../components/icons.ts"; +import { t } from "../../i18n/index.ts"; +import { + acquireBoardProviderForSession, + boardExists, + boardProviderCacheKey, + boardProviderForSession, + GatewayBoardProvider, + type BoardProvider, + type BoardProviderLease, + type BoardViewCallbacks, +} from "../../lib/board/provider.ts"; +import type { BoardViewSnapshot } from "../../lib/board/view-types.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; + +function ensureBoardViewElement(): Promise { + return ensureCustomElementDefined( + "openclaw-board-view", + () => import("../../components/board/board-view.ts"), + ); +} + +class WorkboardCardDashboard extends OpenClawLightDomElement { + @property({ attribute: false }) sessionKey = ""; + @property({ attribute: false }) client: GatewayBrowserClient | null = null; + @property({ attribute: false }) connected = false; + @property({ attribute: false }) canMutate = false; + @property({ attribute: false }) canGrant = false; + + @state() private provider: BoardProvider | null = null; + @state() private expanded = false; + @state() private activeTabId = ""; + private lease: + | (BoardProviderLease & { client: GatewayBrowserClient; sessionKey: string }) + | null = null; + private unsubscribeSnapshot: (() => void) | null = null; + private expansionInitialized = false; + + override updated(): void { + void ensureBoardViewElement().catch(() => undefined); + this.synchronizeProvider(); + } + + override disconnectedCallback(): void { + this.releaseProvider(); + super.disconnectedCallback(); + } + + private synchronizeProvider(): void { + const sessionKey = this.sessionKey.trim(); + const client = this.client; + if (!sessionKey || !client) { + this.releaseProvider(); + return; + } + const key = boardProviderCacheKey(sessionKey); + if (this.lease?.client === client && this.lease.sessionKey === key) { + boardProviderForSession( + key, + client, + true, + this.connected, + false, + false, + this.canMutate, + this.canGrant, + ); + return; + } + + this.releaseProvider(); + this.expansionInitialized = false; + this.activeTabId = ""; + const lease = acquireBoardProviderForSession( + key, + client, + this.connected, + false, + false, + this.canMutate, + this.canGrant, + ); + this.lease = { ...lease, client, sessionKey: key }; + this.provider = lease.provider; + this.unsubscribeSnapshot = lease.provider.snapshot$.subscribe(() => { + this.reconcileSnapshot(lease.provider); + this.requestUpdate(); + }); + this.reconcileSnapshot(lease.provider); + this.requestUpdate(); + } + + private releaseProvider(): void { + this.unsubscribeSnapshot?.(); + this.unsubscribeSnapshot = null; + this.lease?.release(); + this.lease = null; + this.provider = null; + } + + private reconcileSnapshot(provider: BoardProvider): void { + const snapshot = provider.snapshot$.value; + const firstTabId = snapshot.tabs[0]?.tabId ?? ""; + if (!snapshot.tabs.some((tab) => tab.tabId === this.activeTabId)) { + this.activeTabId = firstTabId; + } + const loaded = !(provider instanceof GatewayBoardProvider) || provider.hasLoadedSnapshot; + if (!this.expansionInitialized && loaded) { + this.expansionInitialized = true; + this.expanded = boardExists(snapshot); + } + } + + override render() { + const provider = this.provider; + const snapshot = provider?.snapshot$.value; + const hasBoard = Boolean(snapshot && boardExists(snapshot)); + const callbacks = provider + ? ({ + applyOps: (ops) => provider.applyOps(ops), + grant: (name, decision) => provider.grant(name, decision), + selectTab: (tabId) => { + this.activeTabId = tabId; + }, + frameLoadFailed: (name) => provider.refreshWidgetFrame(name), + widgetAppView: (name, revision) => provider.widgetAppView(name, revision), + refreshWidgetAppView: (name, revision) => provider.refreshWidgetAppView(name, revision), + } satisfies BoardViewCallbacks) + : null; + const boardSnapshot = snapshot as BoardViewSnapshot | undefined; + + return html` +
+ +
+ ${hasBoard && provider && boardSnapshot && callbacks + ? html` + + provider.widgetFrameUrl(name, revision)} + .callbacks=${callbacks} + .sessions=${[]} + .canMutate=${provider.canMutate} + .canGrant=${provider.canGrant} + > + ` + : html`

${t("workboard.dashboardEmpty")}

`} +
+ ${!this.expanded && this.expansionInitialized && !hasBoard + ? html`

+ ${t("workboard.dashboardEmpty")} +

` + : nothing} +
+ `; + } +} + +if (!customElements.get("openclaw-workboard-card-dashboard")) { + customElements.define("openclaw-workboard-card-dashboard", WorkboardCardDashboard); +} + +declare global { + interface HTMLElementTagNameMap { + "openclaw-workboard-card-dashboard": WorkboardCardDashboard; + } +} diff --git a/ui/src/pages/workboard/workboard-page.ts b/ui/src/pages/workboard/workboard-page.ts index 5edd9a1de97f..b4a5a4b59a55 100644 --- a/ui/src/pages/workboard/workboard-page.ts +++ b/ui/src/pages/workboard/workboard-page.ts @@ -3,7 +3,11 @@ import { html, nothing, type PropertyValues } from "lit"; import { property } from "lit/decorators.js"; import { titleForRoute } from "../../app-navigation.ts"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; -import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts"; +import { + hasOperatorAdminAccess, + hasOperatorApprovalsAccess, + hasOperatorWriteAccess, +} from "../../app/operator-access.ts"; import { renderAgentScopeControl } from "../../components/agent-scope-control.ts"; import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts"; import { searchForSession } from "../../lib/sessions/index.ts"; @@ -268,6 +272,7 @@ class WorkboardPage extends OpenClawLightDomElement { client: gateway.client, connected: gateway.connected, canWrite: hasOperatorWriteAccess(auth), + canGrant: hasOperatorApprovalsAccess(auth), canModelOverride: hasOperatorAdminAccess(auth), pluginEnabled, pluginEnablementError: diff --git a/ui/src/styles/chat/board.css b/ui/src/styles/chat/board.css index ce499014e8e6..ee566d481dc3 100644 --- a/ui/src/styles/chat/board.css +++ b/ui/src/styles/chat/board.css @@ -69,12 +69,74 @@ .board-session-surface__board { display: flex; + flex-direction: column; flex: 1 1 0; min-width: 0; min-height: 0; overflow: hidden; } +openclaw-workboard-card-chip { + display: contents; +} + +.board-session-surface__workboard-chip { + display: inline-flex; + flex: 0 1 auto; + align-items: center; + align-self: flex-start; + gap: 7px; + max-width: calc(100% - 32px); + min-height: 28px; + margin: 12px 16px 0; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + border-radius: var(--radius-full); + color: var(--muted); + background: color-mix(in srgb, var(--panel-strong) 84%, transparent); + font-size: 11px; + line-height: 1; + text-decoration: none; +} + +.board-session-surface__workboard-chip:hover, +.board-session-surface__workboard-chip:focus-visible { + border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); + color: var(--text); +} + +.board-session-surface__workboard-chip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.board-session-surface__workboard-chip svg { + width: 14px; + height: 14px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.board-session-surface__workboard-title { + min-width: 0; + overflow: hidden; + color: var(--text); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.board-session-surface__workboard-status { + flex: 0 0 auto; + padding-left: 7px; + border-left: 1px solid var(--border); + color: var(--muted); +} + /* Scroll owner for the board face. Widget/tab menus stay usable because wa-popup renders them in the top layer, so this overflow cannot clip them. Inline padding lives on .board-view below: macOS overlay scrollbars render diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index 58bb704d8516..29cd4c2c2d97 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -1298,6 +1298,90 @@ white-space: pre-wrap; } +openclaw-workboard-card-dashboard { + display: block; +} + +.workboard-card-dashboard { + padding-top: 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); +} + +.workboard-card-dashboard__toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0; + border: 0; + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.workboard-card-dashboard__toggle:focus-visible { + border-radius: 6px; + outline: 2px solid color-mix(in srgb, var(--accent) 68%, transparent); + outline-offset: 4px; +} + +.workboard-card-dashboard__title { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 0.74rem; + font-weight: 700; + text-transform: uppercase; +} + +.workboard-card-dashboard__title svg, +.workboard-card-dashboard__chevron svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.workboard-card-dashboard__chevron { + display: inline-flex; + transition: transform 140ms ease; +} + +.workboard-card-dashboard__toggle[aria-expanded="true"] .workboard-card-dashboard__chevron { + transform: rotate(180deg); +} + +.workboard-card-dashboard__body { + min-width: 0; + padding-top: 10px; +} + +.workboard-card-dashboard__body[hidden] { + display: none; +} + +.workboard-card-dashboard__body .board-view { + gap: 9px; +} + +.workboard-card-dashboard__body .board-grid { + gap: 8px; + grid-auto-rows: 44px; +} + +.workboard-detail__section p.workboard-card-dashboard__empty, +.workboard-detail__section p.workboard-card-dashboard__collapsed-empty { + color: var(--muted); + font-size: 0.78rem; +} + +.workboard-detail__section p.workboard-card-dashboard__collapsed-empty { + margin-top: -2px; +} + .workboard-detail__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));