mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): keep dashboards alive across view switches (#120640)
* fix(ui): retain dashboards across view switches Keep visited dashboard runtimes alive across Chat, Split, and Dashboard transitions, suspend hidden work, and avoid same-face persistence requests. Refs #120639 * fix(ui): preserve session owner during route loads Carry the last successful route match through pending loads so face-only navigation retains dashboard runtimes without stranding session or catalog switches. Refs #120639 * fix(ui): preserve sandbox bridge while hidden Suspend hidden board frame work without disposing the one-shot sandbox handshake, loaded document, or MessagePort. Refs #120639 * fix(ui): forward sandbox readiness while hidden Keep the constrained sandbox message channel alive during dashboard suspension so one-shot proxy readiness reaches the retained host without resuming widget work. Refs #120639 * fix(ui): pause hidden plugin widgets Propagate board activity through trusted plugin renderers so Workboard widgets suppress hidden refresh and mutation work, then refresh once without remounting. Refs #120639
This commit is contained in:
committed by
GitHub
parent
39bbdd6142
commit
bd6d35443f
+11
-1
@@ -1,5 +1,11 @@
|
||||
import { createRouter } from "@openclaw/uirouter";
|
||||
import type { PageDefinition, RouteLocation, Router, RouterHistory } from "@openclaw/uirouter";
|
||||
import type {
|
||||
PageDefinition,
|
||||
RouteLocation,
|
||||
RouteMatch,
|
||||
Router,
|
||||
RouterHistory,
|
||||
} from "@openclaw/uirouter";
|
||||
import {
|
||||
agentRouteFromPath,
|
||||
INTERNAL_AGENT_PATH_PARAM,
|
||||
@@ -51,6 +57,10 @@ import { page as worktreesPage } from "./pages/worktrees/route.ts";
|
||||
|
||||
type AppRouteModule = {
|
||||
render: (data: unknown) => unknown;
|
||||
renderOwnerKey?: (
|
||||
match: Pick<RouteMatch, "data" | "location">,
|
||||
settled: Pick<RouteMatch, "data" | "location"> | undefined,
|
||||
) => string | undefined;
|
||||
};
|
||||
|
||||
export type ApplicationRouter = Router<
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
createRouter,
|
||||
definePage,
|
||||
type RouteLocation,
|
||||
type RouteMatch,
|
||||
type Router,
|
||||
} from "@openclaw/uirouter";
|
||||
import { html, nothing, type LitElement } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
SESSION_COMPOSER_FOCUS_PARAM,
|
||||
SESSION_NAVIGATION_KEY_PARAM,
|
||||
sessionNavigationTarget,
|
||||
} from "../lib/sessions/route-navigation.ts";
|
||||
import type { ChatRouteData } from "../pages/chat/route-loader.ts";
|
||||
import { pages as chatPages } from "../pages/chat/route.ts";
|
||||
import { settleLitElement } from "../test-helpers/lit-settle.ts";
|
||||
import "./router-outlet.ts";
|
||||
|
||||
type RouteId = "chat" | "dashboard";
|
||||
type TestContext = Record<string, never>;
|
||||
type OwnerMatch = Pick<RouteMatch<string, unknown, ChatRouteData>, "data" | "location">;
|
||||
type TestModule = {
|
||||
render: (data: ChatRouteData | undefined) => unknown;
|
||||
renderOwnerKey?: (match: OwnerMatch, settled: OwnerMatch | undefined) => string | undefined;
|
||||
};
|
||||
type TestRouter = Router<RouteId, TestContext, TestModule, ChatRouteData>;
|
||||
type RouterOutletElement = LitElement & { router?: TestRouter };
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function location(pathname: string, search = ""): RouteLocation {
|
||||
return { pathname, search, hash: "" };
|
||||
}
|
||||
|
||||
function sessionData(sessionKey: string, face: "chat" | "dashboard"): ChatRouteData {
|
||||
return { kind: "session", sessionKey, agentId: "main", face, shortId: "12345678" };
|
||||
}
|
||||
|
||||
function createOutlet(router: TestRouter): RouterOutletElement {
|
||||
const outlet = document.createElement("openclaw-router-outlet") as RouterOutletElement;
|
||||
outlet.router = router;
|
||||
document.body.append(outlet);
|
||||
return outlet;
|
||||
}
|
||||
|
||||
async function settleOutlet(outlet: RouterOutletElement): Promise<void> {
|
||||
await settleLitElement(outlet);
|
||||
}
|
||||
|
||||
function ownedRenderer(teardown: () => Promise<void>) {
|
||||
return (data: ChatRouteData | undefined) =>
|
||||
data
|
||||
? html`
|
||||
<mcp-app-view
|
||||
${ref((element) => {
|
||||
if (element) {
|
||||
Reflect.set(element, "restartAfterTeardown", vi.fn());
|
||||
Reflect.set(element, "teardown", teardown);
|
||||
}
|
||||
})}
|
||||
></mcp-app-view>
|
||||
<div data-testid="route-value">${data.kind === "session" ? data.face : "chooser"}</div>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
async function routeModule(
|
||||
face: "chat" | "dashboard",
|
||||
render: TestModule["render"],
|
||||
): Promise<TestModule> {
|
||||
const declared = await chatPages[face === "chat" ? 0 : 1].component();
|
||||
return { renderOwnerKey: declared.renderOwnerKey, render };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__");
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("openclaw-router-outlet chat ownership", () => {
|
||||
it("retains the exact subtree while the same thread switches presentation face", async () => {
|
||||
const sessionKey = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
|
||||
const row = { key: sessionKey, displayName: "Retained board" };
|
||||
const chatTarget = sessionNavigationTarget({
|
||||
face: "chat",
|
||||
sessionKey,
|
||||
fallbackAgentId: "main",
|
||||
row,
|
||||
});
|
||||
const dashboardTarget = sessionNavigationTarget({
|
||||
face: "dashboard",
|
||||
sessionKey,
|
||||
fallbackAgentId: "main",
|
||||
row,
|
||||
});
|
||||
const nextData = deferred<ChatRouteData>();
|
||||
const teardown = vi.fn(async () => undefined);
|
||||
const render = ownedRenderer(teardown);
|
||||
const chatModule = await routeModule("chat", render);
|
||||
const dashboardModule = await routeModule("dashboard", render);
|
||||
const router = createRouter<RouteId, TestContext, TestModule, ChatRouteData>({
|
||||
routes: [
|
||||
definePage({
|
||||
id: "chat",
|
||||
path: "/chat",
|
||||
component: () => chatModule,
|
||||
loader: () => sessionData(sessionKey, "chat"),
|
||||
}),
|
||||
definePage({
|
||||
id: "dashboard",
|
||||
path: "/dashboard",
|
||||
component: () => dashboardModule,
|
||||
loader: () => nextData.promise,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const outlet = createOutlet(router);
|
||||
await router.navigate("chat", {}, undefined, location(chatTarget.href));
|
||||
await settleOutlet(outlet);
|
||||
const appView = outlet.querySelector("mcp-app-view");
|
||||
|
||||
const navigation = router.navigate(
|
||||
"dashboard",
|
||||
{},
|
||||
undefined,
|
||||
location(dashboardTarget.options.pathname, dashboardTarget.options.search),
|
||||
);
|
||||
await settleOutlet(outlet);
|
||||
expect(outlet.querySelector("mcp-app-view")).toBe(appView);
|
||||
expect(outlet.querySelector('[data-testid="route-value"]')?.textContent).toBe("chat");
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
|
||||
nextData.resolve(sessionData(sessionKey, "dashboard"));
|
||||
await navigation;
|
||||
await settleOutlet(outlet);
|
||||
expect(outlet.querySelector("mcp-app-view")).toBe(appView);
|
||||
expect(outlet.querySelector('[data-testid="route-value"]')?.textContent).toBe("dashboard");
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
router.stop();
|
||||
});
|
||||
|
||||
it("retains a full-key-hinted route while it cleans up to its recorded canonical location", async () => {
|
||||
const sessionKey = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
|
||||
const clean = location("/chat/main/deploy-monitor-12345678");
|
||||
const hintedSearch = new URLSearchParams({
|
||||
[SESSION_NAVIGATION_KEY_PARAM]: sessionKey,
|
||||
draft: "ship it",
|
||||
[SESSION_COMPOSER_FOCUS_PARAM]: "1",
|
||||
});
|
||||
const hinted = location("/chat/main/wrong-name-12345678", `?${hintedSearch.toString()}`);
|
||||
const canonical = location(
|
||||
clean.pathname,
|
||||
`?${new URLSearchParams({ draft: "ship it", [SESSION_COMPOSER_FOCUS_PARAM]: "1" })}`,
|
||||
);
|
||||
const initial = { ...sessionData(sessionKey, "chat"), canonicalLocation: canonical };
|
||||
const nextData = deferred<ChatRouteData>();
|
||||
let loadCount = 0;
|
||||
const teardown = vi.fn(async () => undefined);
|
||||
const module = await routeModule("chat", ownedRenderer(teardown));
|
||||
const router = createRouter<RouteId, TestContext, TestModule, ChatRouteData>({
|
||||
routes: [
|
||||
definePage({
|
||||
id: "chat",
|
||||
path: "/chat",
|
||||
component: () => module,
|
||||
loaderDeps: (_context, routeLocation) =>
|
||||
`${routeLocation.pathname}${routeLocation.search}`,
|
||||
loader: () => (++loadCount === 1 ? initial : nextData.promise),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const outlet = createOutlet(router);
|
||||
await router.navigate("chat", {}, undefined, hinted);
|
||||
await settleOutlet(outlet);
|
||||
const appView = outlet.querySelector("mcp-app-view");
|
||||
|
||||
const navigation = router.navigate("chat", {}, undefined, clean);
|
||||
await settleOutlet(outlet);
|
||||
expect(outlet.querySelector("mcp-app-view")).toBe(appView);
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
|
||||
nextData.resolve(sessionData(sessionKey, "chat"));
|
||||
await navigation;
|
||||
await settleOutlet(outlet);
|
||||
expect(outlet.querySelector("mcp-app-view")).toBe(appView);
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
router.stop();
|
||||
});
|
||||
|
||||
it("does not retain a colliding short path when its full-key hint changes", async () => {
|
||||
const firstKey = "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001";
|
||||
const secondKey = "agent:main:dashboard:12345678-0bbb-4000-8000-000000000002";
|
||||
const pathname = "/chat/main/deploy-monitor-12345678";
|
||||
const nextData = deferred<ChatRouteData>();
|
||||
let loadCount = 0;
|
||||
const teardown = vi.fn(async () => undefined);
|
||||
const module = await routeModule("chat", ownedRenderer(teardown));
|
||||
const router = createRouter<RouteId, TestContext, TestModule, ChatRouteData>({
|
||||
routes: [
|
||||
definePage({
|
||||
id: "chat",
|
||||
path: "/chat",
|
||||
component: () => module,
|
||||
loaderDeps: (_context, routeLocation) => routeLocation.search,
|
||||
loader: () => (++loadCount === 1 ? sessionData(firstKey, "chat") : nextData.promise),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const outlet = createOutlet(router);
|
||||
await router.navigate(
|
||||
"chat",
|
||||
{},
|
||||
undefined,
|
||||
location(pathname, `?${SESSION_NAVIGATION_KEY_PARAM}=${encodeURIComponent(firstKey)}`),
|
||||
);
|
||||
await settleOutlet(outlet);
|
||||
const firstView = outlet.querySelector("mcp-app-view");
|
||||
|
||||
const navigation = router.navigate(
|
||||
"chat",
|
||||
{},
|
||||
undefined,
|
||||
location(pathname, `?${SESSION_NAVIGATION_KEY_PARAM}=${encodeURIComponent(secondKey)}`),
|
||||
);
|
||||
await expect.poll(() => outlet.querySelector("mcp-app-view")).toBeNull();
|
||||
expect(teardown).toHaveBeenCalledOnce();
|
||||
|
||||
nextData.resolve(sessionData(secondKey, "chat"));
|
||||
await navigation;
|
||||
await settleOutlet(outlet);
|
||||
expect(outlet.querySelector("mcp-app-view")).not.toBe(firstView);
|
||||
router.stop();
|
||||
});
|
||||
|
||||
it("replaces the old owner for a clean unresolved route and its ambiguous result", async () => {
|
||||
const sessionKey = "agent:main:dashboard:12345678-0aaa-4000-8000-000000000001";
|
||||
const nextData = deferred<ChatRouteData>();
|
||||
let loadCount = 0;
|
||||
const teardown = vi.fn(async () => undefined);
|
||||
const module = await routeModule("chat", ownedRenderer(teardown));
|
||||
const router = createRouter<RouteId, TestContext, TestModule, ChatRouteData>({
|
||||
routes: [
|
||||
definePage({
|
||||
id: "chat",
|
||||
path: "/chat",
|
||||
component: () => module,
|
||||
loaderDeps: (_context, routeLocation) => routeLocation.pathname,
|
||||
loader: () => (++loadCount === 1 ? sessionData(sessionKey, "chat") : nextData.promise),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const outlet = createOutlet(router);
|
||||
await router.navigate("chat", {}, undefined, location("/chat/main/alpha-12345678"));
|
||||
await settleOutlet(outlet);
|
||||
|
||||
const navigation = router.navigate("chat", {}, undefined, location("/chat/main/beta-12345678"));
|
||||
await expect.poll(() => outlet.querySelector("mcp-app-view")).toBeNull();
|
||||
expect(teardown).toHaveBeenCalledOnce();
|
||||
|
||||
nextData.resolve({
|
||||
kind: "ambiguous",
|
||||
shortId: "12345678",
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
face: "chat",
|
||||
});
|
||||
await navigation;
|
||||
await settleOutlet(outlet);
|
||||
expect(outlet.querySelector('[data-testid="route-value"]')?.textContent).toBe("chooser");
|
||||
expect(outlet.querySelector("mcp-app-view")).not.toBeNull();
|
||||
router.stop();
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe("RouterOutletController pending presentation", () => {
|
||||
router.stop();
|
||||
});
|
||||
|
||||
it("keeps active content while the next route module is cold", async () => {
|
||||
it("carries the last settled match through a cold and module-loaded navigation", async () => {
|
||||
vi.useFakeTimers();
|
||||
const secondModule = deferred<TestModule>();
|
||||
const secondData = deferred<TestData>();
|
||||
@@ -103,11 +103,14 @@ describe("RouterOutletController pending presentation", () => {
|
||||
controller.setInputs({ router });
|
||||
controller.connect();
|
||||
await router.navigate("first", { label: "test" });
|
||||
expect(controller.snapshot.settled).toBe(controller.snapshot.active);
|
||||
expect(controller.snapshot.settled?.routeId).toBe("first");
|
||||
|
||||
const navigation = router.navigate("second", { label: "test" });
|
||||
expect(
|
||||
selectRenderedRouteMatch(controller.snapshot.active, controller.snapshot.pending)?.routeId,
|
||||
).toBe("first");
|
||||
expect(controller.snapshot.settled?.routeId).toBe("first");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(controller.snapshot.showPending).toBe(false);
|
||||
|
||||
@@ -117,11 +120,21 @@ describe("RouterOutletController pending presentation", () => {
|
||||
selectRenderedRouteMatch(controller.snapshot.active, controller.snapshot.pending)?.routeId,
|
||||
).toBe("second");
|
||||
expect(controller.snapshot.active?.data).toBeUndefined();
|
||||
expect(controller.snapshot.active?.status).toBe("pending");
|
||||
expect(controller.snapshot.settled?.routeId).toBe("first");
|
||||
|
||||
secondData.resolve({ label: "second" });
|
||||
await navigation;
|
||||
expect(controller.snapshot.active?.routeId).toBe("second");
|
||||
expect(controller.snapshot.settled).toBe(controller.snapshot.active);
|
||||
|
||||
const replacement = createRouter<RouteId, TestContext, TestModule, TestData>({ routes: [] });
|
||||
controller.setInputs({ router: replacement });
|
||||
expect(controller.snapshot.status).toBe("idle");
|
||||
expect(controller.snapshot.settled).toBeUndefined();
|
||||
controller.disconnect();
|
||||
router.stop();
|
||||
replacement.stop();
|
||||
});
|
||||
|
||||
it("restarts a canceled pending delay after reconnect", async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export type RouterOutletSnapshot<
|
||||
TModule = unknown,
|
||||
TData = unknown,
|
||||
> = RouterOutletStateSlice<TRouteId, TModule, TData> & {
|
||||
settled: RouteMatch<TRouteId, TModule, TData> | undefined;
|
||||
showPending: boolean;
|
||||
};
|
||||
|
||||
@@ -68,6 +69,7 @@ function idleSnapshot<TRouteId extends string, TModule, TData>(): RouterOutletSn
|
||||
status: "idle",
|
||||
active: undefined,
|
||||
pending: undefined,
|
||||
settled: undefined,
|
||||
showPending: false,
|
||||
};
|
||||
}
|
||||
@@ -89,6 +91,7 @@ export class RouterOutletController<
|
||||
private unsubscribe?: () => void;
|
||||
private selection: RouterOutletStateSlice<TRouteId, TModule, TData> = idleSnapshot();
|
||||
private snapshotValue: RouterOutletSnapshot<TRouteId, TModule, TData> = idleSnapshot();
|
||||
private settled?: RouteMatch<TRouteId, TModule, TData>;
|
||||
private pendingMatchId?: string;
|
||||
private pendingTimer?: ReturnType<typeof globalThis.setTimeout>;
|
||||
private showPending = false;
|
||||
@@ -116,6 +119,7 @@ export class RouterOutletController<
|
||||
|
||||
this.detachSource();
|
||||
this.router = inputs.router;
|
||||
this.settled = undefined;
|
||||
if (this.connected) {
|
||||
this.attachSource();
|
||||
return;
|
||||
@@ -123,8 +127,7 @@ export class RouterOutletController<
|
||||
const selection = inputs.router
|
||||
? selectRouterOutletState(inputs.router.getState())
|
||||
: idleSnapshot<TRouteId, TModule, TData>();
|
||||
this.selection = selection;
|
||||
this.publish({ ...selection, showPending: false });
|
||||
this.applySelection(selection);
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
@@ -148,7 +151,11 @@ export class RouterOutletController<
|
||||
|
||||
private attachSource(notify = true): void {
|
||||
const router = this.router;
|
||||
if (!router || this.unsubscribe) {
|
||||
if (this.unsubscribe) {
|
||||
return;
|
||||
}
|
||||
if (!router) {
|
||||
this.applySelection(idleSnapshot<TRouteId, TModule, TData>(), notify);
|
||||
return;
|
||||
}
|
||||
this.applySelection(selectRouterOutletState(router.getState()), notify);
|
||||
@@ -173,6 +180,14 @@ export class RouterOutletController<
|
||||
notify = true,
|
||||
): void {
|
||||
this.selection = selection;
|
||||
if (selection.status === "idle") {
|
||||
this.settled = undefined;
|
||||
} else {
|
||||
const rendered = selectRenderedRouteMatch(selection.active, selection.pending);
|
||||
if (rendered?.status === "success") {
|
||||
this.settled = rendered;
|
||||
}
|
||||
}
|
||||
const pending = selection.pending;
|
||||
const coldPending =
|
||||
pending?.status === "pending" && pending.module === undefined && pending.error === undefined;
|
||||
@@ -190,7 +205,7 @@ export class RouterOutletController<
|
||||
this.schedulePendingFallback(pending.id);
|
||||
}
|
||||
|
||||
this.publish({ ...selection, showPending: this.showPending }, notify);
|
||||
this.publish({ ...selection, settled: this.settled, showPending: this.showPending }, notify);
|
||||
this.updateNotFoundEffect(selection.status);
|
||||
}
|
||||
|
||||
@@ -211,7 +226,7 @@ export class RouterOutletController<
|
||||
return;
|
||||
}
|
||||
this.showPending = true;
|
||||
this.publish({ ...this.selection, showPending: true });
|
||||
this.publish({ ...this.selection, settled: this.settled, showPending: true });
|
||||
}, this.pendingDelayMs);
|
||||
}
|
||||
|
||||
@@ -254,6 +269,7 @@ export class RouterOutletController<
|
||||
previous.status === snapshot.status &&
|
||||
previous.active === snapshot.active &&
|
||||
previous.pending === snapshot.pending &&
|
||||
previous.settled === snapshot.settled &&
|
||||
previous.showPending === snapshot.showPending
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, definePage, type Router } from "@openclaw/uirouter";
|
||||
import { html, type LitElement } from "lit";
|
||||
import { createRouter, definePage, type RouteMatch, type Router } from "@openclaw/uirouter";
|
||||
import { html, nothing, type LitElement } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { settleLitElement } from "../test-helpers/lit-settle.ts";
|
||||
@@ -8,7 +8,14 @@ import "./router-outlet.ts";
|
||||
type RouteId = "page" | "next";
|
||||
type TestContext = { label: string };
|
||||
type TestData = { label: string };
|
||||
type TestModule = { render: (data: TestData | undefined) => unknown };
|
||||
type TestOwnerMatch = Pick<RouteMatch<string, unknown, TestData>, "data" | "location">;
|
||||
type TestModule = {
|
||||
render: (data: TestData | undefined) => unknown;
|
||||
renderOwnerKey?: (
|
||||
match: TestOwnerMatch,
|
||||
settled: TestOwnerMatch | undefined,
|
||||
) => string | undefined;
|
||||
};
|
||||
type TestRouter = Router<RouteId, TestContext, TestModule, TestData>;
|
||||
type RouterOutletElement = LitElement & {
|
||||
router?: TestRouter;
|
||||
@@ -53,6 +60,68 @@ async function settleOutlet(outlet: RouterOutletElement): Promise<void> {
|
||||
}
|
||||
|
||||
describe("openclaw-router-outlet", () => {
|
||||
it("retains MCP Apps across route IDs that share an explicit owner", async () => {
|
||||
const teardownView = vi.fn(async () => undefined);
|
||||
const nextData = deferred<TestData>();
|
||||
const renderOwnedRoute = vi.fn((data: TestData | undefined) =>
|
||||
data
|
||||
? html`
|
||||
<mcp-app-view
|
||||
${ref((element) => {
|
||||
if (element) {
|
||||
Reflect.set(element, "restartAfterTeardown", vi.fn());
|
||||
Reflect.set(element, "teardown", teardownView);
|
||||
}
|
||||
})}
|
||||
></mcp-app-view>
|
||||
<div data-testid="owned-route">${data.label}</div>
|
||||
`
|
||||
: nothing,
|
||||
);
|
||||
const ownedModule: TestModule = {
|
||||
renderOwnerKey: () => "shared-owner",
|
||||
render: renderOwnedRoute,
|
||||
};
|
||||
const context = { label: "page" };
|
||||
const router = createRouter<RouteId, TestContext, TestModule, TestData>({
|
||||
routes: [
|
||||
definePage({
|
||||
id: "page",
|
||||
path: "/page",
|
||||
component: () => ownedModule,
|
||||
loader: () => ({ label: "page" }),
|
||||
}),
|
||||
definePage({
|
||||
id: "next",
|
||||
path: "/next",
|
||||
component: () => ownedModule,
|
||||
loader: () => nextData.promise,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const outlet = createOutlet(router, context);
|
||||
await router.navigate("page", context);
|
||||
await settleOutlet(outlet);
|
||||
const appView = outlet.querySelector("mcp-app-view");
|
||||
|
||||
const navigation = router.navigate("next", context);
|
||||
await settleOutlet(outlet);
|
||||
expect(renderOwnedRoute).toHaveBeenCalledWith(undefined);
|
||||
expect(outlet.querySelector("mcp-app-view")).toBe(appView);
|
||||
expect(outlet.querySelector('[data-testid="owned-route"]')?.textContent).toBe("page");
|
||||
expect(teardownView).not.toHaveBeenCalled();
|
||||
|
||||
nextData.resolve({ label: "next" });
|
||||
await navigation;
|
||||
await settleOutlet(outlet);
|
||||
|
||||
expect(outlet.querySelector("mcp-app-view")).toBe(appView);
|
||||
expect(outlet.querySelector('[data-testid="owned-route"]')?.textContent).toBe("next");
|
||||
expect(teardownView).not.toHaveBeenCalled();
|
||||
outlet.remove();
|
||||
router.stop();
|
||||
});
|
||||
|
||||
it("replaces the centered loading mascot with the resolved route", async () => {
|
||||
vi.useFakeTimers();
|
||||
const routeModule = deferred<TestModule>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Router } from "@openclaw/uirouter";
|
||||
import type { RouteMatch, Router } from "@openclaw/uirouter";
|
||||
import { html, nothing } from "lit";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
@@ -22,6 +22,10 @@ export { selectRenderedRouteMatch } from "./router-outlet-controller.ts";
|
||||
|
||||
type RenderableModule<TData> = {
|
||||
render: (data: TData | undefined) => unknown;
|
||||
renderOwnerKey?: (
|
||||
match: Pick<RouteMatch<string, unknown, TData>, "data" | "location">,
|
||||
settled: Pick<RouteMatch<string, unknown, TData>, "data" | "location"> | undefined,
|
||||
) => string | undefined;
|
||||
};
|
||||
|
||||
type RouterOutletOptions<TLoadContext = unknown> = {
|
||||
@@ -139,10 +143,9 @@ function renderError<TRouteId extends string, TLoadContext, TModule, TData>(
|
||||
function renderRouterOutlet<TRouteId extends string, TLoadContext, TModule, TData = unknown>(
|
||||
router: Router<TRouteId, TLoadContext, TModule, TData>,
|
||||
selection: RouterOutletSnapshot<TRouteId, TModule, TData>,
|
||||
renderedMatch: RouteMatch<TRouteId, TModule, TData> | undefined,
|
||||
options: RouterOutletOptions<TLoadContext> = {},
|
||||
): unknown {
|
||||
const pending = selection.pending;
|
||||
const renderedMatch = selectRenderedRouteMatch(selection.active, pending);
|
||||
if (renderedMatch?.status === "notFound") {
|
||||
return nothing;
|
||||
}
|
||||
@@ -250,14 +253,23 @@ class OpenClawRouterOutlet<
|
||||
}
|
||||
const snapshot = this.outlet.snapshot;
|
||||
const renderedMatch = selectRenderedRouteMatch(snapshot.active, snapshot.pending);
|
||||
const rendered = renderRouterOutlet(this.router, snapshot, {
|
||||
const rendered = renderRouterOutlet(this.router, snapshot, renderedMatch, {
|
||||
retryContext: this.retryContext,
|
||||
});
|
||||
return this.mcpAppUnmountGate.render(
|
||||
renderedMatch ? `${renderedMatch.routeId}:${renderedMatch.status}` : "empty",
|
||||
rendered,
|
||||
() => [this],
|
||||
);
|
||||
const routeKey = renderedMatch ? `${renderedMatch.routeId}:${renderedMatch.status}` : "empty";
|
||||
const routeModule = renderedMatch?.module;
|
||||
const declaredOwnerKey =
|
||||
renderedMatch && isRenderableModule<TData>(routeModule)
|
||||
? routeModule.renderOwnerKey?.(renderedMatch, snapshot.settled)
|
||||
: undefined;
|
||||
const explicitOwnerKey = renderedMatch?.error === undefined ? declaredOwnerKey : undefined;
|
||||
const retainCurrent =
|
||||
explicitOwnerKey !== undefined &&
|
||||
renderedMatch?.status === "pending" &&
|
||||
renderedMatch.data === undefined;
|
||||
return this.mcpAppUnmountGate.render(explicitOwnerKey ?? routeKey, rendered, () => [this], {
|
||||
retainRenderedValue: retainCurrent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const GRANT_NOTICE_HEIGHT_PX = 112;
|
||||
|
||||
type BoardMcpAppContentOptions = {
|
||||
accessNotice: TemplateResult | typeof nothing;
|
||||
active: boolean;
|
||||
appView?: BoardWidgetAppViewState;
|
||||
busy: boolean;
|
||||
loading: boolean;
|
||||
@@ -38,41 +39,41 @@ export function renderBoardMcpAppContent(options: BoardMcpAppContentOptions): Te
|
||||
${t("board.widget.appLoading")}
|
||||
</div>`;
|
||||
const view =
|
||||
!options.nearVisible || !appView
|
||||
? loading
|
||||
: appView.status === "stale"
|
||||
? html`<div class="board-widget__stale" data-test-id="board-mcp-app-stale">
|
||||
<strong>${t("board.widget.appStaleTitle")}</strong>
|
||||
<span>${t("board.widget.appStaleDetail")}</span>
|
||||
<div class="board-widget__grant-actions">
|
||||
<button
|
||||
class="btn btn--small btn--primary"
|
||||
type="button"
|
||||
?disabled=${options.loading}
|
||||
@click=${options.retry}
|
||||
>
|
||||
${t("board.widget.retry")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${options.busy}
|
||||
@click=${options.remove}
|
||||
>
|
||||
${t("board.widget.remove")}
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
: ready
|
||||
? html`<mcp-app-view
|
||||
class="board-widget__mcp-app-view"
|
||||
.sessionKey=${options.sessionKey}
|
||||
.viewId=${ready.viewId}
|
||||
.height=${height}
|
||||
.fixedHeight=${true}
|
||||
.title=${widget.title || widget.name}
|
||||
@openclaw-mcp-app-view-expired=${options.expired}
|
||||
></mcp-app-view>`
|
||||
ready && (!options.active || options.nearVisible)
|
||||
? html`<mcp-app-view
|
||||
class="board-widget__mcp-app-view"
|
||||
.sessionKey=${options.sessionKey}
|
||||
.viewId=${ready.viewId}
|
||||
.height=${height}
|
||||
.fixedHeight=${true}
|
||||
.title=${widget.title || widget.name}
|
||||
@openclaw-mcp-app-view-expired=${options.expired}
|
||||
></mcp-app-view>`
|
||||
: !options.nearVisible || !appView
|
||||
? loading
|
||||
: appView.status === "stale"
|
||||
? html`<div class="board-widget__stale" data-test-id="board-mcp-app-stale">
|
||||
<strong>${t("board.widget.appStaleTitle")}</strong>
|
||||
<span>${t("board.widget.appStaleDetail")}</span>
|
||||
<div class="board-widget__grant-actions">
|
||||
<button
|
||||
class="btn btn--small btn--primary"
|
||||
type="button"
|
||||
?disabled=${options.loading}
|
||||
@click=${options.retry}
|
||||
>
|
||||
${t("board.widget.retry")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${options.busy}
|
||||
@click=${options.remove}
|
||||
>
|
||||
${t("board.widget.remove")}
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
: loading;
|
||||
return html`<div class="board-widget__mcp-app">${options.accessNotice}${view}</div>`;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ type AppViewCallbacks = {
|
||||
};
|
||||
|
||||
type LifecycleHost = {
|
||||
active: () => boolean;
|
||||
connected: () => boolean;
|
||||
requestUpdate: () => void;
|
||||
sessionKey: () => string;
|
||||
@@ -112,6 +113,13 @@ export class BoardMcpAppLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
activityChanged(): void {
|
||||
if (!this.host.active()) {
|
||||
this.visibility.disconnect();
|
||||
this.clearTimers();
|
||||
}
|
||||
}
|
||||
|
||||
observe(target: Element | null, enabled: boolean): void {
|
||||
if (!target || !enabled) {
|
||||
this.visibility.disconnect();
|
||||
@@ -123,7 +131,7 @@ export class BoardMcpAppLifecycle {
|
||||
sync(): void {
|
||||
const widget = this.host.widget();
|
||||
const callbacks = this.callbacks;
|
||||
if (!widget || widget.contentKind !== "mcp-app" || !callbacks) {
|
||||
if (!this.host.active() || !widget || widget.contentKind !== "mcp-app" || !callbacks) {
|
||||
this.renewalTimer = clearTimer(this.renewalTimer);
|
||||
return;
|
||||
}
|
||||
@@ -153,7 +161,7 @@ export class BoardMcpAppLifecycle {
|
||||
|
||||
retry(): void {
|
||||
const widget = this.host.widget();
|
||||
if (widget && this.callbacks) {
|
||||
if (this.host.active() && widget && this.callbacks) {
|
||||
void this.load(widget, this.callbacks, "refresh");
|
||||
}
|
||||
}
|
||||
@@ -168,7 +176,7 @@ export class BoardMcpAppLifecycle {
|
||||
this.state = { status: "stale", error: "MCP App view expired" };
|
||||
this.loading = false;
|
||||
this.notify();
|
||||
if (!wasLoading) {
|
||||
if (this.host.active() && !wasLoading) {
|
||||
void this.load(widget, callbacks, "expired");
|
||||
}
|
||||
}
|
||||
@@ -202,7 +210,7 @@ export class BoardMcpAppLifecycle {
|
||||
callbacks: AppViewCallbacks,
|
||||
mode: AppViewMode,
|
||||
): Promise<void> {
|
||||
if (this.loading || !this.nearVisible) {
|
||||
if (!this.host.active() || this.loading || !this.nearVisible) {
|
||||
return;
|
||||
}
|
||||
const key = appViewKey(this.host.sessionKey(), widget);
|
||||
@@ -314,6 +322,9 @@ export class BoardMcpAppLifecycle {
|
||||
if (appView.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
if (!this.host.active()) {
|
||||
return;
|
||||
}
|
||||
const key = this.key;
|
||||
const delayMs = appView.expiresAtMs - Date.now() - REFRESH_LEAD_MS;
|
||||
if (!this.nearVisible) {
|
||||
@@ -335,6 +346,7 @@ export class BoardMcpAppLifecycle {
|
||||
const current = this.host.widget();
|
||||
if (
|
||||
this.host.connected() &&
|
||||
this.host.active() &&
|
||||
this.nearVisible &&
|
||||
this.key === key &&
|
||||
current?.name === widget.name &&
|
||||
|
||||
@@ -939,6 +939,32 @@ describe("openclaw-board-view", () => {
|
||||
expect(applyOps).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels an in-progress gesture when the board becomes inactive", async () => {
|
||||
const applyOps = vi.fn(async () => undefined);
|
||||
const view = await mount({ callbacks: callbacks({ applyOps }) });
|
||||
const handle = view.querySelector<HTMLElement>(".board-widget__resize-handle");
|
||||
const pointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
cancelable: true,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
Object.defineProperty(pointerDown, "pointerId", { value: 7 });
|
||||
handle?.dispatchEvent(pointerDown);
|
||||
await view.updateComplete;
|
||||
expect(view.querySelector(".board-widget--dragging")).not.toBeNull();
|
||||
|
||||
view.active = false;
|
||||
await settleCells(view);
|
||||
expect(view.querySelector(".board-widget--dragging")).toBeNull();
|
||||
|
||||
const pointerUp = new MouseEvent("pointerup", { bubbles: true, clientX: 200, clientY: 200 });
|
||||
Object.defineProperty(pointerUp, "pointerId", { value: 7 });
|
||||
window.dispatchEvent(pointerUp);
|
||||
expect(applyOps).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves widgets to another tab from the kebab menu", async () => {
|
||||
const applyOps = vi.fn(async () => undefined);
|
||||
const view = await mount({ callbacks: callbacks({ applyOps }) });
|
||||
|
||||
@@ -78,9 +78,9 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
|
||||
@property({ attribute: false }) callbacks?: BoardViewCallbacks;
|
||||
@property({ attribute: false }) observer?: BoardObserverContext;
|
||||
@property({ type: Boolean }) active = true;
|
||||
@property({ type: Boolean }) canMutate = true;
|
||||
@property({ type: Boolean }) canGrant = true;
|
||||
@property({ type: Boolean }) ticketRefreshEnabled = true;
|
||||
|
||||
@state() private previewItems: BoardGridItem[] | null = null;
|
||||
@state() private gestureName = "";
|
||||
@@ -138,7 +138,12 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
if (changed.has("activeTabId")) {
|
||||
this.focusName = "";
|
||||
}
|
||||
if (this.gesture && (changed.has("snapshot") || changed.has("activeTabId"))) {
|
||||
if (
|
||||
this.gesture &&
|
||||
(changed.has("snapshot") ||
|
||||
changed.has("activeTabId") ||
|
||||
(changed.has("active") && !this.active))
|
||||
) {
|
||||
this.cancelGesture();
|
||||
}
|
||||
}
|
||||
@@ -298,7 +303,13 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
widget: BoardViewWidget,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
if (!this.canMutate || event.button !== 0 || this.gesture || this.mutationPending) {
|
||||
if (
|
||||
!this.active ||
|
||||
!this.canMutate ||
|
||||
event.button !== 0 ||
|
||||
this.gesture ||
|
||||
this.mutationPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const snapshot = this.snapshot;
|
||||
@@ -657,6 +668,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
.widgetFrameUrl=${this.widgetFrameUrl}
|
||||
.callbacks=${this.cellCallbacks}
|
||||
.observer=${this.observer}
|
||||
.active=${this.active}
|
||||
.dragging=${widget.name === this.gestureName}
|
||||
.focusTabIndex=${widget.name === focusName ? 0 : -1}
|
||||
.positionInSet=${(logicalPosition.get(widget.name) ?? 0) + 1}
|
||||
@@ -664,7 +676,6 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
.busy=${this.mutationPending}
|
||||
.canMutate=${this.canMutate}
|
||||
.canGrant=${this.canGrant}
|
||||
.ticketRefreshEnabled=${this.ticketRefreshEnabled}
|
||||
></openclaw-board-widget-cell>
|
||||
`;
|
||||
},
|
||||
|
||||
@@ -65,12 +65,14 @@ function callbacks(overrides: Partial<BoardWidgetCellCallbacks> = {}): BoardWidg
|
||||
async function mount(
|
||||
currentWidget: BoardViewWidget,
|
||||
currentCallbacks: BoardWidgetCellCallbacks,
|
||||
active = true,
|
||||
): Promise<BoardWidgetCell> {
|
||||
const cell = document.createElement("openclaw-board-widget-cell");
|
||||
cell.widget = currentWidget;
|
||||
cell.rect = { name: currentWidget.name, x: 0, y: 0, w: 6, h: currentWidget.sizeH };
|
||||
cell.sessionKey = "agent:main:test";
|
||||
cell.callbacks = currentCallbacks;
|
||||
cell.active = active;
|
||||
document.body.append(cell);
|
||||
await settle(cell);
|
||||
return cell;
|
||||
@@ -128,6 +130,25 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("board MCP App cell lifecycle", () => {
|
||||
it("waits to materialize an inactive cell until its board becomes active", async () => {
|
||||
const visibility = stubVisibility(() => true);
|
||||
const widgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "activated-view",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
}));
|
||||
const cell = await mount(widget(), callbacks({ widgetAppView }), false);
|
||||
|
||||
expect(visibility.observed()).toBe(0);
|
||||
expect(widgetAppView).not.toHaveBeenCalled();
|
||||
expect(cell.querySelector("mcp-app-view")).toBeNull();
|
||||
|
||||
cell.active = true;
|
||||
await settle(cell);
|
||||
await vi.waitFor(() => expect(widgetAppView).toHaveBeenCalledOnce());
|
||||
expect(cell.querySelector("mcp-app-view")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uses the board height as fixed AppBridge host context", async () => {
|
||||
const cell = await mount(
|
||||
widget({ grantState: "pending" }),
|
||||
@@ -157,6 +178,102 @@ describe("board MCP App cell lifecycle", () => {
|
||||
expect(cell.querySelector('[data-test-id="board-pending"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves a mounted app while its board is inactive", async () => {
|
||||
const widgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "retained-view",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
}));
|
||||
const cell = await mount(widget(), callbacks({ widgetAppView }));
|
||||
await vi.waitFor(() => expect(cell.querySelector("mcp-app-view")).not.toBeNull());
|
||||
const appView = cell.querySelector("mcp-app-view");
|
||||
|
||||
cell.active = false;
|
||||
await settle(cell);
|
||||
expect(cell.querySelector("mcp-app-view")).toBe(appView);
|
||||
|
||||
cell.active = true;
|
||||
await settle(cell);
|
||||
expect(cell.querySelector("mcp-app-view")).toBe(appView);
|
||||
expect(widgetAppView).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps active offscreen behavior while retaining inactive ready views", async () => {
|
||||
let visible = true;
|
||||
let emitVisibility: () => void = () => undefined;
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
class {
|
||||
constructor(private readonly callback: IntersectionObserverCallback) {}
|
||||
observe(target: Element) {
|
||||
vi.spyOn(target, "getBoundingClientRect").mockImplementation(
|
||||
() => ({ bottom: visible ? 200 : 5_200, top: visible ? 0 : 5_000 }) as DOMRect,
|
||||
);
|
||||
emitVisibility = () =>
|
||||
this.callback(
|
||||
[{ isIntersecting: visible, target } as IntersectionObserverEntry],
|
||||
this as never,
|
||||
);
|
||||
emitVisibility();
|
||||
}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
const widgetAppView = vi.fn(async () => ({
|
||||
status: "ready" as const,
|
||||
viewId: "viewport-view",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
}));
|
||||
const cell = await mount(widget(), callbacks({ widgetAppView }));
|
||||
await vi.waitFor(() => expect(cell.querySelector("mcp-app-view")).not.toBeNull());
|
||||
const initialView = cell.querySelector("mcp-app-view");
|
||||
|
||||
visible = false;
|
||||
emitVisibility();
|
||||
await settle(cell);
|
||||
expect(cell.querySelector("mcp-app-view")).toBeNull();
|
||||
|
||||
visible = true;
|
||||
emitVisibility();
|
||||
await settle(cell);
|
||||
const remountedView = cell.querySelector("mcp-app-view");
|
||||
expect(remountedView).not.toBeNull();
|
||||
expect(remountedView).not.toBe(initialView);
|
||||
expect(widgetAppView).toHaveBeenCalledOnce();
|
||||
|
||||
cell.active = false;
|
||||
await settle(cell);
|
||||
expect(cell.querySelector("mcp-app-view")).toBe(remountedView);
|
||||
});
|
||||
|
||||
it("lets an in-flight materialization finish while inactive without duplicating it", async () => {
|
||||
stubVisibility(() => true);
|
||||
const appView = deferred<BoardWidgetAppViewState>();
|
||||
const widgetAppView = vi.fn(() => appView.promise);
|
||||
const cell = await mount(widget(), callbacks({ widgetAppView }));
|
||||
await vi.waitFor(() => expect(widgetAppView).toHaveBeenCalledOnce());
|
||||
|
||||
cell.active = false;
|
||||
await settle(cell);
|
||||
appView.resolve({
|
||||
status: "ready",
|
||||
viewId: "finished-hidden",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
await settle(cell);
|
||||
expect((cell.querySelector("mcp-app-view") as TestMcpAppView | null)?.viewId).toBe(
|
||||
"finished-hidden",
|
||||
);
|
||||
|
||||
cell.active = true;
|
||||
await settle(cell);
|
||||
expect(widgetAppView).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("treats the bridge expiry event as authoritative", async () => {
|
||||
const refreshWidgetAppView = vi.fn(async () => ({
|
||||
status: "stale" as const,
|
||||
|
||||
@@ -117,6 +117,60 @@ describe("plugin board widget cells", () => {
|
||||
expect(cell.querySelector('[data-test-id="board-widget-error"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("passes activity to a retained Workboard plugin element", async () => {
|
||||
const context = {
|
||||
gateway: {
|
||||
snapshot: {
|
||||
phase: "stopped",
|
||||
hello: {
|
||||
controlUiWidgetKinds: [
|
||||
{ pluginId: "workboard", kind: "workboard:card", label: "Workboard card" },
|
||||
],
|
||||
},
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
subscribeEvents: () => () => undefined,
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
const widget: BoardViewWidget = {
|
||||
name: "work-item",
|
||||
tabId: "main",
|
||||
title: "Work item",
|
||||
contentKind: "plugin",
|
||||
pluginKind: "workboard:card",
|
||||
props: { cardId: "card-123" },
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
};
|
||||
const provider = createApplicationContextProvider(context);
|
||||
const cell = document.createElement("openclaw-board-widget-cell");
|
||||
cell.widget = widget;
|
||||
cell.rect = { name: widget.name, x: 0, y: 0, w: 6, h: 4 };
|
||||
cell.sessionKey = "agent:main:test";
|
||||
cell.callbacks = callbacks();
|
||||
provider.append(cell);
|
||||
document.body.append(provider);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(cell.querySelector("openclaw-workboard-card-widget")).not.toBeNull(),
|
||||
);
|
||||
const retained = cell.querySelector("openclaw-workboard-card-widget");
|
||||
expect(retained?.active).toBe(true);
|
||||
|
||||
cell.active = false;
|
||||
await cell.updateComplete;
|
||||
expect(cell.querySelector("openclaw-workboard-card-widget")).toBe(retained);
|
||||
expect(retained?.active).toBe(false);
|
||||
|
||||
cell.active = true;
|
||||
await cell.updateComplete;
|
||||
expect(cell.querySelector("openclaw-workboard-card-widget")).toBe(retained);
|
||||
expect(retained?.active).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "read-only board", canMutate: false, widgetReadOnly: false },
|
||||
{ name: "read-only widget", canMutate: true, widgetReadOnly: true },
|
||||
|
||||
@@ -74,6 +74,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
|
||||
@property({ attribute: false }) callbacks?: BoardWidgetCellCallbacks;
|
||||
@property({ attribute: false }) observer?: BoardObserverContext;
|
||||
@property({ type: Boolean }) active = true;
|
||||
@property({ type: Boolean }) dragging = false;
|
||||
@property({ type: Number }) focusTabIndex = -1;
|
||||
@property({ type: Number }) positionInSet = 1;
|
||||
@@ -81,7 +82,6 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
@property({ type: Boolean }) busy = false;
|
||||
@property({ type: Boolean }) canMutate = true;
|
||||
@property({ type: Boolean }) canGrant = true;
|
||||
@property({ type: Boolean }) ticketRefreshEnabled = true;
|
||||
|
||||
@state() private actionError = "";
|
||||
@state() private actionPending = false;
|
||||
@@ -91,12 +91,14 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
private pluginRendererKind = "";
|
||||
private pluginRendererLoadToken: object | null = null;
|
||||
private readonly appView = new BoardMcpAppLifecycle({
|
||||
active: () => this.active,
|
||||
connected: () => this.isConnected,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
sessionKey: () => this.sessionKey,
|
||||
widget: () => this.widget,
|
||||
});
|
||||
private readonly frame = new BoardWidgetFrameLifecycle({
|
||||
active: () => this.active,
|
||||
connected: () => this.isConnected,
|
||||
context: () => this.context,
|
||||
refreshFrame: () => this.callbacks?.frameLoadFailed,
|
||||
@@ -104,7 +106,6 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
resolveFrameUrl: () => this.widgetFrameUrl,
|
||||
root: () => this,
|
||||
ticketRefreshEnabled: () => this.ticketRefreshEnabled,
|
||||
widget: () => this.widget,
|
||||
});
|
||||
|
||||
@@ -121,6 +122,16 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
this.frame.widgetChanged(previousWidget, this.widget);
|
||||
}
|
||||
this.appView.update(this.widget, this.callbacks);
|
||||
if (changed.has("active")) {
|
||||
this.appView.activityChanged();
|
||||
this.frame.activityChanged();
|
||||
if (this.active) {
|
||||
this.appView.observe(
|
||||
this.querySelector(".board-widget"),
|
||||
this.widget?.contentKind === "mcp-app",
|
||||
);
|
||||
}
|
||||
}
|
||||
this.syncPluginRenderer();
|
||||
}
|
||||
|
||||
@@ -131,7 +142,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
}
|
||||
this.appView.observe(
|
||||
this.querySelector(".board-widget"),
|
||||
this.widget?.contentKind === "mcp-app",
|
||||
this.active && this.widget?.contentKind === "mcp-app",
|
||||
);
|
||||
queueMicrotask(() => {
|
||||
if (this.isConnected) {
|
||||
@@ -222,6 +233,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
accessNotice,
|
||||
appView: this.appView.state,
|
||||
busy: this.busy || this.actionPending || !this.canMutate,
|
||||
active: this.active,
|
||||
loading: this.appView.loading,
|
||||
nearVisible: this.appView.nearVisible,
|
||||
rectHeight: this.rect?.h ?? 4,
|
||||
@@ -272,6 +284,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
return this.pluginRenderer({
|
||||
widget,
|
||||
sessionKey: this.sessionKey,
|
||||
active: this.active,
|
||||
canMutate: this.canMutate && !widget.readOnly,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@ import { BoardWidgetFrameLifecycle } from "./board-widget-frame.ts";
|
||||
|
||||
type LifecycleInternals = {
|
||||
sandboxOrigin: string;
|
||||
sandboxHost: {
|
||||
dispose: () => void;
|
||||
handleMessage: (event: MessageEvent) => void;
|
||||
setActive: (active: boolean) => void;
|
||||
} | null;
|
||||
frameFailureKey: string;
|
||||
frameRefreshAttempts: number;
|
||||
refreshFailedFrame: (widget: BoardViewWidget) => void;
|
||||
@@ -17,6 +22,7 @@ function createTicketRefreshLifecycle(
|
||||
refreshFrame: (name: string) => Promise<void>,
|
||||
): BoardWidgetFrameLifecycle {
|
||||
const lifecycle = new BoardWidgetFrameLifecycle({
|
||||
active: () => true,
|
||||
connected: () => true,
|
||||
context: () => undefined,
|
||||
refreshFrame: () => refreshFrame,
|
||||
@@ -24,7 +30,6 @@ function createTicketRefreshLifecycle(
|
||||
requestUpdate: () => {},
|
||||
resolveFrameUrl: () => () => "",
|
||||
root: () => document,
|
||||
ticketRefreshEnabled: () => true,
|
||||
widget: () => widget,
|
||||
});
|
||||
lifecycle.connect();
|
||||
@@ -33,6 +38,7 @@ function createTicketRefreshLifecycle(
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -45,6 +51,7 @@ function terminalFailureError(params: {
|
||||
}): string {
|
||||
const widget = { name: "clock", revision: 1, ...params.widget } as BoardViewWidget;
|
||||
const lifecycle = new BoardWidgetFrameLifecycle({
|
||||
active: () => true,
|
||||
connected: () => true,
|
||||
context: () => undefined,
|
||||
refreshFrame: () => undefined,
|
||||
@@ -52,7 +59,6 @@ function terminalFailureError(params: {
|
||||
requestUpdate: () => {},
|
||||
resolveFrameUrl: () => () => "",
|
||||
root: () => document,
|
||||
ticketRefreshEnabled: () => true,
|
||||
widget: () => widget,
|
||||
});
|
||||
const internals = lifecycle as unknown as LifecycleInternals;
|
||||
@@ -101,6 +107,76 @@ describe("board widget frame terminal failure message", () => {
|
||||
});
|
||||
|
||||
describe("board widget frame ticket refresh", () => {
|
||||
it("suspends frame work while inactive and reconnects on activation", async () => {
|
||||
vi.useFakeTimers();
|
||||
let active = true;
|
||||
const refreshFrame = vi.fn(async () => undefined);
|
||||
const widget = {
|
||||
name: "clock",
|
||||
revision: 1,
|
||||
viewTicket: "ticket",
|
||||
viewTicketTtlMs: 30_000,
|
||||
} as BoardViewWidget;
|
||||
recordBoardWidgetTicketReceipt(widget);
|
||||
const lifecycle = new BoardWidgetFrameLifecycle({
|
||||
active: () => active,
|
||||
connected: () => true,
|
||||
context: () => undefined,
|
||||
refreshFrame: () => refreshFrame,
|
||||
reportContentHeight: () => {},
|
||||
requestUpdate: () => {},
|
||||
resolveFrameUrl: () => () => "",
|
||||
root: () => document,
|
||||
widget: () => widget,
|
||||
});
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "board-widget__frame";
|
||||
document.body.append(frame);
|
||||
const removeWindowListener = vi.spyOn(window, "removeEventListener");
|
||||
const removeDocumentListener = vi.spyOn(document, "removeEventListener");
|
||||
const dispose = vi.fn();
|
||||
const handleMessage = vi.fn();
|
||||
const setActive = vi.fn();
|
||||
const internals = lifecycle as unknown as LifecycleInternals;
|
||||
|
||||
lifecycle.connect();
|
||||
lifecycle.update();
|
||||
internals.sandboxOrigin = "https://sandbox.example";
|
||||
internals.sandboxHost = { dispose, handleMessage, setActive };
|
||||
active = false;
|
||||
lifecycle.activityChanged();
|
||||
lifecycle.update();
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(setActive).toHaveBeenCalledWith(false);
|
||||
expect(removeWindowListener).not.toHaveBeenCalledWith("message", expect.any(Function));
|
||||
expect(removeDocumentListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: { method: "ui/notifications/sandbox-proxy-ready" },
|
||||
}),
|
||||
);
|
||||
expect(handleMessage).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(refreshFrame).not.toHaveBeenCalled();
|
||||
|
||||
active = true;
|
||||
lifecycle.activityChanged();
|
||||
expect(setActive).toHaveBeenLastCalledWith(true);
|
||||
internals.sandboxHost = null;
|
||||
lifecycle.update();
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(refreshFrame).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(refreshFrame).toHaveBeenCalledOnce();
|
||||
internals.sandboxHost = { dispose, handleMessage, setActive };
|
||||
lifecycle.disconnect();
|
||||
expect(removeWindowListener).toHaveBeenCalledWith("message", expect.any(Function));
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("pauses while hidden and re-arms when the document becomes visible", async () => {
|
||||
vi.useFakeTimers();
|
||||
let visibilityState: DocumentVisibilityState = "hidden";
|
||||
|
||||
@@ -48,6 +48,7 @@ function resolveBoardFrameFailureMessage(
|
||||
type FrameRefresh = (name: string) => Promise<void>;
|
||||
|
||||
type BoardWidgetFrameLifecycleHost = {
|
||||
active: () => boolean;
|
||||
connected: () => boolean;
|
||||
context: () => ApplicationContext | undefined;
|
||||
refreshFrame: () => FrameRefresh | undefined;
|
||||
@@ -55,7 +56,6 @@ type BoardWidgetFrameLifecycleHost = {
|
||||
reportContentHeight: (name: string, height: number) => void;
|
||||
resolveFrameUrl: () => BoardWidgetFrameUrl | undefined;
|
||||
root: () => ParentNode;
|
||||
ticketRefreshEnabled: () => boolean;
|
||||
widget: () => BoardViewWidget | undefined;
|
||||
};
|
||||
|
||||
@@ -137,36 +137,62 @@ export class BoardWidgetFrameLifecycle {
|
||||
private frameRefreshAttempts = 0;
|
||||
private frameProbeGeneration = 0;
|
||||
private lastFrameUrl = "";
|
||||
private listening = false;
|
||||
private messageListening = false;
|
||||
private visibilityListening = false;
|
||||
private sandboxOrigin = "";
|
||||
private sandboxHost: BoardWidgetSandboxHost | null = null;
|
||||
private readonly ticketRefresh = new BoardWidgetTicketRefresh(
|
||||
() => this.host.widget()?.viewTicket,
|
||||
() => this.host.ticketRefreshEnabled() && !documentHidden(),
|
||||
() => this.host.active() && !documentHidden(),
|
||||
);
|
||||
|
||||
constructor(private readonly host: BoardWidgetFrameLifecycleHost) {}
|
||||
|
||||
connect(): void {
|
||||
if (this.listening) {
|
||||
return;
|
||||
if (!this.messageListening) {
|
||||
window.addEventListener("message", this.handleWindowMessage);
|
||||
this.messageListening = true;
|
||||
}
|
||||
if (this.host.active() && !this.visibilityListening) {
|
||||
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
this.visibilityListening = true;
|
||||
}
|
||||
window.addEventListener("message", this.handleWindowMessage);
|
||||
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
this.listening = true;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.listening) {
|
||||
this.stopWork();
|
||||
if (this.messageListening) {
|
||||
window.removeEventListener("message", this.handleWindowMessage);
|
||||
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
this.listening = false;
|
||||
this.messageListening = false;
|
||||
}
|
||||
this.ticketRefresh.reset();
|
||||
this.sandboxHost?.dispose();
|
||||
this.sandboxHost = null;
|
||||
}
|
||||
|
||||
private suspend(): void {
|
||||
this.stopWork();
|
||||
this.sandboxHost?.setActive(false);
|
||||
}
|
||||
|
||||
private stopWork(): void {
|
||||
if (this.visibilityListening) {
|
||||
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
this.visibilityListening = false;
|
||||
}
|
||||
this.ticketRefresh.reset();
|
||||
}
|
||||
|
||||
activityChanged(): void {
|
||||
if (this.host.active()) {
|
||||
this.connect();
|
||||
this.sandboxHost?.setActive(true);
|
||||
} else {
|
||||
// Hidden dashboard cells retain their iframe and sandbox handshake;
|
||||
// terminal disconnect is the only lifecycle edge that disposes them.
|
||||
this.suspend();
|
||||
}
|
||||
}
|
||||
|
||||
widgetChanged(previous: BoardViewWidget, current: BoardViewWidget | undefined): void {
|
||||
if (previous.name !== current?.name || previous.revision !== current?.revision) {
|
||||
this.resetFailures(false);
|
||||
@@ -184,8 +210,18 @@ export class BoardWidgetFrameLifecycle {
|
||||
}
|
||||
|
||||
update(): void {
|
||||
if (!this.host.active()) {
|
||||
this.suspend();
|
||||
return;
|
||||
}
|
||||
this.resume();
|
||||
}
|
||||
|
||||
private resume(): void {
|
||||
this.connect();
|
||||
this.ticketRefresh.schedule(this.host.widget(), this.host.refreshFrame());
|
||||
this.updateSandboxHost();
|
||||
this.sandboxHost?.setActive(true);
|
||||
}
|
||||
|
||||
render(widget: BoardViewWidget): TemplateResult {
|
||||
@@ -253,6 +289,9 @@ export class BoardWidgetFrameLifecycle {
|
||||
}
|
||||
|
||||
private refreshFailedFrame(widget: BoardViewWidget): void {
|
||||
if (!this.host.active()) {
|
||||
return;
|
||||
}
|
||||
this.frameProbeGeneration += 1;
|
||||
const failureKey = `${widget.name}:${widget.revision}`;
|
||||
if (this.frameFailureKey !== failureKey) {
|
||||
@@ -290,6 +329,7 @@ export class BoardWidgetFrameLifecycle {
|
||||
frame.isConnected &&
|
||||
frame.getAttribute("src") === src &&
|
||||
this.frameProbeGeneration === probeGeneration &&
|
||||
this.host.active() &&
|
||||
this.host.widget()?.name === widget.name &&
|
||||
this.host.widget()?.revision === widget.revision;
|
||||
// View tickets are reusable HMAC bindings until expiry. Iframe load events
|
||||
@@ -410,6 +450,12 @@ export class BoardWidgetFrameLifecycle {
|
||||
}
|
||||
const frame = this.host.root().querySelector<HTMLIFrameElement>(".board-widget__frame");
|
||||
const widget = this.host.widget();
|
||||
if (!this.host.active()) {
|
||||
if (frame && event.source === frame.contentWindow && event.origin === this.sandboxOrigin) {
|
||||
this.sandboxHost?.handleMessage(event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = event.data as { type?: unknown; height?: unknown } | null;
|
||||
if (
|
||||
frame &&
|
||||
|
||||
@@ -36,19 +36,25 @@ class TestMcpAppUnmountTarget extends HTMLElement {
|
||||
|
||||
class TestMcpAppUnmountOwner extends LitElement {
|
||||
key = "initial";
|
||||
valueKey = "initial";
|
||||
retainRenderedValue = false;
|
||||
private readonly gate = new McpAppUnmountGate(this, targetTag);
|
||||
|
||||
show(key: string) {
|
||||
show(key: string, valueKey = key, retainRenderedValue = false) {
|
||||
this.key = key;
|
||||
this.valueKey = valueKey;
|
||||
this.retainRenderedValue = retainRenderedValue;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const value =
|
||||
this.key === "initial"
|
||||
this.valueKey === "initial"
|
||||
? staticHtml`<${staticTargetTag}></${staticTargetTag}><span data-value="initial">initial</span>`
|
||||
: html`<span data-value=${this.key}>${this.key}</span>`;
|
||||
return this.gate.render(this.key, value, () => [this.renderRoot]);
|
||||
: html`<span data-value=${this.valueKey}>${this.valueKey}</span>`;
|
||||
return this.gate.render(this.key, value, () => [this.renderRoot], {
|
||||
retainRenderedValue: this.retainRenderedValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +92,24 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("McpAppUnmountGate", () => {
|
||||
it("retains the current value for an unchanged explicit owner", async () => {
|
||||
const owner = document.createElement(ownerTag) as TestMcpAppUnmountOwner;
|
||||
document.body.append(owner);
|
||||
await owner.updateComplete;
|
||||
const target = owner.shadowRoot!.querySelector(targetTag);
|
||||
|
||||
owner.show("initial", "pending", true);
|
||||
await owner.updateComplete;
|
||||
expect(owner.shadowRoot!.querySelector(targetTag)).toBe(target);
|
||||
expect(owner.shadowRoot!.querySelector("[data-value='pending']")).toBeNull();
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
|
||||
owner.show("initial", "resolved");
|
||||
await owner.updateComplete;
|
||||
expect(owner.shadowRoot!.querySelector(targetTag)).toBeNull();
|
||||
expect(owner.shadowRoot!.querySelector("[data-value='resolved']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the old subtree connected and coalesces replacements until teardown resolves", async () => {
|
||||
const pending = deferred();
|
||||
teardown.mockReturnValue(pending.promise);
|
||||
|
||||
@@ -51,7 +51,12 @@ export class McpAppUnmountGate {
|
||||
return this.renderedValue;
|
||||
}
|
||||
|
||||
render(key: string, value: unknown, leavingRoots: () => Iterable<ParentNode>): unknown {
|
||||
render(
|
||||
key: string,
|
||||
value: unknown,
|
||||
leavingRoots: () => Iterable<ParentNode>,
|
||||
options: { retainRenderedValue?: boolean } = {},
|
||||
): unknown {
|
||||
if (this.pending) {
|
||||
return this.renderedValue;
|
||||
}
|
||||
@@ -67,7 +72,12 @@ export class McpAppUnmountGate {
|
||||
}
|
||||
}
|
||||
});
|
||||
return this.apply(key, value);
|
||||
return this.renderedKey === key && options.retainRenderedValue
|
||||
? this.renderedValue
|
||||
: this.apply(key, value);
|
||||
}
|
||||
if (this.renderedKey === key && options.retainRenderedValue) {
|
||||
return this.renderedValue;
|
||||
}
|
||||
if (this.renderedKey === null || this.renderedKey === key) {
|
||||
return this.apply(key, value);
|
||||
|
||||
@@ -40,11 +40,15 @@ function widget(index: number) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function boardSnapshot(count: number) {
|
||||
function boardSnapshot(
|
||||
count: number,
|
||||
chatDock: "left" | "right" | "bottom" | "hidden" = "right",
|
||||
revision = 1,
|
||||
) {
|
||||
return {
|
||||
sessionKey,
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
|
||||
revision,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock }],
|
||||
widgets: Array.from({ length: count }, (_, index) => widget(index)),
|
||||
};
|
||||
}
|
||||
@@ -86,6 +90,86 @@ async function waitForMountedApp(page: Page): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function captureBoardIdentity(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const surface = document.querySelector<HTMLElement>(".board-session-surface");
|
||||
const board = surface?.querySelector("openclaw-board-view");
|
||||
const cell = board?.querySelector("openclaw-board-widget-cell");
|
||||
const appView = cell?.querySelector("mcp-app-view");
|
||||
const iframe = appView?.shadowRoot?.querySelector("iframe");
|
||||
if (!surface || !board || !cell || !appView || !iframe) {
|
||||
throw new Error("Board MCP App identity is incomplete");
|
||||
}
|
||||
Reflect.set(window, "__openclawBoardIdentity", { surface, board, cell, appView, iframe });
|
||||
});
|
||||
}
|
||||
|
||||
async function readBoardIdentity(page: Page) {
|
||||
return await page.evaluate(() => {
|
||||
const stored = Reflect.get(window, "__openclawBoardIdentity") as {
|
||||
surface: HTMLElement;
|
||||
board: Element;
|
||||
cell: Element;
|
||||
appView: Element;
|
||||
iframe: Element;
|
||||
};
|
||||
const surface = document.querySelector<HTMLElement>(".board-session-surface");
|
||||
const board = surface?.querySelector("openclaw-board-view");
|
||||
const cell = board?.querySelector("openclaw-board-widget-cell");
|
||||
const appView = cell?.querySelector("mcp-app-view");
|
||||
const iframe = appView?.shadowRoot?.querySelector("iframe");
|
||||
return {
|
||||
connected: [stored.surface, stored.board, stored.cell, stored.appView, stored.iframe].every(
|
||||
(element) => element.isConnected,
|
||||
),
|
||||
hidden: surface?.hidden ?? null,
|
||||
inert: surface?.inert ?? null,
|
||||
same:
|
||||
surface === stored.surface &&
|
||||
board === stored.board &&
|
||||
cell === stored.cell &&
|
||||
appView === stored.appView &&
|
||||
iframe === stored.iframe,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function expectRetainedBoardMode(
|
||||
page: Page,
|
||||
mode: "chat" | "split" | "dashboard",
|
||||
): Promise<void> {
|
||||
const hidden = mode === "chat";
|
||||
await expect
|
||||
.poll(() => readBoardIdentity(page))
|
||||
.toEqual({ connected: true, hidden, inert: hidden, same: true });
|
||||
await expect.poll(() => page.locator(".board-session-surface").isVisible()).toBe(!hidden);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("wa-radio.settings-segmented__btn--active")
|
||||
.evaluateAll((radios) => radios.map((radio) => radio.getAttribute("value"))),
|
||||
)
|
||||
.toEqual([mode]);
|
||||
}
|
||||
|
||||
async function waitForCachedBoardFace(page: Page, face: "chat" | "dashboard"): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ key, expectedFace }) => {
|
||||
const chatPage = document.querySelector("openclaw-chat-page");
|
||||
const context = chatPage ? Reflect.get(chatPage, "context") : undefined;
|
||||
const sessions = context?.sessions?.state?.result?.sessions;
|
||||
return (
|
||||
Array.isArray(sessions) &&
|
||||
sessions.some(
|
||||
(session: { key?: unknown; boardFace?: unknown }) =>
|
||||
session.key === key && session.boardFace === expectedFace,
|
||||
)
|
||||
);
|
||||
},
|
||||
{ key: sessionKey, expectedFace: face },
|
||||
);
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI dashboard MCP Apps", () => {
|
||||
beforeAll(async () => {
|
||||
controlUi = await startControlUiE2eServer();
|
||||
@@ -157,6 +241,101 @@ describeControlUiE2e("Control UI dashboard MCP Apps", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains one board runtime across Chat, Split, and Dashboard", async () => {
|
||||
const context = await browser.newContext({
|
||||
permissions: ["local-network-access"],
|
||||
viewport: { width: 1280, height: 800 },
|
||||
});
|
||||
contexts.add(context);
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
sessionKey,
|
||||
featureMethods: [
|
||||
"board.get",
|
||||
"board.update",
|
||||
"board.widget.appView",
|
||||
"chat.history",
|
||||
"chat.metadata",
|
||||
"chat.startup",
|
||||
"mcp.app.view",
|
||||
"sessions.patch",
|
||||
],
|
||||
methodResponses: {
|
||||
"board.get": boardSnapshot(1, "hidden"),
|
||||
"board.update": {
|
||||
sequence: [
|
||||
boardSnapshot(1, "right", 2),
|
||||
boardSnapshot(1, "hidden", 3),
|
||||
boardSnapshot(1, "right", 4),
|
||||
],
|
||||
},
|
||||
"board.widget.appView": {
|
||||
viewId: "retained-view",
|
||||
expiresAtMs: Date.now() + 3_600_000,
|
||||
},
|
||||
"mcp.app.view": appViewPayload(),
|
||||
},
|
||||
});
|
||||
|
||||
await openDashboard(page);
|
||||
await waitForMountedApp(page);
|
||||
await captureBoardIdentity(page);
|
||||
const stableCounts = {
|
||||
boardGet: (await gateway.getRequests("board.get")).length,
|
||||
appView: (await gateway.getRequests("board.widget.appView")).length,
|
||||
mcpView: (await gateway.getRequests("mcp.app.view")).length,
|
||||
};
|
||||
const initialPatchCount = (await gateway.getRequests("sessions.patch")).length;
|
||||
const mode = (value: "chat" | "split" | "dashboard") =>
|
||||
page.locator(`wa-radio.settings-segmented__btn[value="${value}"]`);
|
||||
|
||||
await mode("chat").click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.patch")).length)
|
||||
.toBe(initialPatchCount + 1);
|
||||
expect((await gateway.getRequests("sessions.patch")).at(-1)?.params).toMatchObject({
|
||||
agentId: "main",
|
||||
boardFace: "chat",
|
||||
key: sessionKey,
|
||||
});
|
||||
await expectRetainedBoardMode(page, "chat");
|
||||
|
||||
await mode("split").click();
|
||||
await expect.poll(async () => (await gateway.getRequests("board.update")).length).toBe(1);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.patch")).length)
|
||||
.toBe(initialPatchCount + 2);
|
||||
expect((await gateway.getRequests("sessions.patch")).at(-1)?.params).toMatchObject({
|
||||
agentId: "main",
|
||||
boardFace: "dashboard",
|
||||
key: sessionKey,
|
||||
});
|
||||
await expectRetainedBoardMode(page, "split");
|
||||
await waitForCachedBoardFace(page, "dashboard");
|
||||
|
||||
const facePatchCount = (await gateway.getRequests("sessions.patch")).length;
|
||||
const faceListCount = (await gateway.getRequests("sessions.list")).length;
|
||||
await mode("dashboard").click();
|
||||
await expect.poll(async () => (await gateway.getRequests("board.update")).length).toBe(2);
|
||||
expect(await gateway.getRequests("sessions.patch")).toHaveLength(facePatchCount);
|
||||
expect(await gateway.getRequests("sessions.list")).toHaveLength(faceListCount);
|
||||
await expectRetainedBoardMode(page, "dashboard");
|
||||
|
||||
await mode("split").click();
|
||||
await expect.poll(async () => (await gateway.getRequests("board.update")).length).toBe(3);
|
||||
expect(await gateway.getRequests("sessions.patch")).toHaveLength(facePatchCount);
|
||||
expect(await gateway.getRequests("sessions.list")).toHaveLength(faceListCount);
|
||||
await expectRetainedBoardMode(page, "split");
|
||||
expect((await gateway.getRequests("board.update")).map((request) => request.params)).toEqual([
|
||||
{ sessionKey, ops: [{ kind: "tab_update", tabId: "main", chatDock: "right" }] },
|
||||
{ sessionKey, ops: [{ kind: "tab_update", tabId: "main", chatDock: "hidden" }] },
|
||||
{ sessionKey, ops: [{ kind: "tab_update", tabId: "main", chatDock: "right" }] },
|
||||
]);
|
||||
expect(await gateway.getRequests("board.get")).toHaveLength(stableCounts.boardGet);
|
||||
expect(await gateway.getRequests("board.widget.appView")).toHaveLength(stableCounts.appView);
|
||||
expect(await gateway.getRequests("mcp.app.view")).toHaveLength(stableCounts.mcpView);
|
||||
});
|
||||
|
||||
it("does not eagerly mint leases for all 48 offscreen cells", async () => {
|
||||
const context = await browser.newContext({
|
||||
permissions: ["local-network-access"],
|
||||
|
||||
@@ -472,6 +472,7 @@ suite.define(() => {
|
||||
"board.get",
|
||||
"chat.metadata",
|
||||
"chat.startup",
|
||||
"sessions.patch",
|
||||
"workboard.cards.list",
|
||||
"workboard.cards.move",
|
||||
],
|
||||
@@ -512,6 +513,55 @@ suite.define(() => {
|
||||
});
|
||||
}
|
||||
|
||||
const cardElement = page.locator("openclaw-workboard-card-widget");
|
||||
await cardElement.evaluate((element) => {
|
||||
Reflect.set(globalThis, "workboardPluginElementIdentity", element);
|
||||
});
|
||||
const listCountBeforeHide = (await gateway.getRequests("workboard.cards.list")).length;
|
||||
const mode = (value: "chat" | "split") =>
|
||||
page.locator(`wa-radio.settings-segmented__btn[value="${value}"]`);
|
||||
|
||||
await mode("chat").click();
|
||||
await expect
|
||||
.poll(() => page.locator(".board-session-surface").getAttribute("hidden"))
|
||||
.not.toBeNull();
|
||||
await expect
|
||||
.poll(() =>
|
||||
cardElement.evaluate(
|
||||
(element) =>
|
||||
element === Reflect.get(globalThis, "workboardPluginElementIdentity") &&
|
||||
Reflect.get(element, "active") === false &&
|
||||
element.isConnected,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await gateway.emitGatewayEvent("plugin.workboard.changed", {
|
||||
epoch: "plugin-widget-e2e-hidden",
|
||||
revision: 2,
|
||||
});
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
expect(await gateway.getRequests("workboard.cards.list")).toHaveLength(listCountBeforeHide);
|
||||
|
||||
await mode("split").click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("workboard.cards.list")).length)
|
||||
.toBe(listCountBeforeHide + 1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
cardElement.evaluate(
|
||||
(element) =>
|
||||
element === Reflect.get(globalThis, "workboardPluginElementIdentity") &&
|
||||
Reflect.get(element, "active") === true &&
|
||||
element.isConnected,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await cardWidget.getByRole("combobox").selectOption("running");
|
||||
const moveRequest = await gateway.waitForRequest("workboard.cards.move");
|
||||
expect(moveRequest.params).toEqual({
|
||||
|
||||
@@ -826,6 +826,178 @@ describe("BoardWidgetSandboxHost", () => {
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("records one-shot proxy readiness while inactive", async () => {
|
||||
vi.useFakeTimers();
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const fetchMock = vi.fn(async () => new Response("<!doctype html><p>retained</p>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onReadyTimeout = vi.fn();
|
||||
const onLoaded = vi.fn();
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
resolveFrameUrl: () => "/widget",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout,
|
||||
onLoaded,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
const reloadFrame = vi.spyOn(frame, "src", "set");
|
||||
|
||||
host.setActive(false);
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(onReadyTimeout).not.toHaveBeenCalled();
|
||||
|
||||
host.setActive(true);
|
||||
await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledOnce());
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(reloadFrame).not.toHaveBeenCalled();
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it("retains a ready loaded frame and bridge while inactive", async () => {
|
||||
vi.useFakeTimers();
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const fetchMock = vi.fn(async () => new Response("<!doctype html><p>retained</p>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = { request: vi.fn(async () => ({ resumed: true })) };
|
||||
const onReadyTimeout = vi.fn();
|
||||
const onLoaded = vi.fn();
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client,
|
||||
resolveFrameUrl: () => "/widget",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout,
|
||||
onLoaded,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledOnce());
|
||||
const bridgePort = await offerBridgePort(host, frame);
|
||||
const retainedFrame = host.frame;
|
||||
const reloadFrame = vi.spyOn(frame, "src", "set");
|
||||
|
||||
host.setActive(false);
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
await expect(
|
||||
sendBridgeRequest(bridgePort, {
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "inactive",
|
||||
method: "data.read",
|
||||
params: { bindingId: "health" },
|
||||
ticket: "ticket",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, error: "Widget inactive" });
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
|
||||
host.setActive(true);
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(host.frame).toBe(retainedFrame);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(onReadyTimeout).not.toHaveBeenCalled();
|
||||
expect(reloadFrame).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
sendBridgeRequest(bridgePort, {
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "resumed",
|
||||
method: "data.read",
|
||||
params: { bindingId: "health" },
|
||||
ticket: "ticket",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, result: { resumed: true } });
|
||||
bridgePort.close();
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it("resumes one interrupted document load after reactivation", async () => {
|
||||
let resolveFirstFetch: (response: Response) => void = () => {};
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const fetchMock = vi
|
||||
.fn<() => Promise<Response>>()
|
||||
.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
resolveFirstFetch = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("<!doctype html><p>resumed</p>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onLoaded = vi.fn();
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
resolveFrameUrl: () => "/widget",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
||||
|
||||
host.setActive(false);
|
||||
resolveFirstFetch(new Response("<!doctype html><p>stale</p>"));
|
||||
await Promise.resolve();
|
||||
expect(onLoaded).not.toHaveBeenCalled();
|
||||
|
||||
host.setActive(true);
|
||||
await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledOnce());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
host.dispose();
|
||||
});
|
||||
|
||||
it("bounds missing proxy readiness and stops the timer on disposal", async () => {
|
||||
vi.useFakeTimers();
|
||||
const frame = document.createElement("iframe");
|
||||
@@ -847,6 +1019,10 @@ describe("BoardWidgetSandboxHost", () => {
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
host.setActive(false);
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(onReadyTimeout).not.toHaveBeenCalled();
|
||||
host.setActive(true);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(onReadyTimeout).toHaveBeenCalledOnce();
|
||||
expect(frame.src).toBe(SANDBOX_URL);
|
||||
|
||||
@@ -27,6 +27,7 @@ type BoardWidgetSandboxHostOptions = {
|
||||
/** Owns one trusted outer sandbox frame and its ticket-bound inner widget bridge. */
|
||||
export class BoardWidgetSandboxHost {
|
||||
private options: BoardWidgetSandboxHostOptions;
|
||||
private active = true;
|
||||
private bridgeController: BoardWidgetBridgeController | null = null;
|
||||
private bridgeClient: BoardWidgetBridgeGatewayClient | undefined;
|
||||
private bridgePort: MessagePort | null = null;
|
||||
@@ -35,6 +36,7 @@ export class BoardWidgetSandboxHost {
|
||||
private ready = false;
|
||||
private readyTimer: number | null = null;
|
||||
private loadedDocumentKey = "";
|
||||
private loadingDocumentKey = "";
|
||||
private loadGeneration = 0;
|
||||
private requestGeneration = 0;
|
||||
private readonly pendingRequests = new Map<string, number>();
|
||||
@@ -48,6 +50,28 @@ export class BoardWidgetSandboxHost {
|
||||
return this.options.frame;
|
||||
}
|
||||
|
||||
setActive(active: boolean): void {
|
||||
if (active === this.active) {
|
||||
return;
|
||||
}
|
||||
this.active = active;
|
||||
if (!active) {
|
||||
this.clearReadyTimeout();
|
||||
this.loadGeneration += 1;
|
||||
this.loadingDocumentKey = "";
|
||||
this.cancelPendingRequests("Widget inactive");
|
||||
this.requestGeneration += 1;
|
||||
return;
|
||||
}
|
||||
if (!this.ready) {
|
||||
this.scheduleReadyTimeout();
|
||||
} else if (this.documentKey() !== this.loadedDocumentKey) {
|
||||
void this.loadDocument();
|
||||
} else {
|
||||
this.postHostInit();
|
||||
}
|
||||
}
|
||||
|
||||
update(options: BoardWidgetSandboxHostOptions): void {
|
||||
const previousClient = this.options.client;
|
||||
const previousDocumentKey = this.documentKey();
|
||||
@@ -83,7 +107,7 @@ export class BoardWidgetSandboxHost {
|
||||
}
|
||||
this.postHostInit();
|
||||
}
|
||||
if (this.ready && this.documentKey() !== this.loadedDocumentKey) {
|
||||
if (this.active && this.ready && this.documentKey() !== this.loadedDocumentKey) {
|
||||
void this.loadDocument();
|
||||
}
|
||||
}
|
||||
@@ -93,6 +117,7 @@ export class BoardWidgetSandboxHost {
|
||||
this.requestGeneration += 1;
|
||||
this.pendingRequests.clear();
|
||||
this.loadedDocumentKey = "";
|
||||
this.loadingDocumentKey = "";
|
||||
this.bridgePort?.close();
|
||||
this.bridgePort = null;
|
||||
this.adoptedTicket = "";
|
||||
@@ -100,6 +125,7 @@ export class BoardWidgetSandboxHost {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.active = false;
|
||||
this.clearReadyTimeout();
|
||||
this.reset();
|
||||
this.ready = false;
|
||||
@@ -115,7 +141,7 @@ export class BoardWidgetSandboxHost {
|
||||
}
|
||||
|
||||
handleFrameError(): void {
|
||||
if (this.ready || !this.options.frame.isConnected) {
|
||||
if (!this.active || this.ready || !this.options.frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.clearReadyTimeout();
|
||||
@@ -132,7 +158,9 @@ export class BoardWidgetSandboxHost {
|
||||
) {
|
||||
this.ready = true;
|
||||
this.clearReadyTimeout();
|
||||
void this.loadDocument();
|
||||
if (this.active) {
|
||||
void this.loadDocument();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.ready) {
|
||||
@@ -179,6 +207,12 @@ export class BoardWidgetSandboxHost {
|
||||
this.postHostInit();
|
||||
return;
|
||||
}
|
||||
if (!this.active) {
|
||||
if (isBoardWidgetBridgeRequest(data)) {
|
||||
this.postResponse(data.id, false, undefined, "Widget inactive");
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.handleBridgeRequest(data);
|
||||
}
|
||||
|
||||
@@ -262,12 +296,12 @@ export class BoardWidgetSandboxHost {
|
||||
}
|
||||
|
||||
private scheduleReadyTimeout(): void {
|
||||
if (this.ready || this.readyTimer !== null) {
|
||||
if (!this.active || this.ready || this.readyTimer !== null) {
|
||||
return;
|
||||
}
|
||||
this.readyTimer = window.setTimeout(() => {
|
||||
this.readyTimer = null;
|
||||
if (this.ready || !this.options.frame.isConnected) {
|
||||
if (!this.active || this.ready || !this.options.frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
// Browsers do not expose iframe HTTP failures through `error`. Bound the
|
||||
@@ -278,7 +312,7 @@ export class BoardWidgetSandboxHost {
|
||||
|
||||
private retrySandboxFrame(): void {
|
||||
const { frame, sandboxUrl } = this.options;
|
||||
if (!frame.isConnected) {
|
||||
if (!this.active || !frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.ready = false;
|
||||
@@ -306,6 +340,7 @@ export class BoardWidgetSandboxHost {
|
||||
const ticket = this.options.widget.viewTicket;
|
||||
if (
|
||||
!this.ready ||
|
||||
!this.active ||
|
||||
!this.bridgePort ||
|
||||
!ticket ||
|
||||
this.loadedDocumentKey !== this.documentKey() ||
|
||||
@@ -319,6 +354,9 @@ export class BoardWidgetSandboxHost {
|
||||
}
|
||||
|
||||
private async loadDocument(): Promise<void> {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
const { frame, widget, resolveFrameUrl } = this.options;
|
||||
if (!frame.contentWindow) {
|
||||
return;
|
||||
@@ -335,12 +373,17 @@ export class BoardWidgetSandboxHost {
|
||||
this.options.onError(new Error("widget content URL is outside the active Gateway"));
|
||||
return;
|
||||
}
|
||||
const documentKey = this.documentKey();
|
||||
if (documentKey === this.loadedDocumentKey || documentKey === this.loadingDocumentKey) {
|
||||
return;
|
||||
}
|
||||
this.loadingDocumentKey = documentKey;
|
||||
const sourceHref = sourceUrl.href;
|
||||
this.options.onFrameUrl(sourceHref);
|
||||
const generation = ++this.loadGeneration;
|
||||
try {
|
||||
const response = await fetch(sourceHref, { cache: "no-store" });
|
||||
if (generation !== this.loadGeneration || !frame.isConnected) {
|
||||
if (!this.active || generation !== this.loadGeneration || !frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
@@ -351,7 +394,7 @@ export class BoardWidgetSandboxHost {
|
||||
throw new Error(`widget content request failed (${response.status})`);
|
||||
}
|
||||
const documentHtml = await response.text();
|
||||
if (generation !== this.loadGeneration || !frame.isConnected) {
|
||||
if (!this.active || generation !== this.loadGeneration || !frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
frame.contentWindow?.postMessage(
|
||||
@@ -362,7 +405,7 @@ export class BoardWidgetSandboxHost {
|
||||
},
|
||||
this.options.sandboxOrigin,
|
||||
);
|
||||
this.loadedDocumentKey = this.documentKey();
|
||||
this.loadedDocumentKey = documentKey;
|
||||
this.options.onLoaded();
|
||||
// The wrapper may offer its private port while the source fetch is still
|
||||
// pending. Complete the handshake once these exact bytes become current.
|
||||
@@ -371,6 +414,10 @@ export class BoardWidgetSandboxHost {
|
||||
if (generation === this.loadGeneration) {
|
||||
this.options.onLoadFailed(widget);
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.loadGeneration) {
|
||||
this.loadingDocumentKey = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ type BuiltinBoardWidgetRenderer = (context: {
|
||||
export type PluginBoardWidgetRenderer = (props: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
active: boolean;
|
||||
canMutate: boolean;
|
||||
requestUpdate: () => void;
|
||||
}) => TemplateResult;
|
||||
|
||||
@@ -93,17 +93,20 @@ if (!customElements.get("openclaw-workboard-card-widget")) {
|
||||
export const renderWorkboardCardWidget: PluginBoardWidgetRenderer = ({
|
||||
widget,
|
||||
sessionKey,
|
||||
active,
|
||||
canMutate,
|
||||
requestUpdate,
|
||||
}: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
active: boolean;
|
||||
canMutate: boolean;
|
||||
requestUpdate: () => void;
|
||||
}) => html`
|
||||
<openclaw-workboard-card-widget
|
||||
.widget=${widget}
|
||||
.sessionKey=${sessionKey}
|
||||
.active=${active}
|
||||
.canMutate=${canMutate}
|
||||
.hostRequestUpdate=${requestUpdate}
|
||||
></openclaw-workboard-card-widget>
|
||||
|
||||
@@ -85,15 +85,18 @@ if (!customElements.get("openclaw-workboard-mini-widget")) {
|
||||
export const renderWorkboardMiniWidget: PluginBoardWidgetRenderer = ({
|
||||
widget,
|
||||
sessionKey,
|
||||
active,
|
||||
requestUpdate,
|
||||
}: {
|
||||
widget: BoardViewWidget;
|
||||
sessionKey: string;
|
||||
active: boolean;
|
||||
requestUpdate: () => void;
|
||||
}) => html`
|
||||
<openclaw-workboard-mini-widget
|
||||
.widget=${widget}
|
||||
.sessionKey=${sessionKey}
|
||||
.active=${active}
|
||||
.hostRequestUpdate=${requestUpdate}
|
||||
></openclaw-workboard-mini-widget>
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { initialState, Task, TaskStatus } from "@lit/task";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../../../api/gateway.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../../app/context.ts";
|
||||
@@ -126,6 +127,7 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
|
||||
|
||||
@property({ attribute: false }) widget?: BoardViewWidget;
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ type: Boolean }) active = true;
|
||||
@property({ type: Boolean }) canMutate = true;
|
||||
@property({ attribute: false }) hostRequestUpdate?: () => void;
|
||||
|
||||
@@ -183,7 +185,9 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
|
||||
sync();
|
||||
const unsubscribeSnapshot = gateway.subscribe(sync);
|
||||
const unsubscribeEvents = subscribeToSharedWorkboardChanges(gateway, () => {
|
||||
void this.refresh(true);
|
||||
if (this.active) {
|
||||
void this.refresh(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribeSnapshot();
|
||||
@@ -197,7 +201,20 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
|
||||
this.syncGateway(this.context?.gateway.snapshot);
|
||||
}
|
||||
|
||||
override updated(): void {
|
||||
override shouldUpdate(): boolean {
|
||||
// @lit/task can schedule its host directly when a request settles. Keep
|
||||
// cached state, but defer the hidden render until active changes again.
|
||||
return this.active;
|
||||
}
|
||||
|
||||
override updated(changed: PropertyValues<this>): void {
|
||||
if (changed.get("active") === false && this.active) {
|
||||
void this.refresh(true);
|
||||
return;
|
||||
}
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
if (!this.loaded && this.refreshTask.status === TaskStatus.INITIAL) {
|
||||
void this.refresh();
|
||||
}
|
||||
@@ -239,7 +256,13 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
|
||||
|
||||
protected async moveCard(card: WorkboardCard, status: WorkboardStatus): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client || !this.canMutate || !isActiveWorkboardCard(card) || card.status === status) {
|
||||
if (
|
||||
!this.active ||
|
||||
!client ||
|
||||
!this.canMutate ||
|
||||
!isActiveWorkboardCard(card) ||
|
||||
card.status === status
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const host = this.workboardHost;
|
||||
@@ -300,7 +323,7 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
|
||||
private async refresh(force = false): Promise<void> {
|
||||
const client = this.client;
|
||||
const sharedRuntime = this.sharedRuntime;
|
||||
if (!client || !sharedRuntime || (!force && this.loaded)) {
|
||||
if (!this.active || !client || !sharedRuntime || (!force && this.loaded)) {
|
||||
return;
|
||||
}
|
||||
const refreshAfterInflight = force && this.refreshTask.status === TaskStatus.PENDING;
|
||||
@@ -322,6 +345,9 @@ export abstract class WorkboardWidgetElement extends OpenClawLightDomElement {
|
||||
}
|
||||
|
||||
private requestRender(): void {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
this.requestUpdate();
|
||||
this.hostRequestUpdate?.();
|
||||
}
|
||||
|
||||
@@ -551,6 +551,72 @@ describe("Workboard plugin widgets", () => {
|
||||
await vi.waitFor(() => expect(element.textContent).toContain("Running card"));
|
||||
});
|
||||
|
||||
it("pauses hidden work and refreshes once when reactivated", async () => {
|
||||
const refreshedCards = cards.map((card) =>
|
||||
card.id === "card-ready" ? { ...card, title: "Reactivated ready card" } : card,
|
||||
);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "workboard.cards.list") {
|
||||
return {
|
||||
cards: request.mock.calls.length === 1 ? cards : refreshedCards,
|
||||
statuses: ["ready", "running", "done"],
|
||||
};
|
||||
}
|
||||
if (method === "workboard.cards.move") {
|
||||
return { card: { ...cards[0], status: "running", position: 3000 } };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
});
|
||||
const events: {
|
||||
listener?: Parameters<ApplicationContext["gateway"]["subscribeEvents"]>[0];
|
||||
} = {};
|
||||
const hostRequestUpdate = vi.fn();
|
||||
const element = document.createElement("openclaw-workboard-card-widget");
|
||||
element.widget = pluginWidget("workboard:card", { cardId: "card-ready" });
|
||||
element.hostRequestUpdate = hostRequestUpdate;
|
||||
await mount(element, createContext(request, events), request);
|
||||
const retained = document.querySelector("openclaw-workboard-card-widget");
|
||||
expect(retained).toBe(element);
|
||||
|
||||
element.active = false;
|
||||
await element.updateComplete;
|
||||
hostRequestUpdate.mockClear();
|
||||
events.listener?.({
|
||||
type: "event",
|
||||
event: "plugin.workboard.changed",
|
||||
payload: { epoch: "epoch-hidden", revision: 2 },
|
||||
});
|
||||
(Reflect.get(element, "retryLoad") as () => void).call(element);
|
||||
const select = element.querySelector("select");
|
||||
expect(select).not.toBeNull();
|
||||
select!.value = "running";
|
||||
select!.dispatchEvent(new Event("change"));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(request.mock.calls.filter(([method]) => method === "workboard.cards.list")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(request).not.toHaveBeenCalledWith("workboard.cards.move", expect.anything());
|
||||
expect(hostRequestUpdate).not.toHaveBeenCalled();
|
||||
|
||||
element.active = true;
|
||||
await element.updateComplete;
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
request.mock.calls.filter(([method]) => method === "workboard.cards.list"),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
await vi.waitFor(() => expect(element.textContent).toContain("Reactivated ready card"));
|
||||
|
||||
expect(document.querySelector("openclaw-workboard-card-widget")).toBe(retained);
|
||||
expect(element.isConnected).toBe(true);
|
||||
expect(hostRequestUpdate).toHaveBeenCalled();
|
||||
expect(request.mock.calls.filter(([method]) => method === "workboard.cards.list")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("queues a second refresh when a change arrives during an active list request", async () => {
|
||||
const firstList = deferred<unknown>();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("board session shell", () => {
|
||||
addEventListener: vi.fn(() => () => {}),
|
||||
} as never;
|
||||
const props = {
|
||||
active: true,
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
dock: "right" as const,
|
||||
@@ -57,6 +58,7 @@ describe("board session shell", () => {
|
||||
renderBoardSessionSurface({
|
||||
...props,
|
||||
workboardCardChip: {
|
||||
active: true,
|
||||
basePath: "",
|
||||
client,
|
||||
sessionKey: "agent:main:workboard-link",
|
||||
@@ -71,6 +73,7 @@ describe("board session shell", () => {
|
||||
);
|
||||
expect(chip?.sessionKey).toBe("agent:main:workboard-link");
|
||||
expect(chip?.client).toBe(client);
|
||||
expect(chip?.active).toBe(true);
|
||||
expect(unlinked.querySelector("openclaw-workboard-card-chip")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -217,6 +220,7 @@ describe("board session shell", () => {
|
||||
const provider = boardProviderForSession("agent:main:main");
|
||||
render(
|
||||
renderBoardSessionSurface({
|
||||
active: true,
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
dock,
|
||||
@@ -246,6 +250,7 @@ describe("board session shell", () => {
|
||||
const provider = boardProviderForSession("agent:main:main");
|
||||
render(
|
||||
renderBoardSessionSurface({
|
||||
active: true,
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
dock: "hidden",
|
||||
@@ -273,6 +278,7 @@ describe("board session shell", () => {
|
||||
const container = createContainer();
|
||||
const provider = boardProviderForSession("agent:main:main");
|
||||
const props = {
|
||||
active: true,
|
||||
snapshot: provider.snapshot$.value,
|
||||
activeTabId: "main",
|
||||
dockSize: { height: 300 },
|
||||
@@ -303,5 +309,18 @@ describe("board session shell", () => {
|
||||
render(renderBoardSessionSurface({ ...props, dock: "hidden" }), container);
|
||||
expect(container.querySelector("openclaw-board-view")).toBe(board);
|
||||
expect(container.querySelector("[data-test-chat]")).toBeNull();
|
||||
|
||||
render(renderBoardSessionSurface({ ...props, active: false, dock: "bottom" }), container);
|
||||
const hiddenSurface = container.querySelector<HTMLElement>(".board-session-surface");
|
||||
expect(hiddenSurface?.hidden).toBe(true);
|
||||
expect(hiddenSurface?.hasAttribute("inert")).toBe(true);
|
||||
expect(container.querySelector("openclaw-board-view")).toBe(board);
|
||||
expect(board?.active).toBe(false);
|
||||
expect(container.querySelector("[data-test-chat]")).toBeNull();
|
||||
|
||||
render(renderBoardSessionSurface({ ...props, dock: "right" }), container);
|
||||
expect(container.querySelector("openclaw-board-view")).toBe(board);
|
||||
expect(container.querySelector<HTMLElement>(".board-session-surface")?.hidden).toBe(false);
|
||||
expect(board?.active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,12 +18,14 @@ export type BoardChatDockSize = {
|
||||
};
|
||||
|
||||
export type WorkboardCardChipProps = {
|
||||
active: boolean;
|
||||
basePath: string;
|
||||
client: GatewayBrowserClient;
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
type BoardSessionSurfaceProps = {
|
||||
active: boolean;
|
||||
snapshot: BoardViewSnapshot;
|
||||
observer?: BoardObserverContext;
|
||||
activeTabId: string;
|
||||
@@ -155,6 +157,7 @@ function renderBoardView(props: BoardSessionSurfaceProps) {
|
||||
${props.workboardCardChip
|
||||
? html`
|
||||
<openclaw-workboard-card-chip
|
||||
.active=${props.workboardCardChip.active}
|
||||
.basePath=${props.workboardCardChip.basePath}
|
||||
.client=${props.workboardCardChip.client}
|
||||
.sessionKey=${props.workboardCardChip.sessionKey}
|
||||
@@ -162,6 +165,7 @@ function renderBoardView(props: BoardSessionSurfaceProps) {
|
||||
`
|
||||
: nothing}
|
||||
<openclaw-board-view
|
||||
.active=${props.active}
|
||||
.snapshot=${props.snapshot}
|
||||
.activeTabId=${props.activeTabId}
|
||||
.widgetFrameUrl=${props.widgetFrameUrl}
|
||||
@@ -182,9 +186,15 @@ function renderChatDock(props: BoardSessionSurfaceProps) {
|
||||
|
||||
export function renderBoardSessionSurface(props: BoardSessionSurfaceProps) {
|
||||
return html`
|
||||
<div class="board-session-surface board-session-surface--dock-${props.dock}">
|
||||
<div
|
||||
class="board-session-surface board-session-surface--dock-${props.dock}"
|
||||
?hidden=${!props.active}
|
||||
?inert=${!props.active}
|
||||
>
|
||||
${renderBoardView(props)}
|
||||
${props.dock === "bottom" ? html`${props.divider}${renderChatDock(props)}` : nothing}
|
||||
${props.active && props.dock === "bottom"
|
||||
? html`${props.divider}${renderChatDock(props)}`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { persistSessionBoardFace } from "./chat-board-face-persistence.ts";
|
||||
|
||||
function contextWithSessions(sessions: Array<{ key: string; boardFace?: "chat" | "dashboard" }>) {
|
||||
const patch = vi.fn(async () => null);
|
||||
const context = {
|
||||
sessions: {
|
||||
state: { result: { sessions } },
|
||||
patch,
|
||||
},
|
||||
} as unknown as Pick<ApplicationContext, "sessions">;
|
||||
return { context, patch };
|
||||
}
|
||||
|
||||
describe("persistSessionBoardFace", () => {
|
||||
it("skips the patch when an equivalent cached row already has the requested face", () => {
|
||||
const { context, patch } = contextWithSessions([{ key: "main", boardFace: "dashboard" }]);
|
||||
|
||||
persistSessionBoardFace(context, "agent:main:main", "dashboard");
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("patches a native session when the cached face differs", () => {
|
||||
const { context, patch } = contextWithSessions([
|
||||
{ key: "agent:work:thread", boardFace: "chat" },
|
||||
]);
|
||||
|
||||
persistSessionBoardFace(context, "agent:work:thread", "dashboard");
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"agent:work:thread",
|
||||
{ boardFace: "dashboard" },
|
||||
{ agentId: "work" },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not patch synthetic catalog sessions", () => {
|
||||
const { context, patch } = contextWithSessions([]);
|
||||
|
||||
persistSessionBoardFace(context, "catalog:codex:gateway%3Alocal:thread-1", "dashboard");
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { BoardFace } from "../../lib/board/settings.ts";
|
||||
import { parseCatalogSessionKey } from "../../lib/sessions/catalog-key.ts";
|
||||
import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts";
|
||||
import {
|
||||
areUiSessionKeysEquivalent,
|
||||
parseAgentSessionKey,
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
|
||||
/**
|
||||
* Persists a thread's preferred face so generic navigation opens the same face on
|
||||
@@ -17,6 +20,12 @@ export function persistSessionBoardFace(
|
||||
if (parseCatalogSessionKey(sessionKey)) {
|
||||
return;
|
||||
}
|
||||
const cached = context.sessions.state.result?.sessions.find((row) =>
|
||||
areUiSessionKeysEquivalent(row.key, sessionKey),
|
||||
);
|
||||
if (cached?.boardFace === face) {
|
||||
return;
|
||||
}
|
||||
const agentId = parseAgentSessionKey(sessionKey)?.agentId;
|
||||
void context.sessions
|
||||
.patch(sessionKey, { boardFace: face }, agentId ? { agentId } : {})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import { locationWithoutDraft } from "./route-loader.ts";
|
||||
import { locationWithoutDraft } from "./route-draft.ts";
|
||||
|
||||
function currentRouteLocation(): RouteLocation {
|
||||
return {
|
||||
|
||||
@@ -24,8 +24,8 @@ import { ChatViewerPresenceController } from "./chat-viewer-presence.ts";
|
||||
import "../../styles/chat.css";
|
||||
import "./chat-pane.ts";
|
||||
import { RouteDraftComposerFocus, type ChatPaneElement } from "./route-draft-focus-handoff.ts";
|
||||
import { routeDraft } from "./route-draft.ts";
|
||||
import { locationWithoutDraft, type SessionChatRouteData } from "./route-loader.ts";
|
||||
import { locationWithoutDraft, routeDraft } from "./route-draft.ts";
|
||||
import type { SessionChatRouteData } from "./route-loader.ts";
|
||||
import type { ChatMessageCache } from "./session-message-cache.ts";
|
||||
import {
|
||||
resolveSplitDropZone,
|
||||
|
||||
@@ -254,6 +254,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
||||
}
|
||||
| undefined;
|
||||
protected readonly lastVisibleBoardDock = new Map<string, BoardVisibleChatDock>();
|
||||
protected retainedBoardSessionKey = "";
|
||||
protected readonly observerDigestHistory = new ObserverDigestHistory();
|
||||
protected builtinBoardSnapshot: BoardViewSnapshot | null = null;
|
||||
protected builtinBoardSnapshotBase: BoardSnapshot | null = null;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { guard } from "lit/directives/guard.js";
|
||||
import {
|
||||
GATEWAY_SERVER_CAPS,
|
||||
type SessionObserverDigest,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
boardProviderForSession,
|
||||
type BoardCommandEvent,
|
||||
type BoardProvider,
|
||||
type BoardViewCallbacks,
|
||||
} from "../../lib/board/provider.ts";
|
||||
import {
|
||||
updateBoardSessionView,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
type BoardVisibleChatDock,
|
||||
} from "../../lib/board/settings.ts";
|
||||
import type { BoardTab } from "../../lib/board/types.ts";
|
||||
import type { BoardViewSnapshot } from "../../lib/board/view-types.ts";
|
||||
import type { BoardObserverContext, BoardViewSnapshot } from "../../lib/board/view-types.ts";
|
||||
import {
|
||||
isGatewayCapabilityAdvertised,
|
||||
isGatewayMethodAdvertised,
|
||||
@@ -32,7 +35,12 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveUiGlobalAliasAgentId,
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
import type { WorkboardCardChipProps } from "./board-session-surface.ts";
|
||||
import {
|
||||
ensureBoardViewElement,
|
||||
ensureWorkboardCardChipElement,
|
||||
renderBoardSessionSurface,
|
||||
type WorkboardCardChipProps,
|
||||
} from "./board-session-surface.ts";
|
||||
import { ChatPaneHistory } from "./chat-pane-history.ts";
|
||||
import { boardChatDockLayout, type ResolvedBoardView } from "./chat-pane-shared.ts";
|
||||
import { renderChatResizableDivider } from "./components/chat-resizable-divider.ts";
|
||||
@@ -217,12 +225,7 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
|
||||
const enabled = isWorkboardEnabledInConfigSnapshot(
|
||||
this.context?.runtimeConfig?.state.configSnapshot,
|
||||
);
|
||||
if (
|
||||
!board.hasBoard ||
|
||||
board.face !== "dashboard" ||
|
||||
!enabled ||
|
||||
gateway?.phase !== "connected"
|
||||
) {
|
||||
if (!board.hasBoard || !enabled || gateway?.phase !== "connected") {
|
||||
return null;
|
||||
}
|
||||
const client = gateway.client;
|
||||
@@ -231,12 +234,38 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
active: board.face === "dashboard",
|
||||
basePath: state.basePath,
|
||||
client,
|
||||
sessionKey: this.resolveBoardSessionKey(board.snapshot.sessionKey),
|
||||
};
|
||||
}
|
||||
|
||||
protected syncRetainedBoardSession(board: ResolvedBoardView): void {
|
||||
const sessionKey = this.resolveBoardSessionKey(board.snapshot.sessionKey);
|
||||
if (!board.hasBoard || !sessionKey) {
|
||||
this.retainedBoardSessionKey = "";
|
||||
} else if (board.face === "dashboard") {
|
||||
this.retainedBoardSessionKey = sessionKey;
|
||||
} else if (this.retainedBoardSessionKey !== sessionKey) {
|
||||
this.retainedBoardSessionKey = "";
|
||||
}
|
||||
if (this.retainedBoardSessionKey === sessionKey && this.resolveWorkboardCardChip(board)) {
|
||||
void ensureWorkboardCardChipElement().catch(() => undefined);
|
||||
}
|
||||
if (
|
||||
board.hasBoard &&
|
||||
board.face === "dashboard" &&
|
||||
!customElements.get("openclaw-board-view")
|
||||
) {
|
||||
void ensureBoardViewElement().then((loaded) => {
|
||||
if (loaded) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected resolveBoardSessionKey(snapshotSessionKey = ""): string {
|
||||
const resolved = resolveSessionKey(
|
||||
snapshotSessionKey || this.state?.sessionKey || this.sessionKey,
|
||||
@@ -423,6 +452,57 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected renderBoardPrimary(
|
||||
board: ResolvedBoardView,
|
||||
chat: TemplateResult,
|
||||
observer: Pick<BoardObserverContext, "activeRunId" | "lastReadAt">,
|
||||
) {
|
||||
const sessionKey = this.resolveBoardSessionKey(board.snapshot.sessionKey);
|
||||
const shouldRender =
|
||||
board.hasBoard &&
|
||||
Boolean(sessionKey) &&
|
||||
(board.face === "dashboard" || this.retainedBoardSessionKey === sessionKey);
|
||||
const boardActive = board.face === "dashboard";
|
||||
const renderSurface = (active: boolean) =>
|
||||
renderBoardSessionSurface({
|
||||
active,
|
||||
snapshot: board.snapshot,
|
||||
observer: {
|
||||
...observer,
|
||||
digests: this.observerDigestHistory.get(
|
||||
this.resolveObserverDigestHistoryKey(board.snapshot.sessionKey),
|
||||
),
|
||||
},
|
||||
activeTabId: board.activeTabId,
|
||||
dock: board.dock,
|
||||
dockSize: this.boardChatDockSize,
|
||||
chat,
|
||||
divider: this.renderBoardDivider("bottom"),
|
||||
canMutate: board.provider.canMutate,
|
||||
canGrant: board.provider.canGrant,
|
||||
callbacks: {
|
||||
applyOps: (ops) => board.provider.applyOps(ops),
|
||||
grant: (name, decision) => board.provider.grant(name, decision),
|
||||
selectTab: (tabId) => {
|
||||
this.boardCommandDock = null;
|
||||
this.persistBoardSessionView({ face: "dashboard", activeTabId: tabId });
|
||||
},
|
||||
frameLoadFailed: (name) => board.provider.refreshWidgetFrame(name),
|
||||
widgetAppView: (name, revision) => board.provider.widgetAppView(name, revision),
|
||||
refreshWidgetAppView: (name, revision) =>
|
||||
board.provider.refreshWidgetAppView(name, revision),
|
||||
} satisfies BoardViewCallbacks,
|
||||
widgetFrameUrl: (name, revision) => board.provider.widgetFrameUrl(name, revision),
|
||||
workboardCardChip: this.resolveWorkboardCardChip(board),
|
||||
});
|
||||
const boardSurface = !shouldRender
|
||||
? nothing
|
||||
: boardActive
|
||||
? renderSurface(true)
|
||||
: guard([sessionKey], () => renderSurface(false));
|
||||
return html`${boardActive ? nothing : chat}${boardSurface}`;
|
||||
}
|
||||
|
||||
protected persistBoardReopenDock(board: ResolvedBoardView, dock: BoardVisibleChatDock): void {
|
||||
if (!board.activeTabId) {
|
||||
return;
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
areUiSessionKeysEquivalent,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../lib/sessions/session-key.ts";
|
||||
import { ensureBoardViewElement, ensureWorkboardCardChipElement } from "./board-session-surface.ts";
|
||||
import { invalidateChatAvatarCache, refreshChatAvatar } from "./chat-avatar.ts";
|
||||
import { clearChatHistory } from "./chat-history.ts";
|
||||
import { ChatPaneBoard } from "./chat-pane-board.ts";
|
||||
@@ -682,20 +681,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
||||
this.cancelResetConfirmationForSessionChange();
|
||||
this.syncHistoryObserver();
|
||||
const board = this.resolveBoardView();
|
||||
if (this.resolveWorkboardCardChip(board)) {
|
||||
void ensureWorkboardCardChipElement().catch(() => undefined);
|
||||
}
|
||||
if (
|
||||
board.hasBoard &&
|
||||
board.face === "dashboard" &&
|
||||
!customElements.get("openclaw-board-view")
|
||||
) {
|
||||
void ensureBoardViewElement().then((loaded) => {
|
||||
if (loaded) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
this.syncRetainedBoardSession(board);
|
||||
const selectedSessionRow = this.state ? selectedChatSessionRow(this.state) : undefined;
|
||||
// Active runs count even without a digest: a hidden observer generates
|
||||
// none, and the rail module owns the restore control for turning it back on.
|
||||
@@ -711,6 +697,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard {
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.clearComposerPrefillAttention();
|
||||
this.retainedBoardSessionKey = "";
|
||||
this.boardProviderLifecycleConnected = false;
|
||||
this.releaseBoardProviderLease();
|
||||
this.settleResetConfirmation(false);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { cancelQuestionPrompt, submitQuestionPrompt } from "../../app/question-p
|
||||
import { readPresenceEntries, resolveCurrentSelfUser } from "../../app/user-profile.ts";
|
||||
import { hasSessionPresenceViewers } from "../../components/viewer-facepile.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { BoardViewCallbacks } from "../../lib/board/provider.ts";
|
||||
import {
|
||||
resolveControlUiFollowUpMode,
|
||||
resolveControlUiServerQueueMode,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
resolveChatPaneObserverRunId,
|
||||
} from "../../lib/observer-digest.ts";
|
||||
import { buildAgentMainSessionKey } from "../../lib/sessions/session-key.ts";
|
||||
import { renderBoardSessionSurface } from "./board-session-surface.ts";
|
||||
import { clearChatHistory } from "./chat-history.ts";
|
||||
import { resolveChatMessageAccess } from "./chat-message-access.ts";
|
||||
import { createChatModelSetupBanner, requiresChatModelSetup } from "./chat-model-setup.ts";
|
||||
@@ -567,41 +565,10 @@ export class ChatPane extends ChatPaneHeader {
|
||||
gatewayUrl: state.settings.gatewayUrl,
|
||||
};
|
||||
const chat = renderChat(props);
|
||||
const workboardCardChip = this.resolveWorkboardCardChip(board);
|
||||
const primary =
|
||||
board.hasBoard && board.face === "dashboard"
|
||||
? renderBoardSessionSurface({
|
||||
snapshot: board.snapshot,
|
||||
observer: {
|
||||
activeRunId: observerRunId,
|
||||
digests: this.observerDigestHistory.get(
|
||||
this.resolveObserverDigestHistoryKey(board.snapshot.sessionKey),
|
||||
),
|
||||
lastReadAt: selectedSession?.lastReadAt,
|
||||
},
|
||||
activeTabId: board.activeTabId,
|
||||
dock: board.dock,
|
||||
dockSize: this.boardChatDockSize,
|
||||
chat,
|
||||
divider: this.renderBoardDivider("bottom"),
|
||||
canMutate: board.provider.canMutate,
|
||||
canGrant: board.provider.canGrant,
|
||||
callbacks: {
|
||||
applyOps: (ops) => board.provider.applyOps(ops),
|
||||
grant: (name, decision) => board.provider.grant(name, decision),
|
||||
selectTab: (tabId) => {
|
||||
this.boardCommandDock = null;
|
||||
this.persistBoardSessionView({ face: "dashboard", activeTabId: tabId });
|
||||
},
|
||||
frameLoadFailed: (name) => board.provider.refreshWidgetFrame(name),
|
||||
widgetAppView: (name, revision) => board.provider.widgetAppView(name, revision),
|
||||
refreshWidgetAppView: (name, revision) =>
|
||||
board.provider.refreshWidgetAppView(name, revision),
|
||||
} satisfies BoardViewCallbacks,
|
||||
widgetFrameUrl: (name, revision) => board.provider.widgetFrameUrl(name, revision),
|
||||
workboardCardChip,
|
||||
})
|
||||
: chat;
|
||||
const primary = this.renderBoardPrimary(board, chat, {
|
||||
activeRunId: observerRunId,
|
||||
lastReadAt: selectedSession?.lastReadAt,
|
||||
});
|
||||
const discussion = this.buildSessionDiscussionPanel(state, state.sessionKey.trim());
|
||||
const panelTemplates = {
|
||||
chat,
|
||||
|
||||
@@ -12,6 +12,14 @@ function focusComposerFromLocation(location: RouteLocation): boolean {
|
||||
return new URLSearchParams(location.search).get(SESSION_COMPOSER_FOCUS_PARAM) === "1";
|
||||
}
|
||||
|
||||
export function locationWithoutDraft(location: RouteLocation): RouteLocation {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("draft");
|
||||
params.delete(SESSION_COMPOSER_FOCUS_PARAM);
|
||||
const search = params.toString();
|
||||
return { ...location, search: search ? `?${search}` : "" };
|
||||
}
|
||||
|
||||
export function draftRouteDataFromLocation(location: RouteLocation): RouteDraftHint {
|
||||
const draft = draftFromLocation(location);
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../../lib/sessions/catalog-key.ts";
|
||||
import {
|
||||
findUiSessionRow,
|
||||
SESSION_COMPOSER_FOCUS_PARAM,
|
||||
SESSION_FACE_PREFERENCE_PARAM,
|
||||
SESSION_NAVIGATION_KEY_PARAM,
|
||||
} from "../../lib/sessions/route-navigation.ts";
|
||||
@@ -76,14 +75,6 @@ export type SessionChatRouteData = Omit<
|
||||
kind?: "session";
|
||||
};
|
||||
|
||||
export function locationWithoutDraft(location: RouteLocation): RouteLocation {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("draft");
|
||||
params.delete(SESSION_COMPOSER_FOCUS_PARAM);
|
||||
const search = params.toString();
|
||||
return { ...location, search: search ? `?${search}` : "" };
|
||||
}
|
||||
|
||||
type SessionReferenceSearch = { agentId: string } & (
|
||||
| { kind: "exact"; value: string }
|
||||
| { kind: "slug"; value: string }
|
||||
|
||||
+123
-1
@@ -1,12 +1,25 @@
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import type { RouteLocation, RouteMatch } from "@openclaw/uirouter";
|
||||
import { definePage } from "@openclaw/uirouter";
|
||||
import { html, nothing } from "lit";
|
||||
import { INTERNAL_SESSION_PATH_PARAM, pathForRoute, routePageSpec } from "../../app-route-paths.ts";
|
||||
import { sessionRefFromPath } from "../../app-session-route-paths.ts";
|
||||
import { resolveControlUiBasePath } from "../../app/browser.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { BoardFace } from "../../lib/board/settings.ts";
|
||||
import {
|
||||
buildCatalogSessionKey,
|
||||
catalogSessionKeyFromSearch,
|
||||
} from "../../lib/sessions/catalog-key.ts";
|
||||
import {
|
||||
SESSION_FACE_PREFERENCE_PARAM,
|
||||
SESSION_NAVIGATION_KEY_PARAM,
|
||||
} from "../../lib/sessions/route-navigation.ts";
|
||||
import { locationWithoutDraft } from "./route-draft.ts";
|
||||
import type { ChatRouteData } from "./route-loader.ts";
|
||||
|
||||
type SessionOwnerMatch = Pick<RouteMatch, "data" | "location">;
|
||||
|
||||
function renderAmbiguous(data: Extract<ChatRouteData, { kind: "ambiguous" }>) {
|
||||
return html`
|
||||
<section class="card">
|
||||
@@ -50,6 +63,111 @@ function sessionLoaderDeps(
|
||||
}`;
|
||||
}
|
||||
|
||||
function sessionOwnerKey(sessionKey: string): string {
|
||||
return `chat-session:${sessionKey}`;
|
||||
}
|
||||
|
||||
function sessionTargetFromLocation(location: RouteLocation) {
|
||||
const internalPath = new URLSearchParams(location.search).get(INTERNAL_SESSION_PATH_PARAM);
|
||||
const pathname = internalPath ?? location.pathname;
|
||||
return sessionRefFromPath(pathname, resolveControlUiBasePath(pathname));
|
||||
}
|
||||
|
||||
function locationWithoutOwnerHints(location: RouteLocation): RouteLocation {
|
||||
const withoutDraft = locationWithoutDraft(location);
|
||||
const search = new URLSearchParams(withoutDraft.search);
|
||||
search.delete(SESSION_FACE_PREFERENCE_PARAM);
|
||||
search.delete(SESSION_NAVIGATION_KEY_PARAM);
|
||||
const serialized = search.toString();
|
||||
return { ...withoutDraft, search: serialized ? `?${serialized}` : "" };
|
||||
}
|
||||
|
||||
function routeLocationsEqual(left: RouteLocation, right: RouteLocation): boolean {
|
||||
return (
|
||||
left.pathname === right.pathname && left.search === right.search && left.hash === right.hash
|
||||
);
|
||||
}
|
||||
|
||||
function sessionTargetsEqual(
|
||||
left: ReturnType<typeof sessionTargetFromLocation>,
|
||||
right: ReturnType<typeof sessionTargetFromLocation>,
|
||||
): boolean {
|
||||
if (!left || !right || left.agentId !== right.agentId || left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "main" && right.kind === "main") {
|
||||
return true;
|
||||
}
|
||||
if (left.kind === "literal" && right.kind === "literal") {
|
||||
return left.sessionKey === right.sessionKey && left.slugCandidate === right.slugCandidate;
|
||||
}
|
||||
return (
|
||||
left.kind === "short" &&
|
||||
right.kind === "short" &&
|
||||
left.shortId === right.shortId &&
|
||||
left.slugHint === right.slugHint
|
||||
);
|
||||
}
|
||||
|
||||
function settledSessionOwnerKey(
|
||||
pending: SessionOwnerMatch,
|
||||
settled: SessionOwnerMatch | undefined,
|
||||
): string | undefined {
|
||||
const settledData = settled?.data as ChatRouteData | undefined;
|
||||
if (!settled || settledData?.kind !== "session") {
|
||||
return undefined;
|
||||
}
|
||||
const canonical = settledData.canonicalLocation;
|
||||
if (
|
||||
canonical &&
|
||||
routeLocationsEqual(
|
||||
locationWithoutOwnerHints(pending.location),
|
||||
locationWithoutOwnerHints(canonical),
|
||||
)
|
||||
) {
|
||||
return sessionOwnerKey(settledData.sessionKey);
|
||||
}
|
||||
return sessionTargetsEqual(
|
||||
sessionTargetFromLocation(pending.location),
|
||||
sessionTargetFromLocation(settled.location),
|
||||
)
|
||||
? sessionOwnerKey(settledData.sessionKey)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function sessionRenderOwnerKey(
|
||||
face: BoardFace,
|
||||
match: SessionOwnerMatch,
|
||||
settled: SessionOwnerMatch | undefined,
|
||||
): string | undefined {
|
||||
const data = match.data as ChatRouteData | undefined;
|
||||
if (data?.kind === "ambiguous") {
|
||||
return undefined;
|
||||
}
|
||||
if (data?.kind === "session") {
|
||||
return sessionOwnerKey(data.sessionKey);
|
||||
}
|
||||
const search = new URLSearchParams(match.location.search);
|
||||
const catalogKey = catalogSessionKeyFromSearch(match.location.search);
|
||||
if (catalogKey) {
|
||||
return sessionOwnerKey(buildCatalogSessionKey(catalogKey));
|
||||
}
|
||||
const navigationKey = search.get(SESSION_NAVIGATION_KEY_PARAM)?.trim();
|
||||
if (navigationKey) {
|
||||
return sessionOwnerKey(navigationKey);
|
||||
}
|
||||
const target = sessionTargetFromLocation(match.location);
|
||||
if (target?.namespace !== face) {
|
||||
return undefined;
|
||||
}
|
||||
if (target.kind === "literal" && target.slugCandidate === undefined) {
|
||||
return sessionOwnerKey(target.sessionKey);
|
||||
}
|
||||
// Unresolved short and slug routes borrow identity only from the exact route
|
||||
// that settled them; path resemblance alone cannot identify a session.
|
||||
return settledSessionOwnerKey(match, settled);
|
||||
}
|
||||
|
||||
function sessionPage(face: BoardFace) {
|
||||
return definePage({
|
||||
...routePageSpec(face),
|
||||
@@ -64,6 +182,10 @@ function sessionPage(face: BoardFace) {
|
||||
component: () =>
|
||||
import("./chat-page.ts").then(() => ({
|
||||
header: true,
|
||||
// ChatPage owns pane/session teardown. The route namespace only changes
|
||||
// presentation, so it must not preempt that owner during face switches.
|
||||
renderOwnerKey: (match: SessionOwnerMatch, settled?: SessionOwnerMatch) =>
|
||||
sessionRenderOwnerKey(face, match, settled),
|
||||
render: (data: unknown) => {
|
||||
const routeData = data as ChatRouteData | undefined;
|
||||
if (!routeData) {
|
||||
|
||||
@@ -19,6 +19,7 @@ afterEach(() => {
|
||||
describe("Workboard card chip", () => {
|
||||
it("loads the matching card and releases its shared lookup lease", async () => {
|
||||
const removeListener = vi.fn();
|
||||
let gatewayListener: ((event: { event: string }) => void) | undefined;
|
||||
const request = vi.fn(async () => ({
|
||||
cards: [
|
||||
{
|
||||
@@ -35,7 +36,10 @@ describe("Workboard card chip", () => {
|
||||
},
|
||||
],
|
||||
}));
|
||||
const addEventListener = vi.fn(() => removeListener);
|
||||
const addEventListener = vi.fn((listener: (event: { event: string }) => void) => {
|
||||
gatewayListener = listener;
|
||||
return removeListener;
|
||||
});
|
||||
const client = {
|
||||
request,
|
||||
addEventListener,
|
||||
@@ -56,12 +60,25 @@ describe("Workboard card chip", () => {
|
||||
expect(link?.textContent).toContain("Review");
|
||||
expect(request).toHaveBeenCalledWith("workboard.cards.list", {});
|
||||
|
||||
element.active = false;
|
||||
element.sessionKey = "agent:main:workboard-next";
|
||||
await element.updateComplete;
|
||||
expect(element.querySelector(".board-session-surface__workboard-chip")).toBeNull();
|
||||
expect(removeListener).toHaveBeenCalledOnce();
|
||||
const requestCount = request.mock.calls.length;
|
||||
gatewayListener?.({ event: "workboard.changed" });
|
||||
await Promise.resolve();
|
||||
expect(request).toHaveBeenCalledTimes(requestCount);
|
||||
|
||||
element.active = true;
|
||||
await vi.waitFor(() => expect(addEventListener).toHaveBeenCalledTimes(2));
|
||||
expect(element.textContent).not.toContain("Ship dashboard stitch");
|
||||
|
||||
element.remove();
|
||||
await element.updateComplete;
|
||||
expect(removeListener).toHaveBeenCalledOnce();
|
||||
expect(addEventListener).toHaveBeenCalledOnce();
|
||||
expect(removeListener).toHaveBeenCalledTimes(2);
|
||||
|
||||
document.body.append(element);
|
||||
await vi.waitFor(() => expect(addEventListener).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(addEventListener).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
|
||||
class WorkboardCardChip extends OpenClawLightDomElement {
|
||||
@property({ type: Boolean }) active = true;
|
||||
@property({ attribute: false }) basePath = "";
|
||||
@property({ attribute: false }) client: GatewayBrowserClient | null = null;
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@@ -40,7 +41,7 @@ class WorkboardCardChip extends OpenClawLightDomElement {
|
||||
private synchronizeLookup(): void {
|
||||
const client = this.client;
|
||||
const sessionKey = this.sessionKey.trim();
|
||||
if (!client || !sessionKey) {
|
||||
if (!this.active || !client || !sessionKey) {
|
||||
this.releaseLookup();
|
||||
return;
|
||||
}
|
||||
@@ -74,7 +75,7 @@ class WorkboardCardChip extends OpenClawLightDomElement {
|
||||
|
||||
override render() {
|
||||
const match = this.match;
|
||||
if (!match) {
|
||||
if (!this.active || !match) {
|
||||
return nothing;
|
||||
}
|
||||
const status = t(`workboard.status.${match.status}`);
|
||||
|
||||
@@ -255,7 +255,7 @@ describe("Workboard card dashboard", () => {
|
||||
expect(element.querySelector("openclaw-board-view")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("disables widget ticket refresh while the dashboard is collapsed", async () => {
|
||||
it("pauses the board while the dashboard is collapsed", async () => {
|
||||
const { client } = createClient([
|
||||
{
|
||||
name: "status",
|
||||
@@ -274,17 +274,17 @@ describe("Workboard card dashboard", () => {
|
||||
const element = await mountDashboard("agent:main:workboard-collapse", client);
|
||||
await vi.waitFor(() => expect(element.querySelector("openclaw-board-view")).not.toBeNull());
|
||||
const board = element.querySelector("openclaw-board-view")!;
|
||||
expect(board.ticketRefreshEnabled).toBe(true);
|
||||
expect(board.active).toBe(true);
|
||||
|
||||
element.querySelector<HTMLButtonElement>(".workboard-card-dashboard__toggle")?.click();
|
||||
await element.updateComplete;
|
||||
await board.updateComplete;
|
||||
expect(board.ticketRefreshEnabled).toBe(false);
|
||||
expect(board.active).toBe(false);
|
||||
|
||||
element.querySelector<HTMLButtonElement>(".workboard-card-dashboard__toggle")?.click();
|
||||
await element.updateComplete;
|
||||
await board.updateComplete;
|
||||
expect(board.ticketRefreshEnabled).toBe(true);
|
||||
expect(board.active).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an empty dashboard compact until the operator expands its hint", async () => {
|
||||
|
||||
@@ -149,6 +149,7 @@ class WorkboardCardDashboard extends OpenClawLightDomElement {
|
||||
${hasBoard && provider && boardSnapshot && callbacks
|
||||
? html`
|
||||
<openclaw-board-view
|
||||
.active=${this.expanded}
|
||||
.snapshot=${boardSnapshot}
|
||||
.activeTabId=${this.activeTabId}
|
||||
.widgetFrameUrl=${(name: string, revision: number) =>
|
||||
@@ -157,7 +158,6 @@ class WorkboardCardDashboard extends OpenClawLightDomElement {
|
||||
.sessions=${[]}
|
||||
.canMutate=${this.canMutate}
|
||||
.canGrant=${this.canGrant}
|
||||
.ticketRefreshEnabled=${this.expanded}
|
||||
></openclaw-board-view>
|
||||
`
|
||||
: html`<p class="workboard-card-dashboard__empty">${t("workboard.dashboardEmpty")}</p>`}
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
background: color-mix(in srgb, var(--panel) 35%, transparent);
|
||||
}
|
||||
|
||||
.board-session-surface[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board-session-surface--dock-bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -1070,6 +1070,7 @@ function installControlUiMockGateway(
|
||||
"label",
|
||||
"category",
|
||||
"icon",
|
||||
"boardFace",
|
||||
"pinned",
|
||||
"unread",
|
||||
"toolOverrides",
|
||||
|
||||
Reference in New Issue
Block a user