mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
committed by
GitHub
parent
64607ba63d
commit
123c58d3ab
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user