mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat(dashboard): harden transcript MCP App pinning (#111748)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Control UI E2E covers the real session-dashboard provider and transcript bridge.
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { GATEWAY_SERVER_CAPS } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
@@ -72,6 +72,39 @@ const pinnedBoardSnapshot = {
|
||||
},
|
||||
],
|
||||
};
|
||||
const pinnedMcpAppBoardSnapshot = {
|
||||
...boardSnapshot,
|
||||
revision: 2,
|
||||
widgets: [
|
||||
...boardSnapshot.widgets,
|
||||
{
|
||||
name: "mcp-app-28b65635ecaa78ac",
|
||||
tabId: "main",
|
||||
title: "Demo App",
|
||||
contentKind: "mcp-app",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 1,
|
||||
grantState: "pending",
|
||||
revision: 1,
|
||||
instanceId: "instance-pinned-app",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function showDashboard(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);
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI session dashboard stitch", () => {
|
||||
beforeAll(async () => {
|
||||
@@ -124,17 +157,7 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
|
||||
"board.widget.put": pinnedBoardSnapshot,
|
||||
},
|
||||
});
|
||||
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 showDashboard(page);
|
||||
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await expect
|
||||
@@ -185,4 +208,73 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
|
||||
.toBe(true);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it("pins an inline MCP App using only its session-bound view identity", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
sessionKey,
|
||||
featureCapabilities: [],
|
||||
featureMethods: [
|
||||
"board.get",
|
||||
"board.widget.appView",
|
||||
"board.widget.put",
|
||||
"chat.metadata",
|
||||
"chat.startup",
|
||||
],
|
||||
historyMessages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "canvas",
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
title: "Demo App",
|
||||
viewId: "outer-view-must-not-be-pinned",
|
||||
mcpApp: {
|
||||
viewId: "view-session-bound",
|
||||
serverName: "forbidden-server",
|
||||
toolName: "forbidden-tool",
|
||||
uiResourceUri: "ui://forbidden/app.html",
|
||||
originSessionKey: sessionKey,
|
||||
toolCallId: "forbidden-call",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
methodResponses: {
|
||||
"board.get": boardSnapshot,
|
||||
"board.widget.appView": {
|
||||
viewId: "view-pinned-lease",
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
},
|
||||
"board.widget.put": pinnedMcpAppBoardSnapshot,
|
||||
},
|
||||
});
|
||||
await showDashboard(page);
|
||||
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.locator(".board-session-surface").waitFor();
|
||||
const preview = page.locator('.chat-tool-card__preview[data-kind="canvas"]');
|
||||
await preview.hover();
|
||||
await preview.getByRole("button", { name: "Pin to dashboard" }).click();
|
||||
|
||||
await expect.poll(async () => (await gateway.getRequests("board.widget.put")).length).toBe(1);
|
||||
expect((await gateway.getRequests("board.widget.put"))[0]?.params).toEqual({
|
||||
sessionKey,
|
||||
name: "mcp-app-28b65635ecaa78ac",
|
||||
title: "Demo App",
|
||||
content: { kind: "mcp-app", viewId: "view-session-bound" },
|
||||
});
|
||||
await expect
|
||||
.poll(() => preview.getByRole("button", { name: "Pinned" }).isDisabled())
|
||||
.toBe(true);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
boardProviderForSession,
|
||||
canvasWidgetNameForDocument,
|
||||
GatewayBoardProvider,
|
||||
mcpAppWidgetNameForViewId,
|
||||
type BoardCommandEvent,
|
||||
type BoardProvider,
|
||||
} from "./provider.ts";
|
||||
@@ -43,6 +44,15 @@ describe("board providers", () => {
|
||||
expect(names.every((name) => /^[a-z0-9][a-z0-9._-]*$/u.test(name))).toBe(true);
|
||||
});
|
||||
|
||||
it("generates opaque stable names for MCP App views", () => {
|
||||
const first = mcpAppWidgetNameForViewId("view-session-bound");
|
||||
const second = mcpAppWidgetNameForViewId("view-session-bound");
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^mcp-app-[a-f0-9]{16}$/u);
|
||||
expect(first).not.toContain("view-session-bound");
|
||||
});
|
||||
|
||||
it("keeps the null provider chat-only", () => {
|
||||
mockLocation.search = "";
|
||||
const provider = boardProviderForSession("agent:main:plain");
|
||||
@@ -79,10 +89,21 @@ describe("board providers", () => {
|
||||
);
|
||||
|
||||
expect(provider.canPinWidgets).toBe(false);
|
||||
expect(provider.canPinMcpApps).toBe(false);
|
||||
expect(
|
||||
boardProviderForSession("agent:main:pin-capability", client as never, true, false, true),
|
||||
boardProviderForSession(
|
||||
"agent:main:pin-capability",
|
||||
client as never,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
).toBe(provider);
|
||||
expect(provider.canPinWidgets).toBe(true);
|
||||
expect(provider.canPinMcpApps).toBe(false);
|
||||
boardProviderForSession("agent:main:pin-capability", client as never, true, false, true, true);
|
||||
expect(provider.canPinMcpApps).toBe(true);
|
||||
});
|
||||
|
||||
it("provides two mock tabs with mixed widget sizes", () => {
|
||||
@@ -850,11 +871,12 @@ describe("board providers", () => {
|
||||
const longTitle = "Pinned ".repeat(20).trim();
|
||||
await provider.pinWidget({ docId: "cv-1", title: longTitle });
|
||||
await provider.pinMcpApp({
|
||||
descriptor: {
|
||||
viewId: "mcp-app-source",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
name: "mcp-app-call-1",
|
||||
viewId: "mcp-app-source",
|
||||
name: "mcp-app-opaque",
|
||||
title: "App status",
|
||||
tabId: "main",
|
||||
size: "md",
|
||||
after: "canvas-cv-1",
|
||||
});
|
||||
listener?.({
|
||||
event: "board.command",
|
||||
@@ -883,8 +905,10 @@ describe("board providers", () => {
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("board.widget.put", {
|
||||
sessionKey: "agent:main:live",
|
||||
name: "mcp-app-call-1",
|
||||
name: "mcp-app-opaque",
|
||||
title: "App status",
|
||||
content: { kind: "mcp-app", viewId: "mcp-app-source" },
|
||||
placement: { tabId: "main", size: "md", after: "canvas-cv-1" },
|
||||
});
|
||||
expect(request.mock.calls.filter(([method]) => method === "board.get")).toHaveLength(1);
|
||||
expect(provider.snapshot$.value).toEqual(pinned);
|
||||
|
||||
@@ -29,9 +29,7 @@ type BoardPinPlacement = {
|
||||
};
|
||||
|
||||
type BoardPinWidgetInput = BoardPinPlacement & { docId: string };
|
||||
type BoardPinMcpAppInput = BoardPinPlacement & {
|
||||
descriptor: { viewId: string; toolCallId: string };
|
||||
};
|
||||
type BoardPinMcpAppInput = BoardPinPlacement & { viewId: string };
|
||||
|
||||
type BoardSnapshotSignal = {
|
||||
readonly value: BoardSnapshot;
|
||||
@@ -45,6 +43,7 @@ type BoardEventStream = {
|
||||
export type BoardProvider = {
|
||||
readonly sessionKey: string;
|
||||
readonly canPinWidgets: boolean;
|
||||
readonly canPinMcpApps: boolean;
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
applyOps(ops: BoardOp[]): Promise<void>;
|
||||
grant(name: string, decision: "granted" | "rejected"): Promise<void>;
|
||||
@@ -57,7 +56,7 @@ export type BoardProvider = {
|
||||
readonly events: BoardEventStream;
|
||||
};
|
||||
|
||||
function hashDocumentId(value: string): string {
|
||||
function hashWidgetIdentity(value: string): string {
|
||||
let hash = 0xcbf29ce484222325n;
|
||||
for (const byte of new TextEncoder().encode(value)) {
|
||||
hash ^= BigInt(byte);
|
||||
@@ -72,16 +71,11 @@ export function canvasWidgetNameForDocument(docId: string): string {
|
||||
return name;
|
||||
}
|
||||
const prefix = name.slice(0, 47).replace(/[._-]+$/gu, "") || "canvas-widget";
|
||||
return `${prefix}-${hashDocumentId(docId)}`;
|
||||
return `${prefix}-${hashWidgetIdentity(docId)}`;
|
||||
}
|
||||
|
||||
export function mcpAppWidgetNameForToolCall(toolCallId: string): string {
|
||||
const name = `mcp-app-${toolCallId.toLowerCase().replace(/[^a-z0-9._-]/gu, "-")}`;
|
||||
if (name === `mcp-app-${toolCallId}` && name.length <= 64) {
|
||||
return name;
|
||||
}
|
||||
const prefix = name.slice(0, 47).replace(/[._-]+$/gu, "") || "mcp-app-widget";
|
||||
return `${prefix}-${hashDocumentId(toolCallId)}`;
|
||||
export function mcpAppWidgetNameForViewId(viewId: string): string {
|
||||
return `mcp-app-${hashWidgetIdentity(viewId)}`;
|
||||
}
|
||||
|
||||
class ValueSignal<T> {
|
||||
@@ -183,6 +177,7 @@ export function boardExists(snapshot: BoardSnapshot): boolean {
|
||||
|
||||
class NullProvider implements BoardProvider {
|
||||
readonly canPinWidgets = false;
|
||||
readonly canPinMcpApps = false;
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
readonly events: BoardEventStream = new EventStream<BoardCommandEvent>();
|
||||
|
||||
@@ -219,6 +214,7 @@ class NullProvider implements BoardProvider {
|
||||
|
||||
class MockBoardProvider implements BoardProvider {
|
||||
readonly canPinWidgets = true;
|
||||
readonly canPinMcpApps = true;
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
readonly events: BoardEventStream;
|
||||
private readonly snapshotSignal: ValueSignal<BoardSnapshot>;
|
||||
@@ -289,7 +285,7 @@ class MockBoardProvider implements BoardProvider {
|
||||
|
||||
async pinMcpApp(input: BoardPinMcpAppInput): Promise<void> {
|
||||
const snapshot = this.snapshotSignal.value;
|
||||
const name = input.name ?? mcpAppWidgetNameForToolCall(input.descriptor.toolCallId);
|
||||
const name = input.name ?? mcpAppWidgetNameForViewId(input.viewId);
|
||||
const title = boardWidgetTitle(input.title);
|
||||
const tabId = input.tabId ?? snapshot.tabs[0]?.tabId ?? "main";
|
||||
const tabs = snapshot.tabs.length
|
||||
@@ -349,7 +345,6 @@ class MockBoardProvider implements BoardProvider {
|
||||
}
|
||||
|
||||
export class GatewayBoardProvider implements BoardProvider {
|
||||
canPinWidgets: boolean;
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
readonly events: BoardEventStream;
|
||||
private readonly snapshotSignal: ValueSignal<BoardSnapshot>;
|
||||
@@ -369,24 +364,30 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
readonly sessionKey: string,
|
||||
client: BoardGatewayClient,
|
||||
connected = true,
|
||||
canPinWidgets = true,
|
||||
public canPinWidgets = true,
|
||||
public canPinMcpApps = false,
|
||||
) {
|
||||
this.snapshotSignal = new ValueSignal(emptySnapshot(sessionKey));
|
||||
this.snapshot$ = this.snapshotSignal;
|
||||
this.events = this.eventStream;
|
||||
this.client = client;
|
||||
this.connected = connected;
|
||||
this.canPinWidgets = canPinWidgets;
|
||||
this.subscribe(client);
|
||||
if (connected) {
|
||||
void this.activate();
|
||||
}
|
||||
}
|
||||
|
||||
attachClient(client: BoardGatewayClient, connected = true, canPinWidgets = true): void {
|
||||
attachClient(
|
||||
client: BoardGatewayClient,
|
||||
connected = true,
|
||||
canPinWidgets = true,
|
||||
canPinMcpApps = false,
|
||||
): void {
|
||||
const connectionActivated = connected && !this.connected;
|
||||
this.connected = connected;
|
||||
this.canPinWidgets = canPinWidgets;
|
||||
this.canPinMcpApps = canPinMcpApps;
|
||||
if (client === this.client) {
|
||||
if (connectionActivated) {
|
||||
void this.activate();
|
||||
@@ -457,7 +458,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
}
|
||||
|
||||
async pinMcpApp(input: BoardPinMcpAppInput): Promise<void> {
|
||||
const name = input.name ?? mcpAppWidgetNameForToolCall(input.descriptor.toolCallId);
|
||||
const name = input.name ?? mcpAppWidgetNameForViewId(input.viewId);
|
||||
const title = boardWidgetTitle(input.title);
|
||||
await this.mutate(
|
||||
"board.widget.put",
|
||||
@@ -465,7 +466,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
sessionKey: this.sessionKey,
|
||||
name,
|
||||
...(title ? { title } : {}),
|
||||
content: { kind: "mcp-app", viewId: input.descriptor.viewId },
|
||||
content: { kind: "mcp-app", viewId: input.viewId },
|
||||
...(input.tabId || input.size || input.after
|
||||
? {
|
||||
placement: {
|
||||
@@ -704,21 +705,18 @@ function isMockBoardSession(sessionKey: string): boolean {
|
||||
return /^agent:[^:]+:[^:]+$/u.test(sessionKey);
|
||||
}
|
||||
|
||||
export function normalizeBoardSessionKeyForComparison(sessionKey: string): string {
|
||||
function boardProviderCacheKey(sessionKey: string): string {
|
||||
const normalized = normalizeSessionKeyForUiComparison(sessionKey);
|
||||
return normalized === "main" ? buildAgentMainSessionKey({ agentId: "main" }) : normalized;
|
||||
}
|
||||
|
||||
function boardProviderCacheKey(sessionKey: string): string {
|
||||
return normalizeBoardSessionKeyForComparison(sessionKey);
|
||||
}
|
||||
|
||||
export function boardProviderForSession(
|
||||
sessionKey: string,
|
||||
client?: BoardGatewayClient | null,
|
||||
available = true,
|
||||
connected = true,
|
||||
canPinWidgets = available,
|
||||
canPinMcpApps = false,
|
||||
): BoardProvider {
|
||||
const key = boardProviderCacheKey(sessionKey);
|
||||
const mockScope = resolveMockBoardScope();
|
||||
@@ -745,10 +743,10 @@ export function boardProviderForSession(
|
||||
if (client) {
|
||||
let provider = gatewayProviders.get(key);
|
||||
if (!provider) {
|
||||
provider = new GatewayBoardProvider(key, client, connected, canPinWidgets);
|
||||
provider = new GatewayBoardProvider(key, client, connected, canPinWidgets, canPinMcpApps);
|
||||
gatewayProviders.set(key, provider);
|
||||
} else {
|
||||
provider.attachClient(client, connected, canPinWidgets);
|
||||
provider.attachClient(client, connected, canPinWidgets, canPinMcpApps);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -345,6 +345,42 @@ describe("chat pane board shell", () => {
|
||||
expect(pane.resolveBoardProvider().snapshot$.value.sessionKey).toBe("agent:work:primary");
|
||||
});
|
||||
|
||||
it("enables MCP App pinning only when app-view and put methods are both advertised", () => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
const cases = [
|
||||
{ suffix: "put-only", methods: ["board.get", "board.widget.put"], expected: false },
|
||||
{ suffix: "view-only", methods: ["board.get", "board.widget.appView"], expected: false },
|
||||
{
|
||||
suffix: "complete",
|
||||
methods: ["board.get", "board.widget.appView", "board.widget.put"],
|
||||
expected: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const pane = createTestPane();
|
||||
const sessionKey = `agent:main:${testCase.suffix}`;
|
||||
const client = {
|
||||
request: vi.fn(async () => ({ sessionKey, revision: 0, tabs: [], widgets: [] })),
|
||||
addEventListener: vi.fn(() => () => {}),
|
||||
} as unknown as GatewayBrowserClient;
|
||||
pane.state.sessionKey = sessionKey;
|
||||
pane.context = {
|
||||
...pane.context,
|
||||
gateway: {
|
||||
...pane.context.gateway,
|
||||
snapshot: {
|
||||
client,
|
||||
connected: true,
|
||||
hello: { features: { methods: testCase.methods } },
|
||||
} as never,
|
||||
},
|
||||
};
|
||||
|
||||
expect(pane.resolveBoardProvider().canPinMcpApps).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the side dock width for rail and detail breakpoints", () => {
|
||||
expect(
|
||||
resolveBoardChatLayoutWidth({
|
||||
|
||||
@@ -1529,6 +1529,9 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
!gateway ||
|
||||
isGatewayCapabilityAdvertised(gateway, GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC) ===
|
||||
true,
|
||||
!gateway ||
|
||||
(isGatewayMethodAdvertised(gateway, "board.widget.appView") === true &&
|
||||
isGatewayMethodAdvertised(gateway, "board.widget.put") === true),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { normalizeBoardSessionKeyForComparison } from "../../../lib/board/provider.ts";
|
||||
import type { ToolPreview } from "../../../lib/chat/tool-cards.ts";
|
||||
|
||||
export type McpAppPinDescriptor = {
|
||||
viewId: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
uiResourceUri: string;
|
||||
originSessionKey: string;
|
||||
toolCallId: string;
|
||||
};
|
||||
|
||||
export function buildMcpAppPinDescriptor(
|
||||
preview: ToolPreview,
|
||||
boardSessionKey: string,
|
||||
): McpAppPinDescriptor | undefined {
|
||||
const descriptor = preview.mcpApp;
|
||||
const viewId = descriptor?.viewId?.trim();
|
||||
const serverName = descriptor?.serverName?.trim();
|
||||
const toolName = descriptor?.toolName?.trim();
|
||||
const uiResourceUri = descriptor?.uiResourceUri?.trim();
|
||||
const toolCallId = descriptor?.toolCallId?.trim();
|
||||
const originSessionKey = descriptor?.originSessionKey?.trim();
|
||||
if (
|
||||
!viewId ||
|
||||
!serverName ||
|
||||
!toolName ||
|
||||
!uiResourceUri ||
|
||||
!toolCallId ||
|
||||
!originSessionKey ||
|
||||
normalizeBoardSessionKeyForComparison(originSessionKey) !==
|
||||
normalizeBoardSessionKeyForComparison(boardSessionKey)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
viewId,
|
||||
serverName,
|
||||
toolName,
|
||||
uiResourceUri,
|
||||
toolCallId,
|
||||
originSessionKey,
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { BoardProvider } from "../../../lib/board/provider.ts";
|
||||
import { buildMcpAppPinDescriptor } from "./widget-card-mcp-app.ts";
|
||||
import { mcpAppWidgetNameForViewId, type BoardProvider } from "../../../lib/board/provider.ts";
|
||||
import { renderToolPreview } from "./widget-card.ts";
|
||||
|
||||
describe("widget-card", () => {
|
||||
@@ -54,7 +53,7 @@ describe("widget-card", () => {
|
||||
expect(unknown.childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it("pins Canvas HTML through the board provider and hides the action for MCP Apps", async () => {
|
||||
it("pins normalized Canvas HTML through the board provider", async () => {
|
||||
const pinWidget = vi.fn(async () => undefined);
|
||||
const snapshotSignal = {
|
||||
value: {
|
||||
@@ -79,7 +78,7 @@ describe("widget-card", () => {
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
title: "Release status",
|
||||
viewId: "cv_release",
|
||||
viewId: " cv_release ",
|
||||
url: "/__openclaw__/canvas/documents/cv_release/index.html",
|
||||
sandbox: "scripts",
|
||||
},
|
||||
@@ -207,11 +206,12 @@ describe("widget-card", () => {
|
||||
expect(app.querySelector("[data-pin-widget]")).toBeNull();
|
||||
});
|
||||
|
||||
it("pins complete MCP App descriptors only to their originating board", async () => {
|
||||
it("pins an MCP App using only its view identity", async () => {
|
||||
const pinMcpApp = vi.fn(async () => undefined);
|
||||
const provider = {
|
||||
sessionKey: "agent:main:main",
|
||||
canPinWidgets: true,
|
||||
canPinMcpApps: true,
|
||||
pinMcpApp,
|
||||
snapshot$: {
|
||||
value: {
|
||||
@@ -229,7 +229,7 @@ describe("widget-card", () => {
|
||||
render: "url" as const,
|
||||
title: "Weather",
|
||||
mcpApp: {
|
||||
viewId: "mcp-app-source",
|
||||
viewId: " mcp-app-source ",
|
||||
serverName: "weather",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://weather/app",
|
||||
@@ -237,18 +237,6 @@ describe("widget-card", () => {
|
||||
originSessionKey: "agent:main:main",
|
||||
},
|
||||
};
|
||||
expect(buildMcpAppPinDescriptor(preview, "agent:main:main")).toEqual({
|
||||
viewId: "mcp-app-source",
|
||||
serverName: "weather",
|
||||
toolName: "show",
|
||||
uiResourceUri: "ui://weather/app",
|
||||
toolCallId: "call-1",
|
||||
originSessionKey: "agent:main:main",
|
||||
});
|
||||
expect(buildMcpAppPinDescriptor(preview, "agent:main:other")).toBeUndefined();
|
||||
expect(buildMcpAppPinDescriptor(preview, "main")).toEqual(
|
||||
expect.objectContaining({ originSessionKey: "agent:main:main" }),
|
||||
);
|
||||
|
||||
const origin = document.createElement("div");
|
||||
render(
|
||||
@@ -261,21 +249,32 @@ describe("widget-card", () => {
|
||||
origin.querySelector<HTMLButtonElement>("[data-pin-widget]")?.click();
|
||||
await vi.waitFor(() =>
|
||||
expect(pinMcpApp).toHaveBeenCalledWith({
|
||||
descriptor: expect.objectContaining({ viewId: "mcp-app-source", toolCallId: "call-1" }),
|
||||
name: "mcp-app-call-1",
|
||||
viewId: "mcp-app-source",
|
||||
name: mcpAppWidgetNameForViewId("mcp-app-source"),
|
||||
title: "Weather",
|
||||
}),
|
||||
);
|
||||
|
||||
const otherProvider = { ...provider, sessionKey: "agent:main:other" } as BoardProvider;
|
||||
const other = document.createElement("div");
|
||||
const unsupportedProvider = { ...provider, canPinMcpApps: false } as BoardProvider;
|
||||
const unsupported = document.createElement("div");
|
||||
render(
|
||||
renderToolPreview(preview, "chat_message", {
|
||||
boardProvider: otherProvider,
|
||||
boardProvider: unsupportedProvider,
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
other,
|
||||
unsupported,
|
||||
);
|
||||
expect(other.querySelector("[data-pin-widget]")).toBeNull();
|
||||
expect(unsupported.querySelector("[data-pin-widget]")).toBeNull();
|
||||
|
||||
const missingView = document.createElement("div");
|
||||
render(
|
||||
renderToolPreview(
|
||||
{ ...preview, mcpApp: { ...preview.mcpApp, viewId: " " } },
|
||||
"chat_message",
|
||||
{ boardProvider: provider, sessionKey: "agent:main:main" },
|
||||
),
|
||||
missingView,
|
||||
);
|
||||
expect(missingView.querySelector("[data-pin-widget]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import "../../../components/web-awesome.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import {
|
||||
canvasWidgetNameForDocument,
|
||||
mcpAppWidgetNameForToolCall,
|
||||
mcpAppWidgetNameForViewId,
|
||||
type BoardProvider,
|
||||
} from "../../../lib/board/provider.ts";
|
||||
import type { ToolPreview } from "../../../lib/chat/tool-cards.ts";
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from "../../../lib/chat/tool-display.ts";
|
||||
import { showToast } from "../../../lib/toast.ts";
|
||||
import type { SidebarContent } from "./chat-sidebar.ts";
|
||||
import { buildMcpAppPinDescriptor, type McpAppPinDescriptor } from "./widget-card-mcp-app.ts";
|
||||
import { exportWidget } from "./widget-export.ts";
|
||||
import { installWidgetThemeObserver, postWidgetTheme } from "./widget-theme.ts";
|
||||
|
||||
@@ -47,14 +46,15 @@ async function pinCanvasWidget(
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const button = event.currentTarget;
|
||||
if (!(button instanceof HTMLButtonElement) || !preview.viewId) {
|
||||
const docId = preview.viewId?.trim();
|
||||
if (!(button instanceof HTMLButtonElement) || !docId) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
button.textContent = t("chat.toolCards.pinToDashboardPending");
|
||||
try {
|
||||
await provider.pinWidget({
|
||||
docId: preview.viewId,
|
||||
docId,
|
||||
name,
|
||||
...(preview.title?.trim() ? { title: preview.title.trim() } : {}),
|
||||
});
|
||||
@@ -72,7 +72,7 @@ async function pinMcpAppWidget(
|
||||
preview: ToolPreview,
|
||||
provider: BoardProvider,
|
||||
name: string,
|
||||
descriptor: McpAppPinDescriptor,
|
||||
viewId: string,
|
||||
): Promise<void> {
|
||||
const button = event.currentTarget;
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
@@ -82,7 +82,7 @@ async function pinMcpAppWidget(
|
||||
button.textContent = t("chat.toolCards.pinToDashboardPending");
|
||||
try {
|
||||
await provider.pinMcpApp({
|
||||
descriptor,
|
||||
viewId,
|
||||
name,
|
||||
...(preview.title?.trim() ? { title: preview.title.trim() } : {}),
|
||||
});
|
||||
@@ -99,7 +99,8 @@ function canvasWidgetName(preview: ToolPreview): string | undefined {
|
||||
if (preview.boardWidgetName) {
|
||||
return preview.boardWidgetName;
|
||||
}
|
||||
return preview.viewId ? canvasWidgetNameForDocument(preview.viewId) : undefined;
|
||||
const viewId = preview.viewId?.trim();
|
||||
return viewId ? canvasWidgetNameForDocument(viewId) : undefined;
|
||||
}
|
||||
|
||||
function isManagedCanvasDocumentPreview(preview: ToolPreview): boolean {
|
||||
@@ -460,25 +461,24 @@ function renderWidgetCard(
|
||||
}
|
||||
const label = preview.title?.trim() || t("chat.toolCards.canvas");
|
||||
const contentKind = preview.mcpApp ? "mcp-app" : "canvas-html";
|
||||
const mcpDescriptor = options?.boardProvider
|
||||
? buildMcpAppPinDescriptor(preview, options.boardProvider.sessionKey)
|
||||
: undefined;
|
||||
const provider = options?.boardProvider;
|
||||
const mcpAppViewId = preview.mcpApp?.viewId?.trim();
|
||||
const pinName = preview.mcpApp
|
||||
? mcpDescriptor
|
||||
? mcpAppWidgetNameForToolCall(mcpDescriptor.toolCallId)
|
||||
? mcpAppViewId
|
||||
? mcpAppWidgetNameForViewId(mcpAppViewId)
|
||||
: undefined
|
||||
: canvasWidgetName(preview);
|
||||
const pinned = Boolean(
|
||||
pinName &&
|
||||
options?.boardProvider?.snapshot$.value.widgets.some((widget) => widget.name === pinName),
|
||||
pinName && provider?.snapshot$.value.widgets.some((widget) => widget.name === pinName),
|
||||
);
|
||||
const pinAction =
|
||||
options?.boardProvider?.canPinWidgets &&
|
||||
provider &&
|
||||
(contentKind === "mcp-app" ? provider.canPinMcpApps : provider.canPinWidgets) &&
|
||||
pinName &&
|
||||
((contentKind === "canvas-html" &&
|
||||
preview.sandbox === "scripts" &&
|
||||
isManagedCanvasDocumentPreview(preview)) ||
|
||||
(contentKind === "mcp-app" && mcpDescriptor))
|
||||
(contentKind === "mcp-app" && mcpAppViewId))
|
||||
? html`<button
|
||||
class="chat-tool-card__widget-action"
|
||||
type="button"
|
||||
@@ -490,9 +490,9 @@ function renderWidgetCard(
|
||||
pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard",
|
||||
)}
|
||||
@click=${(event: Event) =>
|
||||
contentKind === "mcp-app" && mcpDescriptor
|
||||
? void pinMcpAppWidget(event, preview, options.boardProvider!, pinName, mcpDescriptor)
|
||||
: void pinCanvasWidget(event, preview, options.boardProvider!, pinName)}
|
||||
contentKind === "mcp-app" && mcpAppViewId
|
||||
? void pinMcpAppWidget(event, preview, provider, pinName, mcpAppViewId)
|
||||
: void pinCanvasWidget(event, preview, provider, pinName)}
|
||||
>
|
||||
${t(pinned ? "chat.toolCards.pinnedToDashboard" : "chat.toolCards.pinToDashboard")}
|
||||
</button>`
|
||||
|
||||
Reference in New Issue
Block a user