feat(dashboard): harden transcript MCP App pinning (#111748)

This commit is contained in:
Jason (Json)
2026-07-20 02:42:00 -06:00
committed by GitHub
parent 391c3bcb17
commit 779cf40d46
8 changed files with 241 additions and 133 deletions
+104 -12
View File
@@ -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();
});
});
+31 -7
View File
@@ -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);
+24 -26
View File
@@ -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;
}
+36
View File
@@ -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({
+3
View File
@@ -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();
});
});
+19 -19
View File
@@ -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>`