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:
Peter Steinberger
2026-08-08 15:53:59 -07:00
committed by GitHub
parent 39bbdd6142
commit bd6d35443f
45 changed files with 1799 additions and 188 deletions
+11 -1
View File
@@ -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<
+286
View File
@@ -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();
});
});
+14 -1
View File
@@ -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 () => {
+21 -5
View File
@@ -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;
+72 -3
View File
@@ -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>();
+21 -9
View File
@@ -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 }) });
+15 -4
View File
@@ -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 },
+16 -3
View File
@@ -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";
+58 -12
View File
@@ -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 &&
+28 -4
View File
@@ -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);
+12 -2
View File
@@ -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);
+182 -3
View File
@@ -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"],
+50
View File
@@ -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);
+56 -9
View File
@@ -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 = "";
}
}
}
+1
View File
@@ -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>
`;
+30 -4
View File
@@ -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);
});
});
+12 -2
View File
@@ -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 -1
View File
@@ -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 {
+2 -2
View File
@@ -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,
+1
View File
@@ -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;
+88 -8
View File
@@ -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;
+2 -15
View File
@@ -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);
+4 -37
View File
@@ -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,
+8
View File
@@ -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 {
-9
View File
@@ -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
View File
@@ -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>`}
+4
View File
@@ -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;
}
+1
View File
@@ -1070,6 +1070,7 @@ function installControlUiMockGateway(
"label",
"category",
"icon",
"boardFace",
"pinned",
"unread",
"toolOverrides",