diff --git a/ui/src/app-routes.ts b/ui/src/app-routes.ts index b939e631340e..851b4507af92 100644 --- a/ui/src/app-routes.ts +++ b/ui/src/app-routes.ts @@ -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, + settled: Pick | undefined, + ) => string | undefined; }; export type ApplicationRouter = Router< diff --git a/ui/src/app/router-outlet-chat.test.ts b/ui/src/app/router-outlet-chat.test.ts new file mode 100644 index 000000000000..994da0042e65 --- /dev/null +++ b/ui/src/app/router-outlet-chat.test.ts @@ -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; +type OwnerMatch = Pick, "data" | "location">; +type TestModule = { + render: (data: ChatRouteData | undefined) => unknown; + renderOwnerKey?: (match: OwnerMatch, settled: OwnerMatch | undefined) => string | undefined; +}; +type TestRouter = Router; +type RouterOutletElement = LitElement & { router?: TestRouter }; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((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 { + await settleLitElement(outlet); +} + +function ownedRenderer(teardown: () => Promise) { + return (data: ChatRouteData | undefined) => + data + ? html` + { + if (element) { + Reflect.set(element, "restartAfterTeardown", vi.fn()); + Reflect.set(element, "teardown", teardown); + } + })} + > +
${data.kind === "session" ? data.face : "chooser"}
+ ` + : nothing; +} + +async function routeModule( + face: "chat" | "dashboard", + render: TestModule["render"], +): Promise { + 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(); + 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({ + 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(); + let loadCount = 0; + const teardown = vi.fn(async () => undefined); + const module = await routeModule("chat", ownedRenderer(teardown)); + const router = createRouter({ + 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(); + let loadCount = 0; + const teardown = vi.fn(async () => undefined); + const module = await routeModule("chat", ownedRenderer(teardown)); + const router = createRouter({ + 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(); + let loadCount = 0; + const teardown = vi.fn(async () => undefined); + const module = await routeModule("chat", ownedRenderer(teardown)); + const router = createRouter({ + 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(); + }); +}); diff --git a/ui/src/app/router-outlet-controller.test.ts b/ui/src/app/router-outlet-controller.test.ts index db963ca9acbd..c61c0bdc8447 100644 --- a/ui/src/app/router-outlet-controller.test.ts +++ b/ui/src/app/router-outlet-controller.test.ts @@ -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(); const secondData = deferred(); @@ -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({ 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 () => { diff --git a/ui/src/app/router-outlet-controller.ts b/ui/src/app/router-outlet-controller.ts index 398cbb7f08b5..6968ada8ec55 100644 --- a/ui/src/app/router-outlet-controller.ts +++ b/ui/src/app/router-outlet-controller.ts @@ -17,6 +17,7 @@ export type RouterOutletSnapshot< TModule = unknown, TData = unknown, > = RouterOutletStateSlice & { + settled: RouteMatch | undefined; showPending: boolean; }; @@ -68,6 +69,7 @@ function idleSnapshot(): RouterOutletSn status: "idle", active: undefined, pending: undefined, + settled: undefined, showPending: false, }; } @@ -89,6 +91,7 @@ export class RouterOutletController< private unsubscribe?: () => void; private selection: RouterOutletStateSlice = idleSnapshot(); private snapshotValue: RouterOutletSnapshot = idleSnapshot(); + private settled?: RouteMatch; private pendingMatchId?: string; private pendingTimer?: ReturnType; 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(); - 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(), 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; diff --git a/ui/src/app/router-outlet.test.ts b/ui/src/app/router-outlet.test.ts index cb326e0fb291..ae75e048468a 100644 --- a/ui/src/app/router-outlet.test.ts +++ b/ui/src/app/router-outlet.test.ts @@ -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, "data" | "location">; +type TestModule = { + render: (data: TestData | undefined) => unknown; + renderOwnerKey?: ( + match: TestOwnerMatch, + settled: TestOwnerMatch | undefined, + ) => string | undefined; +}; type TestRouter = Router; type RouterOutletElement = LitElement & { router?: TestRouter; @@ -53,6 +60,68 @@ async function settleOutlet(outlet: RouterOutletElement): Promise { } 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(); + const renderOwnedRoute = vi.fn((data: TestData | undefined) => + data + ? html` + { + if (element) { + Reflect.set(element, "restartAfterTeardown", vi.fn()); + Reflect.set(element, "teardown", teardownView); + } + })} + > +
${data.label}
+ ` + : nothing, + ); + const ownedModule: TestModule = { + renderOwnerKey: () => "shared-owner", + render: renderOwnedRoute, + }; + const context = { label: "page" }; + const router = createRouter({ + 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(); diff --git a/ui/src/app/router-outlet.ts b/ui/src/app/router-outlet.ts index 522c2128493a..bae739b03ffb 100644 --- a/ui/src/app/router-outlet.ts +++ b/ui/src/app/router-outlet.ts @@ -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 = { render: (data: TData | undefined) => unknown; + renderOwnerKey?: ( + match: Pick, "data" | "location">, + settled: Pick, "data" | "location"> | undefined, + ) => string | undefined; }; type RouterOutletOptions = { @@ -139,10 +143,9 @@ function renderError( function renderRouterOutlet( router: Router, selection: RouterOutletSnapshot, + renderedMatch: RouteMatch | undefined, options: RouterOutletOptions = {}, ): 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(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, + }); } } diff --git a/ui/src/components/board/board-mcp-app-content.ts b/ui/src/components/board/board-mcp-app-content.ts index 8b4782e8676c..01b1f27e7ec8 100644 --- a/ui/src/components/board/board-mcp-app-content.ts +++ b/ui/src/components/board/board-mcp-app-content.ts @@ -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")} `; const view = - !options.nearVisible || !appView - ? loading - : appView.status === "stale" - ? html`
- ${t("board.widget.appStaleTitle")} - ${t("board.widget.appStaleDetail")} -
- - -
-
` - : ready - ? html`` + ready && (!options.active || options.nearVisible) + ? html`` + : !options.nearVisible || !appView + ? loading + : appView.status === "stale" + ? html`
+ ${t("board.widget.appStaleTitle")} + ${t("board.widget.appStaleDetail")} +
+ + +
+
` : loading; return html`
${options.accessNotice}${view}
`; } diff --git a/ui/src/components/board/board-mcp-app-lifecycle.ts b/ui/src/components/board/board-mcp-app-lifecycle.ts index 091d50a186d1..95516b9fbccb 100644 --- a/ui/src/components/board/board-mcp-app-lifecycle.ts +++ b/ui/src/components/board/board-mcp-app-lifecycle.ts @@ -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 { - 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 && diff --git a/ui/src/components/board/board-view.test.ts b/ui/src/components/board/board-view.test.ts index a5b7380f0110..805f8a997d6b 100644 --- a/ui/src/components/board/board-view.test.ts +++ b/ui/src/components/board/board-view.test.ts @@ -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(".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 }) }); diff --git a/ui/src/components/board/board-view.ts b/ui/src/components/board/board-view.ts index d8fff793c7e7..563ed346e00a 100644 --- a/ui/src/components/board/board-view.ts +++ b/ui/src/components/board/board-view.ts @@ -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} > `; }, diff --git a/ui/src/components/board/board-widget-cell-mcp-app.test.ts b/ui/src/components/board/board-widget-cell-mcp-app.test.ts index 06ec18ed3d9e..356d174ce6b9 100644 --- a/ui/src/components/board/board-widget-cell-mcp-app.test.ts +++ b/ui/src/components/board/board-widget-cell-mcp-app.test.ts @@ -65,12 +65,14 @@ function callbacks(overrides: Partial = {}): BoardWidg async function mount( currentWidget: BoardViewWidget, currentCallbacks: BoardWidgetCellCallbacks, + active = true, ): Promise { 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(); + 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, diff --git a/ui/src/components/board/board-widget-cell-plugin.test.ts b/ui/src/components/board/board-widget-cell-plugin.test.ts index ef661e337096..a8f45d98c243 100644 --- a/ui/src/components/board/board-widget-cell-plugin.test.ts +++ b/ui/src/components/board/board-widget-cell-plugin.test.ts @@ -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 }, diff --git a/ui/src/components/board/board-widget-cell.ts b/ui/src/components/board/board-widget-cell.ts index d4c50548090c..f66540e718ca 100644 --- a/ui/src/components/board/board-widget-cell.ts +++ b/ui/src/components/board/board-widget-cell.ts @@ -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(), }); diff --git a/ui/src/components/board/board-widget-frame.test.ts b/ui/src/components/board/board-widget-frame.test.ts index 5ad696fe2cdb..5f2cfb37b232 100644 --- a/ui/src/components/board/board-widget-frame.test.ts +++ b/ui/src/components/board/board-widget-frame.test.ts @@ -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, ): 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"; diff --git a/ui/src/components/board/board-widget-frame.ts b/ui/src/components/board/board-widget-frame.ts index e5fdc695993c..64262a531cf7 100644 --- a/ui/src/components/board/board-widget-frame.ts +++ b/ui/src/components/board/board-widget-frame.ts @@ -48,6 +48,7 @@ function resolveBoardFrameFailureMessage( type FrameRefresh = (name: string) => Promise; 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(".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 && diff --git a/ui/src/components/mcp-app-unmount.test.ts b/ui/src/components/mcp-app-unmount.test.ts index e93bce31d123..410fe169cabe 100644 --- a/ui/src/components/mcp-app-unmount.test.ts +++ b/ui/src/components/mcp-app-unmount.test.ts @@ -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}>initial` - : html`${this.key}`; - return this.gate.render(this.key, value, () => [this.renderRoot]); + : html`${this.valueKey}`; + 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); diff --git a/ui/src/components/mcp-app-unmount.ts b/ui/src/components/mcp-app-unmount.ts index 26e4bfa326ef..58fcdcfa66c3 100644 --- a/ui/src/components/mcp-app-unmount.ts +++ b/ui/src/components/mcp-app-unmount.ts @@ -51,7 +51,12 @@ export class McpAppUnmountGate { return this.renderedValue; } - render(key: string, value: unknown, leavingRoots: () => Iterable): unknown { + render( + key: string, + value: unknown, + leavingRoots: () => Iterable, + 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); diff --git a/ui/src/e2e/board-mcp-app.e2e.test.ts b/ui/src/e2e/board-mcp-app.e2e.test.ts index 479d3a6478b6..5877d608fd94 100644 --- a/ui/src/e2e/board-mcp-app.e2e.test.ts +++ b/ui/src/e2e/board-mcp-app.e2e.test.ts @@ -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 { ); } +async function captureBoardIdentity(page: Page): Promise { + await page.evaluate(() => { + const surface = document.querySelector(".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(".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 { + 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 { + 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"], diff --git a/ui/src/e2e/session-dashboard.e2e.test.ts b/ui/src/e2e/session-dashboard.e2e.test.ts index 614647127cef..bb1a87ee0760 100644 --- a/ui/src/e2e/session-dashboard.e2e.test.ts +++ b/ui/src/e2e/session-dashboard.e2e.test.ts @@ -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((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({ diff --git a/ui/src/lib/board/widget-sandbox-host.test.ts b/ui/src/lib/board/widget-sandbox-host.test.ts index d243a7692f12..d0da11b1faf6 100644 --- a/ui/src/lib/board/widget-sandbox-host.test.ts +++ b/ui/src/lib/board/widget-sandbox-host.test.ts @@ -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("

retained

")); + 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("

retained

")); + 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>() + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveFirstFetch = resolve; + }), + ) + .mockResolvedValueOnce(new Response("

resumed

")); + 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("

stale

")); + 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); diff --git a/ui/src/lib/board/widget-sandbox-host.ts b/ui/src/lib/board/widget-sandbox-host.ts index 5be6dc184a92..3aa8df706224 100644 --- a/ui/src/lib/board/widget-sandbox-host.ts +++ b/ui/src/lib/board/widget-sandbox-host.ts @@ -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(); @@ -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 { + 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 = ""; + } } } diff --git a/ui/src/lib/board/widgets/index.ts b/ui/src/lib/board/widgets/index.ts index 7a6d6903536e..f8fee1df8767 100644 --- a/ui/src/lib/board/widgets/index.ts +++ b/ui/src/lib/board/widgets/index.ts @@ -13,6 +13,7 @@ type BuiltinBoardWidgetRenderer = (context: { export type PluginBoardWidgetRenderer = (props: { widget: BoardViewWidget; sessionKey: string; + active: boolean; canMutate: boolean; requestUpdate: () => void; }) => TemplateResult; diff --git a/ui/src/lib/board/widgets/workboard-card.ts b/ui/src/lib/board/widgets/workboard-card.ts index a83b1ede52f8..329416ecee98 100644 --- a/ui/src/lib/board/widgets/workboard-card.ts +++ b/ui/src/lib/board/widgets/workboard-card.ts @@ -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` diff --git a/ui/src/lib/board/widgets/workboard-mini.ts b/ui/src/lib/board/widgets/workboard-mini.ts index d1f64c74e6e3..927e25c3f442 100644 --- a/ui/src/lib/board/widgets/workboard-mini.ts +++ b/ui/src/lib/board/widgets/workboard-mini.ts @@ -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` `; diff --git a/ui/src/lib/board/widgets/workboard-widget.ts b/ui/src/lib/board/widgets/workboard-widget.ts index a842f8d592e6..81394202cec7 100644 --- a/ui/src/lib/board/widgets/workboard-widget.ts +++ b/ui/src/lib/board/widgets/workboard-widget.ts @@ -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): 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 { 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 { 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?.(); } diff --git a/ui/src/lib/board/widgets/workboard.test.ts b/ui/src/lib/board/widgets/workboard.test.ts index 66ae8ec696d1..f6b37289d578 100644 --- a/ui/src/lib/board/widgets/workboard.test.ts +++ b/ui/src/lib/board/widgets/workboard.test.ts @@ -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[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(); const request = vi.fn(async (method: string) => { diff --git a/ui/src/pages/chat/board-session-surface.test.ts b/ui/src/pages/chat/board-session-surface.test.ts index f1a9499e0407..45f668c4006c 100644 --- a/ui/src/pages/chat/board-session-surface.test.ts +++ b/ui/src/pages/chat/board-session-surface.test.ts @@ -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(".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(".board-session-surface")?.hidden).toBe(false); + expect(board?.active).toBe(true); }); }); diff --git a/ui/src/pages/chat/board-session-surface.ts b/ui/src/pages/chat/board-session-surface.ts index 8a9ef23187b0..040869ce860b 100644 --- a/ui/src/pages/chat/board-session-surface.ts +++ b/ui/src/pages/chat/board-session-surface.ts @@ -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` +
${renderBoardView(props)} - ${props.dock === "bottom" ? html`${props.divider}${renderChatDock(props)}` : nothing} + ${props.active && props.dock === "bottom" + ? html`${props.divider}${renderChatDock(props)}` + : nothing}
`; } diff --git a/ui/src/pages/chat/chat-board-face-persistence.test.ts b/ui/src/pages/chat/chat-board-face-persistence.test.ts new file mode 100644 index 000000000000..86272f1bce1a --- /dev/null +++ b/ui/src/pages/chat/chat-board-face-persistence.test.ts @@ -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; + 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(); + }); +}); diff --git a/ui/src/pages/chat/chat-board-face-persistence.ts b/ui/src/pages/chat/chat-board-face-persistence.ts index 7da98424641a..96c000466a5d 100644 --- a/ui/src/pages/chat/chat-board-face-persistence.ts +++ b/ui/src/pages/chat/chat-board-face-persistence.ts @@ -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 } : {}) diff --git a/ui/src/pages/chat/chat-canonical-location.ts b/ui/src/pages/chat/chat-canonical-location.ts index 740d6b0478db..ec690c27b4c7 100644 --- a/ui/src/pages/chat/chat-canonical-location.ts +++ b/ui/src/pages/chat/chat-canonical-location.ts @@ -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 { diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index 05f2d698d21b..994f82c84db0 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -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, diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 006b86bf5f7d..f1eb2bde79e9 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -254,6 +254,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { } | undefined; protected readonly lastVisibleBoardDock = new Map(); + protected retainedBoardSessionKey = ""; protected readonly observerDigestHistory = new ObserverDigestHistory(); protected builtinBoardSnapshot: BoardViewSnapshot | null = null; protected builtinBoardSnapshotBase: BoardSnapshot | null = null; diff --git a/ui/src/pages/chat/chat-pane-board.ts b/ui/src/pages/chat/chat-pane-board.ts index a54b47ba9a39..6a6c6cb10b06 100644 --- a/ui/src/pages/chat/chat-pane-board.ts +++ b/ui/src/pages/chat/chat-pane-board.ts @@ -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, + ) { + 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; diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 316cc34a92d8..269055b30e1c 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -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); diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 98140bedbabf..23cba6040243 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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, diff --git a/ui/src/pages/chat/route-draft.ts b/ui/src/pages/chat/route-draft.ts index d75086c0f028..095f332a6949 100644 --- a/ui/src/pages/chat/route-draft.ts +++ b/ui/src/pages/chat/route-draft.ts @@ -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 { diff --git a/ui/src/pages/chat/route-loader.ts b/ui/src/pages/chat/route-loader.ts index 84134756ae53..799ef8eeb4f5 100644 --- a/ui/src/pages/chat/route-loader.ts +++ b/ui/src/pages/chat/route-loader.ts @@ -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 } diff --git a/ui/src/pages/chat/route.ts b/ui/src/pages/chat/route.ts index 0fec60533030..2814b84463db 100644 --- a/ui/src/pages/chat/route.ts +++ b/ui/src/pages/chat/route.ts @@ -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; + function renderAmbiguous(data: Extract) { return html`
@@ -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, + right: ReturnType, +): 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) { diff --git a/ui/src/pages/chat/workboard-card-chip.runtime.test.ts b/ui/src/pages/chat/workboard-card-chip.runtime.test.ts index a711985242bc..7472bfe8560f 100644 --- a/ui/src/pages/chat/workboard-card-chip.runtime.test.ts +++ b/ui/src/pages/chat/workboard-card-chip.runtime.test.ts @@ -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)); }); }); diff --git a/ui/src/pages/chat/workboard-card-chip.runtime.ts b/ui/src/pages/chat/workboard-card-chip.runtime.ts index 7e52ff0d9269..077653d6f8e4 100644 --- a/ui/src/pages/chat/workboard-card-chip.runtime.ts +++ b/ui/src/pages/chat/workboard-card-chip.runtime.ts @@ -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}`); diff --git a/ui/src/pages/workboard/workboard-card-dashboard.test.ts b/ui/src/pages/workboard/workboard-card-dashboard.test.ts index 54e8c44fe2ff..484284226151 100644 --- a/ui/src/pages/workboard/workboard-card-dashboard.test.ts +++ b/ui/src/pages/workboard/workboard-card-dashboard.test.ts @@ -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(".workboard-card-dashboard__toggle")?.click(); await element.updateComplete; await board.updateComplete; - expect(board.ticketRefreshEnabled).toBe(false); + expect(board.active).toBe(false); element.querySelector(".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 () => { diff --git a/ui/src/pages/workboard/workboard-card-dashboard.ts b/ui/src/pages/workboard/workboard-card-dashboard.ts index 93acffa48225..07a240fe0a2d 100644 --- a/ui/src/pages/workboard/workboard-card-dashboard.ts +++ b/ui/src/pages/workboard/workboard-card-dashboard.ts @@ -149,6 +149,7 @@ class WorkboardCardDashboard extends OpenClawLightDomElement { ${hasBoard && provider && boardSnapshot && callbacks ? html` @@ -157,7 +158,6 @@ class WorkboardCardDashboard extends OpenClawLightDomElement { .sessions=${[]} .canMutate=${this.canMutate} .canGrant=${this.canGrant} - .ticketRefreshEnabled=${this.expanded} > ` : html`

${t("workboard.dashboardEmpty")}

`} diff --git a/ui/src/styles/chat/board.css b/ui/src/styles/chat/board.css index 1c1d566e5e42..dacbf301d014 100644 --- a/ui/src/styles/chat/board.css +++ b/ui/src/styles/chat/board.css @@ -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; } diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 630c5133cb70..56ef5d48458c 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -1070,6 +1070,7 @@ function installControlUiMockGateway( "label", "category", "icon", + "boardFace", "pinned", "unread", "toolOverrides",