feat(chat): honor pinned widget presentation, dashboard-face deep link, board trust dot (#112076)

This commit is contained in:
Peter Steinberger
2026-07-20 23:00:58 -07:00
committed by GitHub
parent a8a79742f8
commit f7374106b3
8 changed files with 147 additions and 7 deletions

View File

@@ -361,6 +361,9 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
title=${t("board.widget.resizeHandle", { title: label })}
@pointerdown=${(event: PointerEvent) => callbacks.resizePointerDown(widget, event)}
></span>`}
${widget.grantState === "granted" && widget.contentKind !== "builtin"
? html`<span class="board-widget__grant-dot" aria-hidden="true"></span>`
: nothing}
</section>
`;
}

View File

@@ -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

View File

@@ -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,
);
});
});

View File

@@ -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)}
</div>
</div>
<div class="chat-tool-card__preview-panel" data-side="canvas">
<div class="chat-tool-card__preview-panel" data-side="canvas" ?data-bleed=${bleed}>
${renderWidgetContent(contentKind, preview, options)}
</div>
</div>

View File

@@ -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: () =>

View File

@@ -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

View File

@@ -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%;

View File

@@ -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,