diff --git a/ui/src/components/board/board-widget-cell.ts b/ui/src/components/board/board-widget-cell.ts index 0260d9db3d20..a304d8e58065 100644 --- a/ui/src/components/board/board-widget-cell.ts +++ b/ui/src/components/board/board-widget-cell.ts @@ -361,6 +361,9 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement { title=${t("board.widget.resizeHandle", { title: label })} @pointerdown=${(event: PointerEvent) => callbacks.resizePointerDown(widget, event)} >`} + ${widget.grantState === "granted" && widget.contentKind !== "builtin" + ? html`` + : nothing} `; } diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index d04d2c9205f9..a5165ce0f22a 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -10,10 +10,15 @@ import "../../components/resizable-divider.ts"; import { McpAppUnmountGate } from "../../components/mcp-app-unmount.ts"; import { UI_COMMAND_EVENT, type UiCommandDetail } from "../../components/panel-toggle-contract.ts"; import { t } from "../../i18n/index.ts"; +import { updateBoardSessionView } from "../../lib/board/settings.ts"; import { resolveSessionDisplayName } from "../../lib/session-display.ts"; import { readSessionDragData, sessionDragActive } from "../../lib/sessions/drag.ts"; import { resolveSessionKey, searchForSession } from "../../lib/sessions/index.ts"; -import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; +import { + areUiSessionKeysEquivalent, + buildAgentMainSessionKey, + normalizeSessionKeyForUiComparison, +} from "../../lib/sessions/session-key.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import "../../styles/chat.css"; @@ -52,6 +57,7 @@ function splitRatio(weights: number[], index: number, context: string): number { type ChatRouteData = { sessionKey: string; draft?: string; + face?: "dashboard"; }; const NARROW_SPLIT_QUERY = "(max-width: 1099px)"; @@ -123,7 +129,7 @@ export class ChatPage extends OpenClawLightDomElement { const data = this.data; const activePane = this.layout ? findPane(this.layout, this.layout.activePaneId)?.pane : null; const routeDraftWasRendered = - Boolean(data?.draft) && + Boolean(data?.draft || data?.face) && this.consumedDraftData !== data && (!this.layout || activePane?.sessionKey === data.sessionKey); if (changedProperties.has("data")) { @@ -135,6 +141,9 @@ export class ChatPage extends OpenClawLightDomElement { queueMicrotask(() => { if (this.isConnected && this.data === data && this.consumedDraftData !== data) { this.consumedDraftData = data; + if (data.face) { + this.applyRouteFace(data.sessionKey, data.face); + } // Route drafts are one-shot actions. Once the matching pane owns the // text, remove it from history so reload/back cannot replay it. this.context.replace("chat", { search: searchForSession(data.sessionKey) }); @@ -504,6 +513,24 @@ export class ChatPage extends OpenClawLightDomElement { } }; + // Route-owned face intent persists here, keyed exactly like chat-pane's + // board settings, because pane-level board resolution is not ready at + // navigation time and would file the face under the wrong session. + private applyRouteFace(sessionKey: string, face: "dashboard"): void { + const resolved = resolveSessionKey(sessionKey, this.context.gateway.snapshot.hello); + const normalized = normalizeSessionKeyForUiComparison(resolved); + const boardSessionKey = + normalized === "main" ? buildAgentMainSessionKey({ agentId: "main" }) : normalized; + if (!boardSessionKey) { + return; + } + patchSettings({ + boardSessionViews: updateBoardSessionView(loadSettings().boardSessionViews, boardSessionKey, { + face, + }), + }); + } + private routeDraftForActivePane(sessionKey = this.data?.sessionKey): string | undefined { const data = this.data; // Route data can render before the split layout catches up. Never hand the diff --git a/ui/src/pages/chat/components/widget-card.test.ts b/ui/src/pages/chat/components/widget-card.test.ts index ad28333ececb..a924c6962226 100644 --- a/ui/src/pages/chat/components/widget-card.test.ts +++ b/ui/src/pages/chat/components/widget-card.test.ts @@ -278,3 +278,60 @@ describe("widget-card", () => { expect(missingView.querySelector("[data-pin-widget]")).toBeNull(); }); }); + +describe("widget-card presentation", () => { + const preview = { + kind: "canvas", + surface: "assistant_message", + render: "url", + title: "Clock", + viewId: "cv_clock", + url: "/__openclaw__/canvas/documents/cv_clock/index.html", + sandbox: "scripts", + } as const; + + function providerWith(presentation?: "card" | "full-bleed" | "frameless"): BoardProvider { + return { + sessionKey: "agent:main:main", + canPinWidgets: true, + pinWidget: vi.fn(async () => undefined), + snapshot$: { + value: { + sessionKey: "agent:main:main", + revision: 1, + tabs: [], + widgets: [ + { + name: "canvas-cv_clock", + tabId: "main", + contentKind: "html", + ...(presentation ? { presentation } : {}), + sizeW: 6, + sizeH: 4, + position: 0, + grantState: "none", + revision: 1, + }, + ], + }, + subscribe: () => () => {}, + }, + } as unknown as BoardProvider; + } + + it("drops the panel inset for non-card pinned presentations", () => { + const host = document.createElement("div"); + render( + renderToolPreview(preview, "chat_message", { boardProvider: providerWith("full-bleed") }), + host, + ); + expect(host.querySelector(".chat-tool-card__preview-panel")?.hasAttribute("data-bleed")).toBe( + true, + ); + + render(renderToolPreview(preview, "chat_message", { boardProvider: providerWith() }), host); + expect(host.querySelector(".chat-tool-card__preview-panel")?.hasAttribute("data-bleed")).toBe( + false, + ); + }); +}); diff --git a/ui/src/pages/chat/components/widget-card.ts b/ui/src/pages/chat/components/widget-card.ts index 2143dc5fc2a6..90599ab94d6b 100644 --- a/ui/src/pages/chat/components/widget-card.ts +++ b/ui/src/pages/chat/components/widget-card.ts @@ -472,9 +472,13 @@ function renderWidgetCard( ? mcpAppWidgetNameForViewId(mcpAppViewId) : undefined : canvasWidgetName(preview); - const pinned = Boolean( - pinName && provider?.snapshot$.value.widgets.some((widget) => widget.name === pinName), - ); + const pinnedWidget = pinName + ? provider?.snapshot$.value.widgets.find((widget) => widget.name === pinName) + : undefined; + const pinned = Boolean(pinnedWidget); + // Chat keeps its labeled card shell, but the inner inset follows the pinned + // widget's presentation so authored edge-to-edge content matches the board. + const bleed = pinned && (pinnedWidget?.presentation ?? "card") !== "card"; const pinAction = provider && (contentKind === "mcp-app" ? provider.canPinMcpApps : provider.canPinWidgets) && @@ -510,7 +514,7 @@ function renderWidgetCard( ${renderWidgetActions(preview)} -