From f7374106b3d6fc5dfcabd2f3d62bbcb0cdca955c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 23:00:58 -0700 Subject: [PATCH] feat(chat): honor pinned widget presentation, dashboard-face deep link, board trust dot (#112076) --- ui/src/components/board/board-widget-cell.ts | 3 + ui/src/pages/chat/chat-page.ts | 31 +++++++++- .../pages/chat/components/widget-card.test.ts | 57 +++++++++++++++++++ ui/src/pages/chat/components/widget-card.ts | 12 ++-- ui/src/pages/chat/route.ts | 9 ++- ui/src/styles/board.css | 30 ++++++++++ ui/src/styles/chat/tool-cards.css | 6 ++ ui/src/test-helpers/board-fixture.ts | 6 ++ 8 files changed, 147 insertions(+), 7 deletions(-) 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)} -
+
${renderWidgetContent(contentKind, preview, options)}
diff --git a/ui/src/pages/chat/route.ts b/ui/src/pages/chat/route.ts index 89715ced0b18..05676bf4447a 100644 --- a/ui/src/pages/chat/route.ts +++ b/ui/src/pages/chat/route.ts @@ -13,11 +13,17 @@ function draftFromLocation(location: RouteLocation): string | undefined { return draft || undefined; } +// Only "dashboard" is meaningful: chat is the default face, so a chat value +// would be a no-op that still dirties history replaces. +function faceFromLocation(location: RouteLocation): "dashboard" | undefined { + return new URLSearchParams(location.search).get("face") === "dashboard" ? "dashboard" : undefined; +} + export const page = definePage({ id: "chat", path: "/chat", loaderDeps: (_context: ApplicationContext, location: RouteLocation) => - `${sessionKeyFromLocation(location) ?? ""}\u0000${draftFromLocation(location) ?? ""}`, + `${sessionKeyFromLocation(location) ?? ""}\u0000${draftFromLocation(location) ?? ""}\u0000${faceFromLocation(location) ?? ""}`, loader: async (_context: ApplicationContext, { location }) => { const sessionKey = sessionKeyFromLocation(location); if (!sessionKey) { @@ -26,6 +32,7 @@ export const page = definePage({ return { sessionKey, draft: draftFromLocation(location), + face: faceFromLocation(location), }; }, component: () => diff --git a/ui/src/styles/board.css b/ui/src/styles/board.css index 6b53a72a6cf2..060b97db5f90 100644 --- a/ui/src/styles/board.css +++ b/ui/src/styles/board.css @@ -300,6 +300,12 @@ openclaw-board-widget-cell { position: absolute; } +/* Passive trust indicator: only meaningful where chrome hides (fine pointer), + so the base rule keeps it off and the overlay block reveals it at rest. */ +.board-widget__grant-dot { + display: none; +} + .board-widget__frame { background: transparent; border: 0; @@ -525,6 +531,30 @@ openclaw-board-widget-cell { visibility: visible; } + /* While chrome is hidden, a granted widget keeps a small trust dot in the + bar's corner; the revealed bar carries the full capabilities chip, so the + dot fades out together with the chrome fade-in. */ + .board-widget__grant-dot { + background: var(--ok, #4ec9a8); + border-radius: 50%; + display: block; + height: 6px; + pointer-events: none; + position: absolute; + right: 10px; + top: 10px; + transition: opacity 120ms ease; + width: 6px; + z-index: 4; + } + + .board-widget:hover .board-widget__grant-dot, + .board-widget:focus-within .board-widget__grant-dot, + .board-widget--dragging .board-widget__grant-dot, + .board-widget:has(.board-widget__menu[open]) .board-widget__grant-dot { + opacity: 0; + } + /* Frameless is a fine-pointer treatment only: touch keeps the card shell because there is no hover to rediscover the widget bounds. While dragging, the reset stops applying so the widget materializes back into a full card diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 2e9128d7d358..7127de8bd7d1 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -827,6 +827,12 @@ padding: var(--widget-frame-inset); } +/* Full-bleed and frameless pins render edge-to-edge here too, so the same + authored document meets identical geometry in chat and on the board. */ +.chat-tool-card__preview-panel[data-bleed] { + padding: 0; +} + .chat-tool-card__preview-frame { display: block; width: 100%; diff --git a/ui/src/test-helpers/board-fixture.ts b/ui/src/test-helpers/board-fixture.ts index 93f51f5c33fe..764b8b1a9fa3 100644 --- a/ui/src/test-helpers/board-fixture.ts +++ b/ui/src/test-helpers/board-fixture.ts @@ -2,8 +2,14 @@ import { html } from "lit"; import { state } from "lit/decorators.js"; import type { BoardOp, BoardSnapshot } from "../lib/board/types.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +// The fixture page renders outside the app shell, so it must load the app +// stylesheet itself (Web Awesome theme included) or dropdown menus render +// theme-less: dark item text on the fixture's dark panels. +import "../styles.css"; import "../components/board/board-view.ts"; +document.documentElement.classList.add("wa-dark"); + const initialSnapshot: BoardSnapshot = { sessionKey: "agent:main:board-fixture", revision: 7,