fix(ui): bound dashboard MCP App lifecycle (#111691)

This commit is contained in:
Jason (Json)
2026-07-20 00:38:04 -06:00
committed by GitHub
parent 6304a47762
commit 8c1ea03da0
12 changed files with 1158 additions and 178 deletions
@@ -0,0 +1,78 @@
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../../i18n/index.ts";
import { BOARD_GRID_GAP, BOARD_GRID_ROW_HEIGHT } from "../../lib/board/grid.ts";
import type { BoardWidgetAppViewState, BoardViewWidget } from "../../lib/board/view-types.ts";
const GRANT_NOTICE_HEIGHT_PX = 112;
type BoardMcpAppContentOptions = {
accessNotice: TemplateResult | typeof nothing;
appView?: BoardWidgetAppViewState;
busy: boolean;
loading: boolean;
nearVisible: boolean;
rectHeight: number;
sessionKey: string;
widget: BoardViewWidget;
expired: () => void;
remove: () => void;
retry: () => void;
};
export function renderBoardMcpAppContent(options: BoardMcpAppContentOptions): TemplateResult {
const { appView, widget } = options;
const noticeHeight =
widget.grantState === "pending" || widget.grantState === "rejected"
? GRANT_NOTICE_HEIGHT_PX
: 0;
const height = Math.max(
160,
options.rectHeight * BOARD_GRID_ROW_HEIGHT +
Math.max(0, options.rectHeight - 1) * BOARD_GRID_GAP -
38 -
noticeHeight,
);
const ready =
appView?.status === "ready" && appView.expiresAtMs > Date.now() ? appView : undefined;
const loading = html`<div class="board-widget__app-loading" data-test-id="board-mcp-app-loading">
${t("board.widget.appLoading")}
</div>`;
const view =
!options.nearVisible || !appView
? loading
: appView.status === "stale"
? html`<div class="board-widget__stale" data-test-id="board-mcp-app-stale">
<strong>${t("board.widget.appStaleTitle")}</strong>
<span>${t("board.widget.appStaleDetail")}</span>
<div class="board-widget__grant-actions">
<button
class="btn btn--small btn--primary"
type="button"
?disabled=${options.loading}
@click=${options.retry}
>
${t("board.widget.retry")}
</button>
<button
class="btn btn--small"
type="button"
?disabled=${options.busy}
@click=${options.remove}
>
${t("board.widget.remove")}
</button>
</div>
</div>`
: ready
? html`<mcp-app-view
class="board-widget__mcp-app-view"
.sessionKey=${options.sessionKey}
.viewId=${ready.viewId}
.height=${height}
.fixedHeight=${true}
.title=${widget.title || widget.name}
@openclaw-mcp-app-view-expired=${options.expired}
></mcp-app-view>`
: loading;
return html`<div class="board-widget__mcp-app">${options.accessNotice}${view}</div>`;
}
@@ -0,0 +1,351 @@
import type { BoardWidgetAppViewState, BoardViewWidget } from "../../lib/board/view-types.ts";
const REFRESH_LEAD_MS = 5_000;
type AppViewMode = "cached" | "refresh" | "expired";
function appViewKey(sessionKey: string, widget: BoardViewWidget): string {
return `${sessionKey}\0${widget.name}\0${widget.revision}\0${widget.instanceId ?? ""}\0${widget.grantState}`;
}
function clearTimer(timer: number | undefined): undefined {
if (timer !== undefined) {
window.clearTimeout(timer);
}
return undefined;
}
class NearViewportObserver {
private observer?: IntersectionObserver;
nearVisible = false;
target?: Element;
constructor(
private readonly marginPx: number,
private readonly visibilityChanged: () => void,
) {}
observe(target: Element): void {
if (target === this.target) {
return;
}
this.disconnect();
this.target = target;
this.setNearVisible(this.isNearViewport(target));
if (typeof IntersectionObserver === "undefined") {
return;
}
this.observer = new IntersectionObserver(
(entries) => {
const entry = entries.at(-1);
if (!entry || entry.target !== this.target) {
return;
}
this.setNearVisible(entry.isIntersecting || this.isNearViewport(entry.target));
},
{ rootMargin: `${this.marginPx}px 0px` },
);
this.observer.observe(target);
}
disconnect(): void {
this.observer?.disconnect();
this.observer = undefined;
this.target = undefined;
this.setNearVisible(false);
}
private setNearVisible(nearVisible: boolean): void {
if (nearVisible !== this.nearVisible) {
this.nearVisible = nearVisible;
this.visibilityChanged();
}
}
private isNearViewport(target: Element): boolean {
const bounds = target.getBoundingClientRect();
return bounds.bottom >= -this.marginPx && bounds.top <= window.innerHeight + this.marginPx;
}
}
type AppViewCallbacks = {
widgetAppView: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
refreshWidgetAppView: (name: string, revision: number) => Promise<BoardWidgetAppViewState>;
};
type LifecycleHost = {
connected: () => boolean;
requestUpdate: () => void;
sessionKey: () => string;
widget: () => BoardViewWidget | undefined;
};
export class BoardMcpAppLifecycle {
state?: BoardWidgetAppViewState;
loading = false;
private callbacks?: AppViewCallbacks;
private key = "";
private generation = 0;
private renewalTimer?: number;
private expiryTimer?: number;
private readonly visibility = new NearViewportObserver(600, () => this.visibilityChanged());
constructor(private readonly host: LifecycleHost) {}
get nearVisible(): boolean {
return this.visibility.nearVisible;
}
update(widget: BoardViewWidget | undefined, callbacks: AppViewCallbacks | undefined): void {
this.callbacks = callbacks;
if (!widget || widget.contentKind !== "mcp-app" || !callbacks) {
this.reset();
return;
}
const key = appViewKey(this.host.sessionKey(), widget);
if (key !== this.key) {
this.clearTimers();
this.generation += 1;
this.loading = false;
this.key = key;
this.state = undefined;
}
}
observe(target: Element | null, enabled: boolean): void {
if (!target || !enabled) {
this.visibility.disconnect();
return;
}
this.visibility.observe(target);
}
sync(): void {
const widget = this.host.widget();
const callbacks = this.callbacks;
if (!widget || widget.contentKind !== "mcp-app" || !callbacks) {
this.renewalTimer = clearTimer(this.renewalTimer);
return;
}
if (!this.nearVisible) {
if (!this.loading) {
this.renewalTimer = clearTimer(this.renewalTimer);
}
return;
}
if (!this.state && !this.loading) {
void this.load(widget, callbacks, "cached");
} else if (
this.state?.status === "ready" &&
!this.loading &&
this.renewalTimer === undefined &&
this.expiryTimer === undefined
) {
this.scheduleRenewal(widget, callbacks, this.state, false);
}
}
disconnect(): void {
this.visibility.disconnect();
this.reset();
this.callbacks = undefined;
}
retry(): void {
const widget = this.host.widget();
if (widget && this.callbacks) {
void this.load(widget, this.callbacks, "refresh");
}
}
expire(): void {
const widget = this.host.widget();
const callbacks = this.callbacks;
if (!widget || !callbacks) {
return;
}
const wasLoading = this.loading;
this.state = { status: "stale", error: "MCP App view expired" };
this.loading = false;
this.notify();
if (!wasLoading) {
void this.load(widget, callbacks, "expired");
}
}
private reset(): void {
this.clearTimers();
this.generation += 1;
this.key = "";
this.state = undefined;
this.loading = false;
}
private clearTimers(): void {
this.renewalTimer = clearTimer(this.renewalTimer);
this.expiryTimer = clearTimer(this.expiryTimer);
}
private visibilityChanged(): void {
queueMicrotask(() => {
if (this.host.connected()) {
this.notify();
}
});
if (!this.nearVisible && !this.loading) {
this.renewalTimer = clearTimer(this.renewalTimer);
}
}
private async load(
widget: BoardViewWidget,
callbacks: AppViewCallbacks,
mode: AppViewMode,
): Promise<void> {
if (this.loading || !this.nearVisible) {
return;
}
const key = appViewKey(this.host.sessionKey(), widget);
if (key !== this.key) {
return;
}
const generation = ++this.generation;
const isCurrent = () => {
const current = this.host.widget();
return (
this.host.connected() &&
generation === this.generation &&
this.key === key &&
current?.contentKind === "mcp-app" &&
appViewKey(this.host.sessionKey(), current) === key
);
};
this.clearTimers();
this.loading = true;
const previousLease = mode === "refresh" && this.state?.status === "ready" ? this.state : null;
if (mode === "expired") {
this.state = undefined;
}
this.notify();
if (previousLease) {
this.expiryTimer = window.setTimeout(
() => {
this.expiryTimer = undefined;
if (isCurrent()) {
this.state = { status: "stale", error: "MCP App lease expired while renewing" };
this.loading = false;
this.notify();
}
},
Math.max(0, previousLease.expiresAtMs - Date.now()),
);
}
try {
const appView = await (mode === "cached"
? callbacks.widgetAppView(widget.name, widget.revision)
: callbacks.refreshWidgetAppView(widget.name, widget.revision));
if (!isCurrent()) {
return;
}
if (appView.status === "stale" && previousLease && previousLease.expiresAtMs > Date.now()) {
this.loading = false;
this.notify();
return;
}
this.clearTimers();
this.state = appView;
this.loading = false;
this.scheduleRenewal(widget, callbacks, appView, mode !== "cached");
this.notify();
} catch (error) {
if (!isCurrent()) {
return;
}
if (previousLease && previousLease.expiresAtMs > Date.now()) {
this.loading = false;
this.notify();
return;
}
this.clearTimers();
this.state = {
status: "stale",
error: error instanceof Error ? error.message : String(error),
};
this.loading = false;
this.notify();
}
}
private scheduleExpiry(widget: BoardViewWidget, appView: BoardWidgetAppViewState): void {
if (appView.status !== "ready") {
return;
}
this.expiryTimer = clearTimer(this.expiryTimer);
const key = this.key;
this.expiryTimer = window.setTimeout(
() => {
this.expiryTimer = undefined;
const current = this.host.widget();
const state = this.state;
if (
this.host.connected() &&
this.key === key &&
current?.name === widget.name &&
current.revision === widget.revision &&
state?.status === "ready" &&
state.viewId === appView.viewId &&
state.expiresAtMs === appView.expiresAtMs
) {
this.state = { status: "stale", error: "MCP App lease expired" };
this.notify();
}
},
Math.max(0, appView.expiresAtMs - Date.now()),
);
}
private scheduleRenewal(
widget: BoardViewWidget,
callbacks: AppViewCallbacks,
appView: BoardWidgetAppViewState,
renewed: boolean,
): void {
this.renewalTimer = clearTimer(this.renewalTimer);
if (appView.status !== "ready") {
return;
}
const key = this.key;
const delayMs = appView.expiresAtMs - Date.now() - REFRESH_LEAD_MS;
if (!this.nearVisible) {
if (renewed && delayMs <= 0) {
this.scheduleExpiry(widget, appView);
}
return;
}
if (delayMs <= 0) {
if (renewed) {
this.scheduleExpiry(widget, appView);
} else {
void this.load(widget, callbacks, "refresh");
}
return;
}
this.renewalTimer = window.setTimeout(() => {
this.renewalTimer = undefined;
const current = this.host.widget();
if (
this.host.connected() &&
this.nearVisible &&
this.key === key &&
current?.name === widget.name &&
current.revision === widget.revision
) {
void this.load(current, callbacks, "refresh");
}
}, delayMs);
}
private notify(): void {
this.host.requestUpdate();
}
}
+2 -2
View File
@@ -502,7 +502,7 @@ describe("openclaw-board-view", () => {
expect(refreshWidgetAppView).toHaveBeenCalledTimes(1);
});
it("does not loop automatic renewal for a lease already inside the refresh window", async () => {
it("refreshes a near-expiry lease only once", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const widgetAppView = vi.fn(async () => ({
@@ -522,7 +522,7 @@ describe("openclaw-board-view", () => {
});
await vi.advanceTimersByTimeAsync(60_000);
expect(refreshWidgetAppView).not.toHaveBeenCalled();
expect(refreshWidgetAppView).toHaveBeenCalledOnce();
view.remove();
});
@@ -0,0 +1,434 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BoardWidgetAppViewState, BoardViewWidget } from "../../lib/board/view-types.ts";
import type { BoardWidgetCellCallbacks } from "./board-widget-cell.ts";
import "./board-widget-cell.ts";
class TestMcpAppView extends HTMLElement {
sessionKey = "";
viewId = "";
height = 0;
fixedHeight = false;
override title = "";
}
if (!customElements.get("mcp-app-view")) {
customElements.define("mcp-app-view", TestMcpAppView);
}
type BoardWidgetCell = HTMLElementTagNameMap["openclaw-board-widget-cell"];
function widget(overrides: Partial<BoardViewWidget> = {}): BoardViewWidget {
return {
name: "alpha",
tabId: "main",
title: "Alpha app",
contentKind: "mcp-app",
sizeW: 6,
sizeH: 4,
position: 0,
grantState: "none",
revision: 1,
instanceId: "alpha-instance",
...overrides,
} as BoardViewWidget;
}
function callbacks(overrides: Partial<BoardWidgetCellCallbacks> = {}): BoardWidgetCellCallbacks {
const noAction = vi.fn(async () => undefined);
return {
grant: noAction,
movePointerDown: vi.fn(),
resizePointerDown: vi.fn(),
moveToTab: noAction,
resizeTo: noAction,
remove: noAction,
nudge: noAction,
focus: vi.fn(),
focusChanged: vi.fn(),
frameLoadFailed: noAction,
widgetAppView: vi.fn(async () => ({
status: "ready" as const,
viewId: "initial-view",
expiresAtMs: Date.now() + 60_000,
})),
refreshWidgetAppView: vi.fn(async () => ({
status: "ready" as const,
viewId: "renewed-view",
expiresAtMs: Date.now() + 60_000,
})),
...overrides,
};
}
async function mount(
currentWidget: BoardViewWidget,
currentCallbacks: BoardWidgetCellCallbacks,
): Promise<BoardWidgetCell> {
const cell = document.createElement("openclaw-board-widget-cell");
cell.widget = currentWidget;
cell.rect = { name: currentWidget.name, x: 0, y: 0, w: 6, h: currentWidget.sizeH };
cell.sessionKey = "agent:main:test";
cell.callbacks = currentCallbacks;
document.body.append(cell);
await settle(cell);
return cell;
}
async function settle(cell: BoardWidgetCell): Promise<void> {
await Promise.resolve();
await cell.updateComplete;
await Promise.resolve();
await cell.updateComplete;
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
function stubVisibility(visible: (index: number) => boolean): {
disconnect: ReturnType<typeof vi.fn>;
observed: () => number;
} {
let observed = 0;
const disconnect = vi.fn();
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(private readonly callback: IntersectionObserverCallback) {}
observe(target: Element) {
const isIntersecting = visible(observed);
observed += 1;
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({
bottom: isIntersecting ? 200 : 5_200,
top: isIntersecting ? 0 : 5_000,
} as DOMRect);
this.callback([{ isIntersecting, target } as IntersectionObserverEntry], this as never);
}
disconnect = disconnect;
unobserve() {}
takeRecords() {
return [];
}
},
);
return { disconnect, observed: () => observed };
}
afterEach(() => {
document.body.replaceChildren();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("board MCP App cell lifecycle", () => {
it("uses the board height as fixed AppBridge host context", async () => {
const cell = await mount(
widget({ grantState: "pending" }),
callbacks({
widgetAppView: vi.fn(async () => ({
status: "ready" as const,
viewId: "fixed-view",
expiresAtMs: Date.now() + 60_000,
})),
}),
);
await vi.waitFor(() => expect(cell.querySelector("mcp-app-view")).not.toBeNull());
expect(cell.querySelector("mcp-app-view") as TestMcpAppView).toMatchObject({
fixedHeight: true,
height: 160,
sessionKey: "agent:main:test",
viewId: "fixed-view",
});
expect(cell.querySelector('[data-test-id="board-pending"]')).not.toBeNull();
cell.widget = widget({ grantState: "granted" });
await settle(cell);
await vi.waitFor(() =>
expect((cell.querySelector("mcp-app-view") as TestMcpAppView | null)?.height).toBe(222),
);
expect(cell.querySelector('[data-test-id="board-pending"]')).toBeNull();
});
it("treats the bridge expiry event as authoritative", async () => {
const refreshWidgetAppView = vi.fn(async () => ({
status: "stale" as const,
error: "lease rejected",
}));
const cell = await mount(widget(), callbacks({ refreshWidgetAppView }));
await vi.waitFor(() => expect(cell.querySelector("mcp-app-view")).not.toBeNull());
cell
.querySelector("mcp-app-view")
?.dispatchEvent(
new CustomEvent("openclaw-mcp-app-view-expired", { bubbles: true, composed: true }),
);
await settle(cell);
expect(cell.querySelector("mcp-app-view")).toBeNull();
expect(cell.querySelector('[data-test-id="board-mcp-app-stale"]')).not.toBeNull();
});
it("keeps a short renewed lease until expiry without another refresh loop", async () => {
vi.useFakeTimers({ now: 1_000 });
const refreshWidgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "short-renewed-view",
expiresAtMs: 5_000,
}));
const cell = await mount(
widget(),
callbacks({
widgetAppView: vi.fn(async () => ({
status: "ready" as const,
viewId: "near-expiry-view",
expiresAtMs: 5_000,
})),
refreshWidgetAppView,
}),
);
await vi.advanceTimersByTimeAsync(0);
await settle(cell);
expect(refreshWidgetAppView).toHaveBeenCalledOnce();
expect(cell.querySelector("mcp-app-view")).not.toBeNull();
await vi.advanceTimersByTimeAsync(4_000);
await settle(cell);
expect(cell.querySelector('[data-test-id="board-mcp-app-stale"]')).not.toBeNull();
expect(refreshWidgetAppView).toHaveBeenCalledOnce();
});
it("cleans up visibility when an MCP App cell becomes HTML", async () => {
const visibility = stubVisibility(() => true);
const widgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "converted-view",
expiresAtMs: Date.now() + 60_000,
}));
const currentCallbacks = callbacks({ widgetAppView });
const cell = await mount(widget({ contentKind: "html" }), currentCallbacks);
cell.widget = widget();
await settle(cell);
await vi.waitFor(() => expect(widgetAppView).toHaveBeenCalledOnce());
expect(visibility.observed()).toBe(1);
cell.widget = widget({ contentKind: "html" });
await settle(cell);
expect(visibility.disconnect).toHaveBeenCalledOnce();
expect(widgetAppView).toHaveBeenCalledOnce();
});
it("recovers when a slow renewal finishes after the expiry watchdog", async () => {
vi.useFakeTimers({ now: 10_000 });
const remint = deferred<BoardWidgetAppViewState>();
const refreshWidgetAppView = vi.fn(() => remint.promise);
const cell = await mount(
widget(),
callbacks({
widgetAppView: vi.fn(async () => ({
status: "ready" as const,
viewId: "short-view",
expiresAtMs: 11_000,
})),
refreshWidgetAppView,
}),
);
await vi.advanceTimersByTimeAsync(0);
await settle(cell);
expect(refreshWidgetAppView).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1_000);
await settle(cell);
expect(cell.querySelector('[data-test-id="board-mcp-app-stale"]')).not.toBeNull();
remint.resolve({ status: "ready" as const, viewId: "late-view", expiresAtMs: 30_000 });
await settle(cell);
expect((cell.querySelector("mcp-app-view") as TestMcpAppView | null)?.viewId).toBe("late-view");
});
it("keeps a valid lease mounted when proactive renewal fails", async () => {
vi.useFakeTimers({ now: 10_000 });
const cell = await mount(
widget(),
callbacks({
widgetAppView: vi.fn(async () => ({
status: "ready" as const,
viewId: "still-valid-view",
expiresAtMs: 20_000,
})),
refreshWidgetAppView: vi.fn(async () => ({
status: "stale" as const,
error: "temporary gateway failure",
})),
}),
);
await vi.advanceTimersByTimeAsync(5_000);
await settle(cell);
expect((cell.querySelector("mcp-app-view") as TestMcpAppView | null)?.viewId).toBe(
"still-valid-view",
);
await vi.advanceTimersByTimeAsync(5_000);
await settle(cell);
expect(cell.querySelector('[data-test-id="board-mcp-app-stale"]')).not.toBeNull();
});
it("keeps the expiry watchdog when a renewing app moves offscreen", async () => {
vi.useFakeTimers({ now: 10_000 });
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 remint = deferred<BoardWidgetAppViewState>();
const refreshWidgetAppView = vi.fn(() => remint.promise);
const cell = await mount(
widget(),
callbacks({
widgetAppView: vi.fn(async () => ({
status: "ready" as const,
viewId: "short-view",
expiresAtMs: 11_000,
})),
refreshWidgetAppView,
}),
);
await vi.advanceTimersByTimeAsync(0);
await settle(cell);
visible = false;
emitVisibility();
await settle(cell);
await vi.advanceTimersByTimeAsync(1_000);
visible = true;
emitVisibility();
await settle(cell);
expect(refreshWidgetAppView).toHaveBeenCalledOnce();
expect(cell.querySelector('[data-test-id="board-mcp-app-stale"]')).not.toBeNull();
});
it("does not remint a short renewed lease when it returns onscreen", async () => {
vi.useFakeTimers({ now: 10_000 });
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 remint = deferred<BoardWidgetAppViewState>();
const refreshWidgetAppView = vi.fn(() => remint.promise);
const cell = await mount(
widget(),
callbacks({
widgetAppView: vi.fn(async () => ({
status: "ready" as const,
viewId: "first-view",
expiresAtMs: 20_000,
})),
refreshWidgetAppView,
}),
);
await vi.advanceTimersByTimeAsync(5_000);
visible = false;
emitVisibility();
remint.resolve({ status: "ready" as const, viewId: "short-renewed", expiresAtMs: 18_000 });
await settle(cell);
visible = true;
emitVisibility();
await settle(cell);
expect(refreshWidgetAppView).toHaveBeenCalledOnce();
expect((cell.querySelector("mcp-app-view") as TestMcpAppView | null)?.viewId).toBe(
"short-renewed",
);
await vi.advanceTimersByTimeAsync(3_000);
await settle(cell);
expect(cell.querySelector('[data-test-id="board-mcp-app-stale"]')).not.toBeNull();
expect(refreshWidgetAppView).toHaveBeenCalledOnce();
});
it("materializes only near-viewport cells on a full board", async () => {
const visibility = stubVisibility((index) => index < 2);
const widgetAppView = vi.fn(async (name: string) => ({
status: "ready" as const,
viewId: `view-${name}`,
expiresAtMs: Date.now() + 60_000,
}));
const currentCallbacks = callbacks({ widgetAppView });
for (let index = 0; index < 48; index += 1) {
await mount(
widget({ name: `app-${index}`, instanceId: `instance-${index}` }),
currentCallbacks,
);
}
await vi.waitFor(() => expect(visibility.observed()).toBe(48));
await vi.waitFor(() => expect(widgetAppView).toHaveBeenCalledTimes(2));
});
it("restarts materialization when a disconnected cell is reattached", async () => {
const widgetAppView = vi.fn(async () => ({
status: "ready" as const,
viewId: "reattached-view",
expiresAtMs: Date.now() + 60_000,
}));
const cell = await mount(widget(), callbacks({ widgetAppView }));
await vi.waitFor(() => expect(widgetAppView).toHaveBeenCalledOnce());
cell.remove();
await Promise.resolve();
document.body.append(cell);
await settle(cell);
await vi.waitFor(() => expect(widgetAppView).toHaveBeenCalledTimes(2));
expect(cell.querySelector("mcp-app-view")).not.toBeNull();
});
});
+46 -168
View File
@@ -14,6 +14,9 @@ import type {
} from "../../lib/board/view-types.ts";
import { getBuiltinWidgetRenderer } from "../../lib/board/widgets/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { renderBoardMcpAppContent } from "./board-mcp-app-content.ts";
import { BoardMcpAppLifecycle } from "./board-mcp-app-lifecycle.ts";
import { renderBoardWidgetFrame } from "./board-widget-frame.ts";
import "../web-awesome.ts";
const BOARD_SIZE_PRESETS = {
@@ -23,7 +26,6 @@ const BOARD_SIZE_PRESETS = {
xl: { w: 12, h: 8 },
} as const;
const MAX_FRAME_REFRESH_ATTEMPTS = 3;
const APP_VIEW_REFRESH_LEAD_MS = 5_000;
const loadMcpAppView = () => import("../mcp-app-view-registration.ts");
export type BoardWidgetCellCallbacks = {
@@ -58,23 +60,20 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@state() private actionError = "";
@state() private actionPending = false;
@state() private frameError = "";
@state() private appViewState?: BoardWidgetAppViewState;
@state() private appViewLoading = false;
private frameFailureKey = "";
private frameRefreshAttempts = 0;
private frameProbeGeneration = 0;
private lastFrameUrl = "";
private appViewKey = "";
private appViewGeneration = 0;
private appViewRenewalTimer?: number;
private readonly appView = new BoardMcpAppLifecycle({
connected: () => this.isConnected,
requestUpdate: () => this.requestUpdate(),
sessionKey: () => this.sessionKey,
widget: () => this.widget,
});
override connectedCallback(): void {
super.connectedCallback();
const widget = this.widget;
if (widget?.contentKind === "mcp-app" && this.callbacks && !this.appViewKey) {
this.appViewKey = this.currentAppViewKey(widget);
void this.loadAppView(widget, this.callbacks, false);
}
this.requestUpdate();
}
override willUpdate(changed: PropertyValues<this>): void {
@@ -95,107 +94,30 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
}
}
const widget = this.widget;
if (!this.isConnected || !widget || widget.contentKind !== "mcp-app" || !this.callbacks) {
this.cancelAppViewRenewal();
this.appViewGeneration += 1;
this.appViewKey = "";
this.appViewState = undefined;
this.appViewLoading = false;
this.appView.update(this.widget, this.callbacks);
}
override updated(): void {
if (!this.isConnected) {
this.appView.observe(null, false);
return;
}
const key = this.currentAppViewKey(widget);
if (key !== this.appViewKey) {
this.cancelAppViewRenewal();
this.appViewGeneration += 1;
this.appViewLoading = false;
this.appViewKey = key;
void this.loadAppView(widget, this.callbacks, false);
}
this.appView.observe(
this.querySelector(".board-widget"),
this.widget?.contentKind === "mcp-app",
);
queueMicrotask(() => {
if (this.isConnected) {
this.appView.sync();
}
});
}
override disconnectedCallback(): void {
this.cancelAppViewRenewal();
this.appViewGeneration += 1;
this.appViewKey = "";
this.appViewState = undefined;
this.appViewLoading = false;
this.appView.disconnect();
super.disconnectedCallback();
}
private async loadAppView(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
force: boolean,
): Promise<void> {
if (this.appViewLoading) {
return;
}
const key = this.currentAppViewKey(widget);
const generation = ++this.appViewGeneration;
this.cancelAppViewRenewal();
this.appViewLoading = true;
if (force) {
this.appViewState = undefined;
}
const appView = await (force
? callbacks.refreshWidgetAppView(widget.name, widget.revision)
: callbacks.widgetAppView(widget.name, widget.revision));
const current = this.widget;
if (
this.isConnected &&
generation === this.appViewGeneration &&
this.appViewKey === key &&
current?.name === widget.name &&
current.revision === widget.revision
) {
this.appViewState = appView;
this.appViewLoading = false;
this.scheduleAppViewRenewal(widget, callbacks, appView);
}
}
private currentAppViewKey(widget: BoardViewWidget): string {
return `${this.sessionKey}\0${widget.name}\0${widget.revision}\0${widget.instanceId ?? ""}\0${widget.grantState}`;
}
private cancelAppViewRenewal(): void {
if (this.appViewRenewalTimer !== undefined) {
window.clearTimeout(this.appViewRenewalTimer);
this.appViewRenewalTimer = undefined;
}
}
private scheduleAppViewRenewal(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
appView: BoardWidgetAppViewState,
): void {
this.cancelAppViewRenewal();
if (appView.status !== "ready") {
return;
}
const key = this.appViewKey;
const delayMs = appView.expiresAtMs - Date.now() - APP_VIEW_REFRESH_LEAD_MS;
if (delayMs <= 0) {
// A near-expiry response or clock skew must not create a tight remint loop.
// The bridge's structured expiry event remains the renewal fallback.
return;
}
this.appViewRenewalTimer = window.setTimeout(() => {
this.appViewRenewalTimer = undefined;
const current = this.widget;
if (
this.isConnected &&
this.appViewKey === key &&
current?.name === widget.name &&
current.revision === widget.revision
) {
void this.loadAppView(current, callbacks, true);
}
}, delayMs);
}
private resetFrameFailures(): void {
this.frameProbeGeneration += 1;
this.frameFailureKey = "";
@@ -423,53 +345,13 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): TemplateResult {
if (!this.widgetFrameUrl) {
throw new Error(t("board.widget.frameResolverMissing"));
}
const src = this.widgetFrameUrl(widget.name, widget.revision);
this.lastFrameUrl = src;
return html`
<iframe
class="board-widget__frame"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
title=${widget.title || widget.name}
src=${src}
@error=${() => this.refreshFailedFrame(widget, callbacks)}
@load=${(event: Event) => this.verifyFrameAuthorization(event, widget, callbacks)}
></iframe>
`;
}
private renderStaleMcpApp(
widget: BoardViewWidget,
callbacks: BoardWidgetCellCallbacks,
): TemplateResult {
return html`
<div class="board-widget__stale" data-test-id="board-mcp-app-stale">
<strong>${t("board.widget.appStaleTitle")}</strong>
<span>${t("board.widget.appStaleDetail")}</span>
<div class="board-widget__grant-actions">
<button
class="btn btn--small btn--primary"
type="button"
?disabled=${this.appViewLoading}
@click=${() => void this.loadAppView(widget, callbacks, true)}
>
${t("board.widget.retry")}
</button>
<button
class="btn btn--small"
type="button"
?disabled=${this.busy || this.actionPending}
@click=${() => void this.runAction(() => callbacks.remove(widget))}
>
${t("board.widget.remove")}
</button>
</div>
</div>
`;
return renderBoardWidgetFrame(
widget,
this.widgetFrameUrl,
(src) => (this.lastFrameUrl = src),
() => this.refreshFailedFrame(widget, callbacks),
(event) => this.verifyFrameAuthorization(event, widget, callbacks),
);
}
private renderMcpApp(
@@ -477,29 +359,25 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
callbacks: BoardWidgetCellCallbacks,
): TemplateResult {
void ensureCustomElementDefined("mcp-app-view", loadMcpAppView).catch(() => undefined);
const appView = this.appViewState;
const accessNotice =
widget.grantState === "pending"
? this.renderPending(widget, callbacks)
: widget.grantState === "rejected"
? this.renderRejected(widget, callbacks)
: nothing;
const view =
!appView || this.appViewLoading
? html`<div class="board-widget__app-loading" data-test-id="board-mcp-app-loading">
${t("board.widget.appLoading")}
</div>`
: appView.status === "stale"
? this.renderStaleMcpApp(widget, callbacks)
: html`<mcp-app-view
class="board-widget__mcp-app-view"
.sessionKey=${this.sessionKey}
.viewId=${appView.viewId}
.height=${Math.max(160, (this.rect?.h ?? 4) * 56 - 38)}
.title=${widget.title || widget.name}
@openclaw-mcp-app-view-expired=${() => void this.loadAppView(widget, callbacks, true)}
></mcp-app-view>`;
return html`<div class="board-widget__mcp-app">${accessNotice}${view}</div>`;
return renderBoardMcpAppContent({
accessNotice,
appView: this.appView.state,
busy: this.busy || this.actionPending,
loading: this.appView.loading,
nearVisible: this.appView.nearVisible,
rectHeight: this.rect?.h ?? 4,
sessionKey: this.sessionKey,
widget,
expired: () => this.appView.expire(),
remove: () => void this.runAction(() => callbacks.remove(widget)),
retry: () => this.appView.retry(),
});
}
private renderBody(widget: BoardViewWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
@@ -0,0 +1,29 @@
import { html, type TemplateResult } from "lit";
import { t } from "../../i18n/index.ts";
import type { BoardViewWidget, BoardWidgetFrameUrl } from "../../lib/board/view-types.ts";
export function renderBoardWidgetFrame(
widget: BoardViewWidget,
resolveFrameUrl: BoardWidgetFrameUrl | undefined,
resolved: (src: string) => void,
loadFailed: () => void,
loaded: (event: Event) => void,
): TemplateResult {
if (!resolveFrameUrl) {
throw new Error(t("board.widget.frameResolverMissing"));
}
const src = resolveFrameUrl(widget.name, widget.revision);
resolved(src);
return html`
<iframe
class="board-widget__frame"
sandbox="allow-scripts"
referrerpolicy="no-referrer"
loading="lazy"
title=${widget.title || widget.name}
src=${src}
@error=${loadFailed}
@load=${loaded}
></iframe>
`;
}
+13
View File
@@ -180,6 +180,7 @@ describe("mcp-app-view localization", () => {
content?: Array<{ type: string; text?: string }>;
structuredContent?: Record<string, unknown>;
}) => Promise<Record<string, never>>;
onsizechange?: (params: { height?: number }) => void;
setHostContext: ReturnType<typeof vi.fn>;
teardownResource: ReturnType<typeof vi.fn>;
emit(type: string): void;
@@ -361,6 +362,18 @@ describe("mcp-app-view localization", () => {
expect.objectContaining({ containerDimensions: { width: 720, height: 480 } }),
);
bridge.onsizechange?.({ height: 900 });
expect(view.shadowRoot?.querySelector("iframe")?.style.height).toBe("900px");
view.fixedHeight = true;
await view.updateComplete;
expect(view.shadowRoot?.querySelector("iframe")?.style.height).toBe("480px");
bridge.onsizechange?.({ height: 900 });
expect(view.shadowRoot?.querySelector("iframe")?.style.height).toBe("480px");
expect(bridge.setHostContext).toHaveBeenLastCalledWith(
expect.objectContaining({ containerDimensions: { width: 720, height: 480 } }),
);
view.remove();
await expect.poll(() => disconnect).toHaveBeenCalledOnce();
expect(unsubscribe).toHaveBeenCalledOnce();
+6 -2
View File
@@ -138,6 +138,7 @@ export class McpAppView extends LitElement {
@property({ attribute: false }) sessionKey = "";
@property({ attribute: false }) viewId = "";
@property({ type: Number }) height = 600;
@property({ type: Boolean }) fixedHeight = false;
@property() override title = "";
@state() private error: string | null = null;
@@ -157,7 +158,10 @@ export class McpAppView extends LitElement {
override updated(changedProperties: PropertyValues<this>) {
if (this.resources) {
this.resources.iframe.title = this.title || t("mcpApp.title");
if (changedProperties.has("height")) {
if (
changedProperties.has("height") ||
(changedProperties.has("fixedHeight") && this.fixedHeight)
) {
this.resources.frameHeight = this.height;
this.resources.iframe.style.height = `${this.height}px`;
this.resources.bridge?.setHostContext(hostContext(this.mount.value, this.height));
@@ -389,7 +393,7 @@ export class McpAppView extends LitElement {
(await this.request("mcp.app.readResource", { uri: params.uri })) as never;
bridge.onopenlink = async ({ url }) => (openExternalUrlSafe(url) ? {} : { isError: true });
bridge.onsizechange = ({ height }) => {
if (height !== undefined) {
if (height !== undefined && !this.fixedHeight) {
const nextHeight = Math.min(1200, Math.max(160, Math.round(height)));
resources.frameHeight = nextHeight;
iframe.style.height = `${nextHeight}px`;
+191
View File
@@ -0,0 +1,191 @@
// Dashboard MCP App E2E covers the real Control UI, sandbox proxy, and mocked Gateway lease flow.
import type { Server as HttpServer } from "node:http";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createMcpAppSandboxHttpServer } from "../../../src/gateway/mcp-app-sandbox-http.js";
import { getFreeGatewayPort } from "../../../src/gateway/test-helpers.e2e.js";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const sessionKey = "agent:main:board-mcp-app";
let browser: Browser;
let controlUi: ControlUiE2eServer;
let sandboxServer: HttpServer;
let sandboxPort: number;
const contexts = new Set<BrowserContext>();
function widget(index: number) {
return {
name: `app-${index}`,
tabId: "main",
title: `App ${index}`,
contentKind: "mcp-app",
sizeW: 12,
sizeH: 3,
position: index,
grantState: "none",
revision: 1,
instanceId: `instance-${index}`,
} as const;
}
function boardSnapshot(count: number) {
return {
sessionKey,
revision: 1,
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" }],
widgets: Array.from({ length: count }, (_, index) => widget(index)),
};
}
async function openDashboard(page: Page): Promise<void> {
await page.addInitScript((key) => {
const settingsKey = "openclaw.control.settings.v1:ws://127.0.0.1:18789";
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
string,
unknown
>;
settings.boardSessionViews = { [key]: { face: "dashboard", activeTabId: "main" } };
localStorage.setItem(settingsKey, JSON.stringify(settings));
}, sessionKey);
await page.goto(`${controlUi.baseUrl}chat`);
await page.locator(".board-session-surface").waitFor();
}
function appViewPayload() {
return {
sandboxUrl: "/mcp-app-sandbox",
sandboxPort,
html: "<!doctype html><output>Dashboard app</output>",
toolInput: {},
toolResult: { content: [{ type: "text", text: "ready" }] },
messageSupported: false,
updateModelContextSupported: false,
};
}
async function waitForMountedApp(page: Page): Promise<void> {
await page.waitForFunction(
() => Boolean(document.querySelector("mcp-app-view")?.shadowRoot?.querySelector("iframe")),
undefined,
{ timeout: 15_000 },
);
}
describeControlUiE2e("Control UI dashboard MCP Apps", () => {
beforeAll(async () => {
controlUi = await startControlUiE2eServer();
sandboxPort = await getFreeGatewayPort();
sandboxServer = createMcpAppSandboxHttpServer();
await new Promise<void>((resolve) => {
sandboxServer.listen(sandboxPort, "127.0.0.1", resolve);
});
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
}, 120_000);
afterAll(async () => {
for (const context of contexts) {
await context.close();
}
await browser?.close();
if (sandboxServer) {
await new Promise<void>((resolve) => {
sandboxServer.close(() => resolve());
});
}
await controlUi?.close();
});
it("renders a pinned app and proactively renews its board lease", async () => {
const context = await browser.newContext({ permissions: ["local-network-access"] });
contexts.add(context);
const page = await context.newPage();
const gateway = await installMockGateway(page, {
sessionKey,
featureMethods: [
"board.get",
"board.widget.appView",
"chat.history",
"chat.metadata",
"chat.startup",
"mcp.app.view",
],
methodResponses: {
"board.get": boardSnapshot(1),
"board.widget.appView": {
sequence: [
{ viewId: "short-view", expiresAtMs: Date.now() + 7_000 },
{ viewId: "renewed-view", expiresAtMs: Date.now() + 3_600_000 },
],
},
"mcp.app.view": appViewPayload(),
},
});
await openDashboard(page);
await expect
.poll(async () => (await gateway.getRequests("board.widget.appView")).length, {
timeout: 10_000,
})
.toBeGreaterThan(0);
await waitForMountedApp(page);
await expect
.poll(async () => (await gateway.getRequests("board.widget.appView")).length, {
timeout: 15_000,
})
.toBe(2);
expect((await gateway.getRequests("board.widget.appView"))[0]?.params).toEqual({
sessionKey,
name: "app-0",
revision: 1,
instanceId: "instance-0",
});
});
it("does not eagerly mint leases for all 48 offscreen cells", 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.widget.appView",
"chat.history",
"chat.metadata",
"chat.startup",
"mcp.app.view",
],
methodResponses: {
"board.get": boardSnapshot(48),
"board.widget.appView": { viewId: "shared-view", expiresAtMs: Date.now() + 3_600_000 },
"mcp.app.view": appViewPayload(),
},
});
await openDashboard(page);
await expect
.poll(async () => (await gateway.getRequests("board.widget.appView")).length, {
timeout: 10_000,
})
.toBeGreaterThan(0);
await waitForMountedApp(page);
await page.waitForTimeout(500);
const requests = await gateway.getRequests("board.widget.appView");
expect(requests.length).toBeLessThan(48);
});
});
+1 -5
View File
@@ -37,11 +37,7 @@ export class BoardMcpAppViewCache {
}
const cached = this.entries.get(key);
if (cached) {
const resolved = await cached;
if (resolved.status !== "ready" || resolved.expiresAtMs > Date.now() + 5_000) {
return resolved;
}
this.entries.delete(key);
return await cached;
}
const pending = request()
.then<BoardWidgetAppViewState>((result) => ({ status: "ready", ...result }))
+5 -1
View File
@@ -894,7 +894,7 @@ describe("board providers", () => {
});
});
it("caches MCP App leases and re-mints them as expiry approaches", async () => {
it("deduplicates MCP App leases until an explicit refresh", async () => {
mockLocation.search = "";
let now = 0;
vi.spyOn(Date, "now").mockImplementation(() => now);
@@ -948,6 +948,10 @@ describe("board providers", () => {
});
now = 6_000;
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
status: "ready",
viewId: "mcp-app-1",
});
await expect(provider.refreshWidgetAppView("server-app", 1)).resolves.toMatchObject({
status: "ready",
viewId: "mcp-app-2",
});
+2
View File
@@ -405,7 +405,9 @@ openclaw-board-widget-cell {
.board-widget__mcp-app > .board-widget__grant {
border-bottom: 1px solid var(--board-line);
height: auto;
max-height: 112px;
min-height: 0;
overflow: auto;
padding: 12px;
}