mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(ui): bound dashboard MCP App lifecycle (#111691)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user