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
This commit is contained in:
Peter Steinberger
2026-07-20 19:27:24 -07:00
committed by GitHub
parent 64607ba63d
commit 123c58d3ab
21 changed files with 1636 additions and 27 deletions
+210
View File
@@ -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<void> {
}, 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();
}
}
});
});
+3
View File
@@ -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",
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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<T>() {
+1 -22
View File
@@ -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);
}
}
+19
View File
@@ -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;
@@ -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<string, unknown> = {}) {
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();
});
});
+308
View File
@@ -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<GatewayBrowserClient, "request" | "addEventListener">;
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<string, WorkboardSessionCardMatch>;
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<string, WorkboardSessionCardMatch> {
const matches = new Map<string, WorkboardSessionCardMatch>();
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<string, Set<WorkboardSessionCardListener>>();
private readonly unsubscribeGateway: () => void;
private matches = new Map<string, WorkboardSessionCardMatch>();
private refreshPromise: Promise<void> | undefined;
private runCandidates: WorkboardCard[] = [];
private runCandidateIndex = 0;
private runScanPromise: Promise<void> | 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<void> {
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<WorkboardLookupSnapshot> {
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<void> {
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<WorkboardLookupClient, LookupEntry>();
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();
}
},
};
}
@@ -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`<div>chat</div>`,
divider: html`<div></div>`,
canMutate: true,
canGrant: true,
callbacks: {
applyOps: (ops: Parameters<typeof provider.applyOps>[0]) => provider.applyOps(ops),
grant: (...args: Parameters<typeof provider.grant>) => 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<HTMLElementTagNameMap["openclaw-workboard-card-chip"]>(
"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();
@@ -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<unknown> | null = null;
export function ensureWorkboardCardChipElement(): Promise<void> {
return ensureCustomElementDefined(
"openclaw-workboard-card-chip",
() => import("./workboard-card-chip.runtime.ts"),
);
}
export async function ensureBoardViewElement(): Promise<boolean> {
if (customElements.get("openclaw-board-view")) {
return false;
@@ -135,6 +151,15 @@ export function renderBoardDockMenu(
function renderBoardView(props: BoardSessionSurfaceProps) {
return html`
<div class="board-session-surface__board">
${props.workboardCardChip
? html`
<openclaw-workboard-card-chip
.basePath=${props.workboardCardChip.basePath}
.client=${props.workboardCardChip.client}
.sessionKey=${props.workboardCardChip.sessionKey}
></openclaw-workboard-card-chip>
`
: nothing}
<openclaw-board-view
.snapshot=${props.snapshot}
.activeTabId=${props.activeTabId}
+32
View File
@@ -91,6 +91,7 @@ import {
isGatewayCapabilityAdvertised,
isGatewayMethodAdvertised,
} from "../../lib/gateway-methods.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../../lib/plugin-activation.ts";
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
import {
announceCatalogSessionContinued,
@@ -119,10 +120,12 @@ import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
ensureBoardViewElement,
ensureWorkboardCardChipElement,
renderBoardDockMenu,
renderBoardFaceToggle,
renderBoardSessionSurface,
type BoardChatDockSize,
type WorkboardCardChipProps,
} from "./board-session-surface.ts";
import { catalogMessageId } from "./catalog-message-id.ts";
import { refreshChatAvatar } from "./chat-avatar.ts";
@@ -447,6 +450,10 @@ class ChatPane extends OpenClawLightDomElement {
notify();
}),
)
.watch(
() => 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;
@@ -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<boolean>;
};
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<HTMLAnchorElement>(".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));
});
});
@@ -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`
<a
class="board-session-surface__workboard-chip"
href=${href}
aria-label=${t("chat.board.workboardCard", { title: match.title, status })}
>
${icons.kanban}
<span class="board-session-surface__workboard-title">${match.title}</span>
<span class="board-session-surface__workboard-status">${status}</span>
</a>
`;
}
}
if (!customElements.get("openclaw-workboard-card-chip")) {
customElements.define("openclaw-workboard-card-chip", WorkboardCardChip);
}
declare global {
interface HTMLElementTagNameMap {
"openclaw-workboard-card-chip": WorkboardCardChip;
}
}
+91
View File
@@ -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);
+23
View File
@@ -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<void> {
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) {
</section>
`
: nothing}
${linkedSessionKey
? html`
<openclaw-workboard-card-dashboard
.sessionKey=${linkedSessionKey}
.client=${props.client}
.connected=${props.connected}
.canMutate=${props.canWrite !== false}
.canGrant=${props.canGrant === true}
></openclaw-workboard-card-dashboard>
`
: nothing}
${renderDependencyDetailList(dependencies)}
${renderDetailList(t("workboard.fieldLabels"), card.labels)}
${renderDetailList(
@@ -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<boolean>;
};
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<DashboardElement> {
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<HTMLButtonElement>(".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();
});
});
@@ -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<void> {
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`
<section class="workboard-detail__section workboard-card-dashboard">
<button
type="button"
class="workboard-card-dashboard__toggle"
aria-expanded=${this.expanded ? "true" : "false"}
@click=${() => {
this.expansionInitialized = true;
this.expanded = !this.expanded;
}}
>
<span class="workboard-card-dashboard__title">
${icons.kanban}<span>${t("workboard.dashboardTitle")}</span>
</span>
<span class="workboard-card-dashboard__chevron" aria-hidden="true"
>${icons.arrowDown}</span
>
</button>
<div class="workboard-card-dashboard__body" ?hidden=${!this.expanded}>
${hasBoard && provider && boardSnapshot && callbacks
? html`
<openclaw-board-view
.snapshot=${boardSnapshot}
.activeTabId=${this.activeTabId}
.widgetFrameUrl=${(name: string, revision: number) =>
provider.widgetFrameUrl(name, revision)}
.callbacks=${callbacks}
.sessions=${[]}
.canMutate=${provider.canMutate}
.canGrant=${provider.canGrant}
></openclaw-board-view>
`
: html`<p class="workboard-card-dashboard__empty">${t("workboard.dashboardEmpty")}</p>`}
</div>
${!this.expanded && this.expansionInitialized && !hasBoard
? html`<p class="workboard-card-dashboard__collapsed-empty">
${t("workboard.dashboardEmpty")}
</p>`
: nothing}
</section>
`;
}
}
if (!customElements.get("openclaw-workboard-card-dashboard")) {
customElements.define("openclaw-workboard-card-dashboard", WorkboardCardDashboard);
}
declare global {
interface HTMLElementTagNameMap {
"openclaw-workboard-card-dashboard": WorkboardCardDashboard;
}
}
+6 -1
View File
@@ -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:
+62
View File
@@ -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
+84
View File
@@ -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));