mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-03 19:21:38 +00:00
feat(chat): honor pinned widget presentation, dashboard-face deep link, board trust dot (#112076)
This commit is contained in:
committed by
GitHub
parent
a8a79742f8
commit
f7374106b3
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user