improve(ui): move fullscreen widget controls into the page menu (#152053)

Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-09-18 11:50:35 -07:00
committed by GitHub
co-authored by steipete
parent 39f4dac5de
commit 0485ca29d6
15 changed files with 719 additions and 129 deletions
+4
View File
@@ -143,6 +143,10 @@ never needs the agent.
toolbar, then **Focus** for a dashboard-only view. **Restore split** brings
the side panel back. A tab with one full-width widget fills the focused
dashboard edge to edge, without a card border or surrounding padding.
Its widget controls move into the task toolbar’s **…** menu, leaving no
hover pill or drag and resize handles over the page. Granted permissions
remain available in that menu; approval requests and errors stay visible
in the widget.
Restoring the split or adding another widget brings back the normal spacing.
- **Shared default.** In the task menu’s **Layout** submenu, choose **Use current
view as default** to save the current fullscreen or split view for this dashboard.
+6
View File
@@ -2,6 +2,12 @@ import { html, nothing, type TemplateResult } from "lit";
import { t } from "../../i18n/index.ts";
import type { BoardTab } from "../../lib/board/types.ts";
export function orderedBoardTabs(tabs: readonly BoardTab[]): BoardTab[] {
return tabs.toSorted(
(left, right) => left.position - right.position || left.tabId.localeCompare(right.tabId),
);
}
function renderTab(tab: BoardTab, activeTabId: string, hoverTabId: string): TemplateResult {
const active = tab.tabId === activeTabId;
const dropTarget = tab.tabId === hoverTabId;
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDeferred as deferred } from "../../../../test/helpers/promise.js";
import "./board-view.ts";
import { boardWidget, callbacks, mount, settleCells, snapshot } from "./board-view.test-support.ts";
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe("page-owned board widget chrome", () => {
it("relocates page chrome without replacing the live widget document", async () => {
const view = await mount({
snapshot: snapshot({ widgets: [boardWidget({ sizeW: 12, grantState: "granted" })] }),
});
const frame = view.querySelector("iframe");
const chrome = ".board-widget__bar, .board-widget__resize-handle, .board-widget__grant-dot";
expect(view.querySelectorAll(chrome)).toHaveLength(3);
view.pageWidgetName = "alpha";
await settleCells(view);
expect(view.querySelectorAll(chrome)).toHaveLength(0);
expect(view.querySelector("iframe")).toBe(frame);
expect(view.querySelector(".board-widget")?.getAttribute("aria-label")).toBe("Alpha status");
view.pageWidgetName = "";
await settleCells(view);
expect(view.querySelectorAll(chrome)).toHaveLength(3);
expect(view.querySelector("iframe")).toBe(frame);
});
it("keeps page actions with their current cell, including serialization and visible failures", async () => {
const pending = deferred();
const applyOps = vi.fn(() => pending.promise);
const view = await mount({
snapshot: snapshot({ widgets: [boardWidget({ sizeW: 12 })] }),
callbacks: callbacks({ applyOps }),
});
view.pageWidgetName = "alpha";
await settleCells(view);
const frame = view.querySelector("iframe");
view.selectPageWidgetMenuItem("alpha", 2, "remove");
expect(applyOps).not.toHaveBeenCalled();
view.selectPageWidgetMenuItem("alpha", 1, "resize:md");
expect(applyOps).toHaveBeenCalledWith([
{ kind: "widget_resize", name: "alpha", sizeW: 6, sizeH: 4, heightMode: "fixed" },
]);
view.selectPageWidgetMenuItem("alpha", 1, "remove");
expect(applyOps).toHaveBeenCalledTimes(1);
pending.reject(new Error("page resize failed"));
await vi.waitFor(() =>
expect(view.querySelector("[data-test-id=board-widget-action-error]")?.textContent).toContain(
"page resize failed",
),
);
expect(view.querySelector("iframe")).toBe(frame);
view.canMutate = false;
view.selectPageWidgetMenuItem("alpha", 1, "remove");
view.canMutate = true;
view.activeTabId = "ops";
view.selectPageWidgetMenuItem("alpha", 1, "remove");
expect(applyOps).toHaveBeenCalledTimes(1);
});
});
+35 -34
View File
@@ -10,6 +10,7 @@ import {
BOARD_GRID_GAP,
BOARD_GRID_ROW_HEIGHT,
boardChromeRowPx,
boardWidgetGridItems,
effectiveBoardWidgetRows,
FINE_POINTER_QUERY,
layout,
@@ -29,7 +30,7 @@ import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import "../../styles/board.css";
import "../web-awesome-tabs.ts";
import "../web-awesome.ts";
import { renderBoardTabs } from "./board-tabs.ts";
import { orderedBoardTabs, renderBoardTabs } from "./board-tabs.ts";
import type { BoardWidgetCellCallbacks } from "./board-widget-cell.ts";
import "./board-widget-cell.ts";
@@ -45,12 +46,6 @@ type BoardPointerGesture = {
items: BoardGridItem[];
};
function orderedTabs(snapshot: BoardSnapshot): BoardTab[] {
return snapshot.tabs.toSorted(
(left, right) => left.position - right.position || left.tabId.localeCompare(right.tabId),
);
}
function orderedWidgets(snapshot: BoardSnapshot, tabId: string): BoardWidget[] {
return snapshot.widgets
.filter((widget) => widget.tabId === tabId)
@@ -59,25 +54,6 @@ function orderedWidgets(snapshot: BoardSnapshot, tabId: string): BoardWidget[] {
);
}
function itemsForWidgets(
widgets: readonly BoardWidget[],
contentHeights: ReadonlyMap<string, number>,
fitAutoContent = false,
): BoardGridItem[] {
const chromeRowPx = boardChromeRowPx();
return widgets.map((widget) => ({
name: widget.name,
w: widget.sizeW,
h: effectiveBoardWidgetRows(
widget,
contentHeights.get(widget.name),
chromeRowPx,
fitAutoContent ? BOARD_DOCUMENT_AUTO_MAX_ROWS : undefined,
),
order: widget.position,
}));
}
class OpenClawBoardView extends OpenClawLightDomElement {
// Snapshots acknowledge an observer-scoped key; native views retain the query's exact owner.
@property({ attribute: false }) session: BoardGetParams = { sessionKey: "" };
@@ -90,6 +66,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
@property({ type: Boolean }) canMutate = true;
@property({ type: Boolean }) canGrant = true;
@property({ type: Boolean }) fitAutoContent = false;
@property({ attribute: false }) pageWidgetName = "";
@state() private previewItems: BoardGridItem[] | null = null;
@state() private gestureName = "";
@@ -174,6 +151,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
this.gesture &&
(changed.has("snapshot") ||
changed.has("activeTabId") ||
changed.has("pageWidgetName") ||
(changed.has("active") && !this.active))
) {
this.cancelGesture();
@@ -275,7 +253,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
? effectiveBoardWidgetRows(
widget,
this.contentHeights.get(widget.name),
boardChromeRowPx(),
widget.name === this.pageWidgetName ? 0 : boardChromeRowPx(),
)
: widget.sizeH;
await this.applyOps(
@@ -326,6 +304,23 @@ class OpenClawBoardView extends OpenClawLightDomElement {
},
};
selectPageWidgetMenuItem(name: string, revision: number, value: string): void {
const widget = this.snapshot?.widgets.find((entry) => entry.name === name);
if (
!this.active ||
!this.canMutate ||
this.pageWidgetName !== name ||
widget?.revision !== revision ||
widget.tabId !== this.activeTabId
) {
return;
}
const cell = [...this.querySelectorAll("openclaw-board-widget-cell")].find(
(entry) => entry.pageChrome && entry.widget === widget,
);
cell?.selectMenuItem(value);
}
private beginGesture(
mode: BoardPointerGesture["mode"],
widget: BoardWidget,
@@ -341,7 +336,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
return;
}
const snapshot = this.snapshot;
const tabs = snapshot ? orderedTabs(snapshot) : [];
const tabs = snapshot ? orderedBoardTabs(snapshot.tabs) : [];
const tab = this.activeTab(tabs);
if (!snapshot || !tab) {
return;
@@ -353,7 +348,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
} catch {
// Synthetic pointers and detached test targets cannot be captured.
}
const items = itemsForWidgets(orderedWidgets(snapshot, tab.tabId), this.contentHeights);
const items = boardWidgetGridItems(orderedWidgets(snapshot, tab.tabId), this.contentHeights);
this.gesture = {
dropValid: false,
mode,
@@ -393,7 +388,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
candidateTabId !== "" &&
(this.snapshot?.tabs.some((tab) => tab.tabId === candidateTabId) ?? false);
const currentTabId = this.snapshot
? this.activeTab(orderedTabs(this.snapshot))?.tabId
? this.activeTab(orderedBoardTabs(this.snapshot.tabs))?.tabId
: this.activeTabId;
this.hoverTabId = candidateIsValid && candidateTabId !== currentTabId ? candidateTabId : "";
if (tabTarget) {
@@ -522,7 +517,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
if (!snapshot) {
return;
}
const items = itemsForWidgets(orderedWidgets(snapshot, widget.tabId), this.contentHeights);
const items = boardWidgetGridItems(orderedWidgets(snapshot, widget.tabId), this.contentHeights);
const moved = nudge(items, widget.name, direction).find((item) => item.name === widget.name);
if (!moved || moved.order === widget.position) {
return;
@@ -558,7 +553,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
}
private readonly handleTabShow = (event: CustomEvent<{ name: string }>): void => {
const tabs = this.snapshot ? orderedTabs(this.snapshot) : [];
const tabs = this.snapshot ? orderedBoardTabs(this.snapshot.tabs) : [];
const currentTabId = this.activeTab(tabs)?.tabId ?? this.activeTabId;
if (event.detail.name !== currentTabId && tabs.some((tab) => tab.tabId === event.detail.name)) {
this.callbacks?.selectTab(event.detail.name);
@@ -584,7 +579,12 @@ class OpenClawBoardView extends OpenClawLightDomElement {
const tabWidgets = widgets.filter((widget) => widget.tabId === tab.tabId);
const items =
(tab.tabId === activeTabId ? this.previewItems : null) ??
itemsForWidgets(tabWidgets, this.contentHeights, this.fitAutoContent);
boardWidgetGridItems(
tabWidgets,
this.contentHeights,
this.fitAutoContent,
this.pageWidgetName,
);
return layout(items, this.fitAutoContent ? BOARD_DOCUMENT_AUTO_MAX_ROWS : undefined);
});
const activeNames = new Set(
@@ -631,6 +631,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
.rect=${rect}
.contentHeightPx=${this.contentHeights.get(widget.name)}
.fitAutoContent=${this.fitAutoContent}
.pageChrome=${widget.name === this.pageWidgetName}
.tabs=${tabs}
.session=${this.session}
.sessionKey=${sessionKey}
@@ -674,7 +675,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
if (!snapshot) {
return nothing;
}
const tabs = orderedTabs(snapshot);
const tabs = orderedBoardTabs(snapshot.tabs);
const activeTab = this.activeTab(tabs);
const activeTabId = activeTab?.tabId ?? this.activeTabId;
const widgets = activeTab ? orderedWidgets(snapshot, activeTab.tabId) : [];
@@ -73,6 +73,7 @@ export function renderBoardPendingCapabilities(options: {
export function renderBoardGrantedCapabilities(
widget: BoardWidget,
presentation: "tooltip" | "details" = "tooltip",
): TemplateResult | typeof nothing {
if (widget.grantState !== "granted" || !widget.declared) {
return nothing;
@@ -88,6 +89,18 @@ export function renderBoardGrantedCapabilities(
if (capabilities.length === 0) {
return nothing;
}
if (presentation === "details") {
return html`<div
class="board-widget__menu-capabilities"
role="note"
aria-label=${t("board.widget.activeCapabilities")}
>
<strong>${t("board.widget.activeCapabilities")}</strong>
<ul>
${capabilities.map((capability) => html`<li>${capability}</li>`)}
</ul>
</div>`;
}
return html`
<openclaw-tooltip
.content=${`${t("board.widget.activeCapabilities")}\n${capabilities.join("\n")}`}
@@ -20,14 +20,20 @@ export function closeBoardWidgetMenu(root: ParentNode): void {
}
}
export type BoardWidgetPageMenu = {
widget: BoardWidget;
tabs: readonly BoardTab[];
canMutate: boolean;
onSelect: (value: string) => void;
};
export function renderBoardWidgetMenu(options: {
widget: BoardWidget;
tabs: readonly BoardTab[];
disabled: boolean;
onSelect: (event: CustomEvent<{ item: { value?: string } }>) => void;
}): TemplateResult {
const { widget, tabs, disabled, onSelect } = options;
const otherTabs = tabs.filter((tab) => tab.tabId !== widget.tabId);
const { onSelect } = options;
return html`
<wa-dropdown class="board-widget__menu" placement="bottom-end" @wa-select=${onSelect}>
<button
@@ -39,50 +45,67 @@ export function renderBoardWidgetMenu(options: {
>
⋮
</button>
<div class="board-widget__menu-heading">${t("board.widget.moveToTab")}</div>
${
otherTabs.length > 0
? otherTabs.map(
(tab) => html`
<wa-dropdown-item value=${`move:${tab.tabId}`} ?disabled=${disabled}>
${tab.title}
</wa-dropdown-item>
`,
)
: html`<span class="board-widget__menu-empty">${t("board.widget.noOtherTabs")}</span>`
}
<div class="board-widget__menu-heading">${t("board.widget.resize")}</div>
${Object.entries(BOARD_SIZE_PRESETS).map(
([label, size]) => html`
<wa-dropdown-item
${renderBoardWidgetMenuItems(options)}
</wa-dropdown>
`;
}
export function renderBoardWidgetMenuItems(options: {
widget: BoardWidget;
tabs: readonly BoardTab[];
disabled: boolean;
prefix?: string;
}): TemplateResult {
const { widget, tabs, disabled, prefix = "" } = options;
const otherTabs = tabs.filter((tab) => tab.tabId !== widget.tabId);
return html`
<div class="board-widget__menu-heading">${t("board.widget.moveToTab")}</div>
${
otherTabs.length > 0
? otherTabs.map(
(tab) => html`
<wa-dropdown-item value=${`${prefix}move:${tab.tabId}`} ?disabled=${disabled}>
${tab.title}
</wa-dropdown-item>
`,
)
: html`<span class="board-widget__menu-empty">${t("board.widget.noOtherTabs")}</span>`
}
<div class="board-widget__menu-heading">${t("board.widget.resize")}</div>
${Object.entries(BOARD_SIZE_PRESETS).map(
([label, size]) => html`
<wa-dropdown-item
class="board-widget__preset"
value=${`${prefix}resize:${label}`}
?disabled=${disabled}
>
${label.toUpperCase()}
<span slot="details">${size.w}×${size.h}</span>
</wa-dropdown-item>
`,
)}
${
widget.contentKind === "html"
? html`<wa-dropdown-item
class="board-widget__preset"
value=${`resize:${label}`}
type="checkbox"
value=${`${prefix}height:auto`}
?checked=${widget.heightMode !== "fixed"}
?disabled=${disabled}
>
${label.toUpperCase()}
<span slot="details">${size.w}×${size.h}</span>
</wa-dropdown-item>
`,
)}
${
widget.contentKind === "html"
? html`<wa-dropdown-item
class="board-widget__preset"
type="checkbox"
value="height:auto"
?checked=${widget.heightMode !== "fixed"}
?disabled=${disabled}
>
${t("board.widget.autoHeight")}
</wa-dropdown-item>`
: nothing
}
<div class="board-widget__menu-separator" role="separator"></div>
<wa-dropdown-item class="board-widget__menu-danger" value="remove" ?disabled=${disabled}>
<span slot="icon" class="board-widget__menu-icon" aria-hidden="true">${icons.trash}</span>
${t("board.widget.remove")}
</wa-dropdown-item>
</wa-dropdown>
${t("board.widget.autoHeight")}
</wa-dropdown-item>`
: nothing
}
<div class="board-widget__menu-separator" role="separator"></div>
<wa-dropdown-item
class="board-widget__menu-danger"
value=${`${prefix}remove`}
?disabled=${disabled}
>
<span slot="icon" class="board-widget__menu-icon" aria-hidden="true">${icons.trash}</span>
${t("board.widget.remove")}
</wa-dropdown-item>
`;
}
+54 -52
View File
@@ -77,6 +77,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
@property({ attribute: false }) rect?: BoardGridRect;
@property({ attribute: false }) contentHeightPx?: number;
@property({ type: Boolean }) fitAutoContent = false;
@property({ type: Boolean }) pageChrome = false;
@property({ attribute: false }) tabs: readonly BoardTab[] = [];
@property({ attribute: false }) session: BoardGetParams = { sessionKey: "" };
@property({ attribute: false }) sessionKey = "";
@@ -205,15 +206,12 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
void this.runAction(() => callbacks.grant(widget.name, decision), failureMessage);
}
private handleMenuSelect(
event: CustomEvent<{ item: { value?: string } }>,
widget: BoardWidget,
callbacks: BoardWidgetCellCallbacks,
): void {
if (!this.canMutate) {
selectMenuItem(value: string | undefined): void {
const widget = this.widget;
const callbacks = this.callbacks;
if (!widget || !callbacks || !this.active || !this.canMutate) {
return;
}
const value = event.detail.item.value;
if (value === "remove") {
void this.runAction(() => callbacks.remove(widget));
return;
@@ -386,7 +384,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
widget: BoardWidget,
callbacks: BoardWidgetCellCallbacks,
): void {
if (event.target !== event.currentTarget || !this.canMutate) {
if (event.target !== event.currentTarget || !this.canMutate || this.pageChrome) {
return;
}
const direction =
@@ -450,7 +448,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
: exactBoardWidgetHeightPx(
widget,
this.contentHeightPx,
boardChromeRowPx(),
this.pageChrome ? 0 : boardChromeRowPx(),
this.fitAutoContent ? BOARD_DOCUMENT_AUTO_MAX_ROWS : undefined,
);
const exactHeightStyle =
@@ -459,58 +457,62 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
: ` height: calc(${exactHeightPx}px - var(--board-widget-height-trim, 0px)); align-self: start;`;
return html`
<section
class=${`board-widget ${this.dragging ? "board-widget--dragging" : ""} ${presentation ? `board-widget--${presentation}` : ""}`}
class=${`board-widget ${this.pageChrome ? "board-widget--page-chrome" : ""} ${this.dragging ? "board-widget--dragging" : ""} ${presentation ? `board-widget--${presentation}` : ""}`}
style=${`${toCssPlacement(rect)} --board-widget-rows: ${rect.h}; --board-widget-order: ${this.positionInSet};${exactHeightStyle}`}
role="listitem"
tabindex=${this.focusTabIndex}
aria-posinset=${this.positionInSet}
aria-setsize=${this.setSize}
aria-label=${readOnly ? label : t("board.widget.cellLabel", { title: label })}
aria-label=${readOnly || this.pageChrome ? label : t("board.widget.cellLabel", { title: label })}
data-widget-name=${widget.name}
data-test-id="board-widget"
@focus=${() => callbacks.focusChanged(widget.name)}
@keydown=${(event: KeyboardEvent) => this.handleKeyDown(event, widget, callbacks)}
>
<header class="board-widget__bar">
${
readOnly
? nothing
: html`<span
class="board-widget__drag-handle"
aria-hidden="true"
title=${t("board.widget.moveHandle", { title: label })}
@pointerdown=${(event: PointerEvent) => callbacks.movePointerDown(widget, event)}
${
this.pageChrome
? nothing
: html`<header class="board-widget__bar">
${
readOnly
? nothing
: html`<span
class="board-widget__drag-handle"
aria-hidden="true"
title=${t("board.widget.moveHandle", { title: label })}
@pointerdown=${(event: PointerEvent) => callbacks.movePointerDown(widget, event)}
>
<span aria-hidden="true">⠿</span>
</span>`
}
<span class="board-widget__title" title=${label}>${label}</span>
<span class="board-widget__kind"
>${
widget.contentKind === "mcp-app"
? t("board.widget.kindMcp")
: widget.contentKind === "plugin"
? widget.kindLabel ||
getPluginWidgetKindContribution(
widget.pluginKind,
this.context?.gateway.snapshot.hello?.controlUiWidgetKinds ?? [],
)?.label ||
t("board.widget.kindPlugin")
: t("board.widget.kindHtml")
}</span
>
<span aria-hidden="true">⠿</span>
</span>`
}
<span class="board-widget__title" title=${label}>${label}</span>
<span class="board-widget__kind"
>${
widget.contentKind === "mcp-app"
? t("board.widget.kindMcp")
: widget.contentKind === "plugin"
? widget.kindLabel ||
getPluginWidgetKindContribution(
widget.pluginKind,
this.context?.gateway.snapshot.hello?.controlUiWidgetKinds ?? [],
)?.label ||
t("board.widget.kindPlugin")
: t("board.widget.kindHtml")
}</span
>
${renderBoardGrantedCapabilities(widget)}
${
readOnly
? nothing
: renderBoardWidgetMenu({
widget,
tabs: this.tabs,
disabled: this.busy || this.actionPending,
onSelect: (event) => this.handleMenuSelect(event, widget, callbacks),
})
}
</header>
${renderBoardGrantedCapabilities(widget)}
${
readOnly
? nothing
: renderBoardWidgetMenu({
widget,
tabs: this.tabs,
disabled: this.busy || this.actionPending,
onSelect: (event) => this.selectMenuItem(event.detail.item.value),
})
}
</header>`
}
<div
class=${`board-widget__body ${contentScrollable ? "board-widget__body--scrollable" : ""} ${presentation === "card" ? "board-widget__body--card" : ""}`}
>
@@ -524,7 +526,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
}
</div>
${
readOnly
readOnly || this.pageChrome
? nothing
: html`<span
class="board-widget__resize-handle"
@@ -534,7 +536,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
></span>`
}
${
widget.grantState === "granted"
widget.grantState === "granted" && !this.pageChrome
? html`<span class="board-widget__grant-dot" aria-hidden="true"></span>`
: nothing
}
+301
View File
@@ -0,0 +1,301 @@
import path from "node:path";
import type { BoardSnapshot } from "@openclaw/gateway-protocol";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { buildWidgetDocument } from "../../../src/canvas/wrap.js";
import {
controlUiSessionUrl,
installMockGateway,
type MockGatewayControls,
} from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({ name: "single fullscreen widget chrome" });
const sessionKey = "agent:main:dashboard:42d71fe0-1234-4567-8901-234567890abc";
const chrome =
".board-widget__bar, .board-widget__drag-handle, .board-widget__resize-handle, .board-widget__grant-dot";
const menuSelector = "openclaw-chat-header-session-menu";
function snapshot(overrides: Partial<BoardSnapshot> = {}): BoardSnapshot {
return {
sessionKey,
revision: 1,
tabs: [
{ tabId: "main", title: "Overview", position: 0, chatDock: "right" },
{ tabId: "research", title: "Research", position: 1, chatDock: "right" },
],
widgets: [
{
name: "release-status",
tabId: "main",
title: "Release status",
contentKind: "html",
sizeW: 12,
sizeH: 8,
position: 0,
grantState: "granted",
revision: 1,
declared: { tools: ["health"], netOrigins: [] },
},
],
...overrides,
};
}
async function openDashboard(page: Page, source: BoardSnapshot, readOnly = false) {
const frameUrl = new URL("__widget/solo-release-status", suite.server.baseUrl).href;
await page.route(frameUrl, (route) =>
route.fulfill({
contentType: "text/html",
body: buildWidgetDocument(
"Release status",
`<style>body{margin:0;background:#102820;color:#ecfff4;font:16px system-ui;min-height:420px}main{padding:36px}h1{font-size:44px;margin:12px 0}p{color:#b0cebc}input,button{font:inherit;padding:10px;border-radius:8px;border:1px solid #668577}input{display:block;margin-top:28px}button{background:#b7fac8;color:#102820}</style><main><p>RELEASE OVERVIEW</p><h1>Ready for the next release.</h1><p>All checks complete. One uninterrupted dashboard.</p><button onclick="this.textContent='Status refreshed'">Refresh status</button><input aria-label="Release note" placeholder="Add a local note"></main>`,
),
}),
);
const board = { ...source, widgets: source.widgets.map((widget) => ({ ...widget, frameUrl })) };
const gateway = await installMockGateway(page, {
sessionKey,
agentModel: "gpt-4.1",
sessions: [
{
key: sessionKey,
agentId: "main",
sessionId: "solo-widget-session",
kind: "direct",
displayName: "Release overview",
boardFace: "dashboard",
boardPresentation: "expanded",
updatedAt: 1,
model: "gpt-4.1",
modelProvider: "openai",
},
],
operatorScopes: readOnly
? ["operator.read"]
: ["operator.read", "operator.write", "operator.approvals"],
featureMethods: [
"board.get",
"board.update",
"board.widget.grant",
"chat.metadata",
"chat.startup",
],
methodResponses: { "board.get": board },
});
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey, "dashboard"));
await gateway.waitForRequest("board.get");
await page.locator(".board-session-surface").waitFor();
await expect.poll(() => page.locator(".sidebar-region--expanded").count()).toBe(1);
await page.locator(".chat-header-session-menu__trigger").waitFor();
return { gateway, board };
}
async function showHeaderMenu(page: Page) {
await page.locator(".chat-header-session-menu__trigger").click();
const menu = page.locator(menuSelector);
await menu.locator(".board-widget__page-menu-heading").waitFor();
return menu;
}
async function updateBoard(gateway: MockGatewayControls, board: BoardSnapshot) {
await gateway.setMethodResponse("board.get", board);
await gateway.emitGatewayEvent("board.changed", { sessionKey });
}
suite.define(() => {
it("relocates real widget actions, retains the frame, and restores split and multi-widget controls", async () => {
await suite.withPage(
{ viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
async ({ page }) => {
const { gateway, board } = await openDashboard(page, snapshot());
const widget = page.locator('[data-widget-name="release-status"]');
const frame = widget.locator("iframe");
const note = frame.contentFrame().getByRole("textbox", { name: "Release note" });
await note.fill("Keep this local draft");
const original = await frame.elementHandle();
await widget.focus();
expect(await widget.locator(chrome).count()).toBe(0);
await page.screenshot({ path: path.join(suite.artifactDir, "candidate-expanded.png") });
const menu = await showHeaderMenu(page);
await page.keyboard.press("Escape");
await page.locator(".chat-header-session-menu__trigger").focus();
await page.keyboard.press("Enter");
const capabilities = menu.getByRole("note", { name: "Active widget capabilities" });
await capabilities.waitFor({ state: "visible" });
expect(await capabilities.textContent()).toContain("Tool: health");
await menu.locator('[value="board-widget:resize:xl"]').waitFor();
await page.screenshot({ path: path.join(suite.artifactDir, "candidate-header-menu.png") });
const resized = { ...board, revision: 2 };
await gateway.setMethodResponse("board.update", resized);
await menu.locator('[value="board-widget:resize:xl"]').click();
const resize = await gateway.waitForRequest("board.update");
expect(resize.params).toMatchObject({
sessionKey,
agentId: "main",
ops: [
{
kind: "widget_resize",
name: "release-status",
sizeW: 12,
sizeH: 8,
heightMode: "fixed",
},
],
});
expect(await frame.evaluate((element, previous) => element === previous, original)).toBe(
true,
);
expect(await note.inputValue()).toBe("Keep this local draft");
await page.getByRole("button", { name: "Restore split", exact: true }).click();
await widget.locator(".board-widget__menu-trigger").waitFor({ state: "attached" });
await widget.focus();
await expect
.poll(() =>
widget
.locator(".board-widget__bar")
.evaluate((element) => getComputedStyle(element).visibility),
)
.toBe("visible");
expect(await page.locator(menuSelector + ' [value^="board-widget:"]').count()).toBe(0);
await page.screenshot({ path: path.join(suite.artifactDir, "candidate-split.png") });
await page
.locator(".chat-pane__header")
.getByRole("button", { name: "Focus", exact: true })
.click();
await expect.poll(() => widget.locator(chrome).count()).toBe(0);
const multiple = {
...board,
revision: 3,
widgets: [
...board.widgets,
{
...board.widgets[0]!,
name: "second",
title: "Second widget",
position: 1,
grantState: "pending" as const,
},
],
};
await updateBoard(gateway, multiple);
await expect.poll(() => page.locator(".board-widget").count()).toBe(2);
await widget.locator(".board-widget__menu-trigger").waitFor({ state: "attached" });
expect(await page.locator(menuSelector + ' [value^="board-widget:"]').count()).toBe(0);
expect(await frame.evaluate((element, previous) => element === previous, original)).toBe(
true,
);
expect(await note.inputValue()).toBe("Keep this local draft");
await updateBoard(gateway, {
...board,
revision: 4,
widgets: [
board.widgets[0]!,
{
...board.widgets[0]!,
name: "research-widget",
tabId: "research",
title: "Research widget",
},
],
});
await expect.poll(() => widget.locator(chrome).count()).toBe(0);
await page.getByRole("tab", { name: "Research", exact: true }).click();
await page.locator('[data-widget-name="research-widget"]').waitFor();
const researchMenu = await showHeaderMenu(page);
expect(
await researchMenu.locator(".board-widget__page-menu-heading").textContent(),
).toContain("Research widget");
await gateway.setMethodResponse("board.update", { ...board, revision: 5 });
await researchMenu.locator('[value="board-widget:remove"]').click();
const remove = await gateway.waitForRequest("board.update", { after: 1 });
expect(remove.params).toMatchObject({
sessionKey,
ops: [{ kind: "widget_remove", name: "research-widget" }],
});
await page.locator('[data-test-id="board-empty"]').waitFor();
expect(await page.locator(menuSelector + ' [value^="board-widget:"]').count()).toBe(0);
await page.getByRole("tab", { name: "Overview", exact: true }).click();
await frame.waitFor();
expect(await frame.evaluate((element, previous) => element === previous, original)).toBe(
true,
);
expect(await note.inputValue()).toBe("Keep this local draft");
},
);
});
it("keeps pending decisions and failed header operations visible in the fullscreen widget", async () => {
await suite.withPage(
{ viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
async ({ page }) => {
const initial = snapshot();
initial.widgets[0]!.grantState = "pending";
const { gateway, board } = await openDashboard(page, initial);
const widget = page.locator('[data-widget-name="release-status"]');
await widget.getByRole("button", { name: "Allow", exact: true }).waitFor();
expect(await widget.locator(chrome).count()).toBe(0);
const rejected = structuredClone(board);
rejected.revision = 2;
for (const item of rejected.widgets) {
item.grantState = "rejected";
}
await gateway.setMethodResponse("board.widget.grant", rejected);
await widget.getByRole("button", { name: "Reject", exact: true }).click();
expect((await gateway.waitForRequest("board.widget.grant")).params).toMatchObject({
name: "release-status",
decision: "rejected",
});
await widget.locator('[data-test-id="board-rejected"]').waitFor();
const menu = await showHeaderMenu(page);
await gateway.setMethodResponse("board.update", {
__mockError: { code: "UNAVAILABLE", message: "Synthetic dashboard write failure" },
});
await gateway.deferNext("board.get", { sessionKey });
await menu.locator('[value="board-widget:remove"]').click();
await gateway.waitForRequest("board.update");
await widget.locator('[data-test-id="board-widget-action-error"]').waitFor();
await page.screenshot({ path: path.join(suite.artifactDir, "candidate-action-error.png") });
expect(await widget.locator(chrome).count()).toBe(0);
},
);
});
it.each([false, true])(
"keeps the touch fullscreen widget clear with readOnly=%s",
async (readOnly) => {
await suite.withPage(
{
viewport: { width: 393, height: 852 },
hasTouch: true,
isMobile: true,
serviceWorkers: "block",
},
async ({ page }) => {
const { gateway } = await openDashboard(page, snapshot(), readOnly);
const widget = page.locator('[data-widget-name="release-status"]');
await widget
.locator("iframe")
.contentFrame()
.getByRole("textbox", { name: "Release note" })
.waitFor();
expect(
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
).toBe(false);
expect(await widget.locator(chrome).count()).toBe(0);
const menu = await showHeaderMenu(page);
expect(await menu.locator('[value="board-widget:remove"]').count()).toBe(
readOnly ? 0 : 1,
);
expect(await gateway.getRequests("board.update")).toHaveLength(0);
await page.screenshot({
path: path.join(
suite.artifactDir,
readOnly ? "candidate-touch-readonly.png" : "candidate-touch-menu.png",
),
});
},
);
},
);
});
+20
View File
@@ -302,3 +302,23 @@ type BoardWidgetSizingInput = {
presentation?: "card" | "full-bleed" | "frameless";
sizeH: number;
};
export function boardWidgetGridItems(
widgets: readonly (BoardWidgetSizingInput & { name: string; sizeW: number; position: number })[],
contentHeights: ReadonlyMap<string, number>,
fitAutoContent = false,
pageWidgetName = "",
): BoardGridItem[] {
const chromeRowPx = boardChromeRowPx();
return widgets.map((widget) => ({
name: widget.name,
w: widget.sizeW,
h: effectiveBoardWidgetRows(
widget,
contentHeights.get(widget.name),
widget.name === pageWidgetName ? 0 : chromeRowPx,
fitAutoContent ? BOARD_DOCUMENT_AUTO_MAX_ROWS : undefined,
),
order: widget.position,
}));
}
@@ -9,6 +9,7 @@ type BoardSessionSurfaceProps = {
session: BoardGetParams;
snapshot: BoardSnapshot;
activeTabId: string;
pageWidgetName?: string;
canMutate: boolean;
canGrant: boolean;
callbacks: BoardViewCallbacks;
@@ -34,6 +35,7 @@ function renderBoardView(props: BoardSessionSurfaceProps) {
.session=${props.session}
.snapshot=${props.snapshot}
.activeTabId=${props.activeTabId}
.pageWidgetName=${props.pageWidgetName ?? ""}
.widgetFrameUrl=${props.widgetFrameUrl}
.callbacks=${props.callbacks}
.canMutate=${props.canMutate}
+55
View File
@@ -4,8 +4,10 @@ import { GATEWAY_SERVER_CAPS } from "../../../../packages/gateway-protocol/src/i
import type { GatewaySessionRow } from "../../api/types.ts";
import { hasOperatorApprovalsAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts";
import { loadSettings, patchSettings } from "../../app/settings.ts";
import type { BoardWidgetPageMenu } from "../../components/board/board-widget-cell-render.ts";
import { renderPanelLoadingSkeleton } from "../../components/panel-loading-skeleton.ts";
import { t } from "../../i18n/index.ts";
import { BOARD_GRID_COLUMNS } from "../../lib/board/grid.ts";
import {
acquireBoardProviderForSession,
boardProviderCacheKey,
@@ -523,6 +525,58 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
return board.available && Boolean(this.resolveBoardSessionKey(board.snapshot.sessionKey));
}
private fullscreenBoardWidget(layout: SidebarLayout | undefined, board: ResolvedBoardView) {
if (
!layout ||
!this.state ||
!selectedChatSessionRow(this.state) ||
!this.visuallyPresented ||
!board.provider.hasLoadedSnapshot ||
!customElements.get("openclaw-board-view") ||
sidebarDashboardPresentation(layout) !== "expanded"
) {
return undefined;
}
const widgets = board.snapshot.widgets.filter((widget) => widget.tabId === board.activeTabId);
const widget = widgets.length === 1 ? widgets[0] : undefined;
return widget?.sizeW === BOARD_GRID_COLUMNS ? widget : undefined;
}
protected fullscreenBoardWidgetMenu(
layout: SidebarLayout | undefined,
board = this.resolveBoardView(),
): BoardWidgetPageMenu | undefined {
const widget = this.fullscreenBoardWidget(layout, board);
if (!widget) {
return undefined;
}
const session = this.resolveBoardConversation();
session.agentId ??= parseAgentSessionKey(board.snapshot.sessionKey)?.agentId;
return {
widget,
tabs: board.snapshot.tabs,
canMutate: board.provider.canMutate,
onSelect: (value) => {
const current = this.resolveBoardView();
if (
current.provider !== board.provider ||
this.fullscreenBoardWidget(this.state?.sidebarLayout, current) !== widget
) {
return;
}
// The retained board owns actions and errors; the header only relocates its menu.
const view = this.querySelector("openclaw-board-view");
if (
view?.snapshot?.sessionKey === board.snapshot.sessionKey &&
view.session.agentId === session.agentId &&
view.session.sessionKey === session.sessionKey
) {
view.selectPageWidgetMenuItem(widget.name, widget.revision, value);
}
},
};
}
protected renderBoardPanel(board: ResolvedBoardView, layout: SidebarLayout) {
const session = this.resolveBoardConversation();
const sessionKey = this.resolveBoardSessionKey(board.snapshot.sessionKey);
@@ -550,6 +604,7 @@ export abstract class ChatPaneBoard extends ChatPaneHistory {
session,
snapshot: board.snapshot,
activeTabId: board.activeTabId,
pageWidgetName: this.fullscreenBoardWidget(layout, board)?.name,
canMutate: board.provider.canMutate,
canGrant: board.provider.canGrant,
callbacks: {
+1
View File
@@ -644,6 +644,7 @@ export abstract class ChatPaneHeader extends ChatPaneDiscussion {
.settings=${this.state.settings}
.panelActions=${panelMenuActions}
.layoutActions=${layoutMenuActions}
.boardWidgetMenu=${this.fullscreenBoardWidgetMenu(currentLayout)}
.sharing=${sharing}
.groups=${knownGroups}
.currentOwner=${row.owner?.actor ?? null}
@@ -1,6 +1,11 @@
import { html, nothing, type TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
import type { UiSettings } from "../../../app/settings.ts";
import { renderBoardGrantedCapabilities } from "../../../components/board/board-widget-capabilities.ts";
import {
renderBoardWidgetMenuItems,
type BoardWidgetPageMenu,
} from "../../../components/board/board-widget-cell-render.ts";
import { icons } from "../../../components/icons.ts";
import { activateMenuShortcut } from "../../../components/menu-shortcuts.ts";
import {
@@ -63,6 +68,7 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
@property({ attribute: false }) settings: UiSettings = EMPTY_SETTINGS;
@property({ attribute: false }) panelActions: HeaderMenuQuickAction[] = [];
@property({ attribute: false }) layoutActions: HeaderMenuQuickAction[] = [];
@property({ attribute: false }) boardWidgetMenu?: BoardWidgetPageMenu;
@property({ attribute: false }) sharing: ChatSessionSharingProps | null = null;
@property({ attribute: false }) groups: readonly string[] = [];
@property({ attribute: false }) currentOwner: SessionCreatedActor | null = null;
@@ -120,6 +126,10 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
if (!value) {
return;
}
if (value.startsWith("board-widget:")) {
this.boardWidgetMenu?.onSelect(value.slice("board-widget:".length));
return;
}
const compactView = compactSessionMenuViewForValue(value) ?? COMPACT_MENU_VIEW_BY_VALUE[value];
if (compactView) {
event.preventDefault();
@@ -323,6 +333,29 @@ class ChatHeaderSessionMenu extends OpenClawLightDomElement {
<div class="session-menu__separator" role="separator"></div>`
: nothing
}
${
this.boardWidgetMenu
? html`
<div class="board-widget__page-menu-heading">
<strong
>${this.boardWidgetMenu.widget.title || this.boardWidgetMenu.widget.name}</strong
>
</div>
${renderBoardGrantedCapabilities(this.boardWidgetMenu.widget, "details")}
${
this.boardWidgetMenu.canMutate
? renderBoardWidgetMenuItems({
widget: this.boardWidgetMenu.widget,
tabs: this.boardWidgetMenu.tabs,
disabled: false,
prefix: "board-widget:",
})
: nothing
}
<div class="session-menu__separator" role="separator"></div>
`
: nothing
}
${this.renderQuickActions("panels", this.panelActions)}
${this.renderQuickActions("layout", this.layoutActions)}
${
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from
import { createDeferred } from "../../../../test/helpers/promise.js";
import type { GatewaySessionRow, SessionsPatchResult } from "../../api/types.ts";
import { loadSettings, patchSettings } from "../../app/settings.ts";
import type { BoardWidgetPageMenu } from "../../components/board/board-widget-cell-render.ts";
import { t } from "../../i18n/index.ts";
import type { BoardCommandEvent, BoardProvider } from "../../lib/board/provider.ts";
import { sessionsResult } from "../../lib/sessions/session-capability.test-support.ts";
@@ -12,6 +13,7 @@ import { showToast } from "../../lib/toast.ts";
import { createMockBoardProvider } from "../../test-helpers/board-provider.ts";
import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { ensureBoardViewElement } from "./board-session-surface.ts";
import { createChatPaneRails } from "./chat-pane-rails.ts";
import type { ResolvedBoardView } from "./chat-pane-shared.ts";
import { sidebarRegionCallbacks } from "./chat-pane-sidebar-layout.ts";
@@ -51,6 +53,11 @@ vi.mock("../../components/board/board-view.ts", () => {
});
type DashboardPane = TestChatPane & {
visuallyPresented: boolean;
fullscreenBoardWidgetMenu: (
layout: SidebarLayout,
board?: ResolvedBoardView,
) => BoardWidgetPageMenu | undefined;
boardProvider: BoardProvider;
routeFace: "chat" | "dashboard";
dashboardExpanded: boolean;
@@ -243,6 +250,35 @@ afterEach(() => {
});
describe("dashboard default activation and personal layout persistence", () => {
it("relocates only the visible fullscreen widget when a replacement task menu exists", async () => {
await ensureBoardViewElement();
const { pane } = createDashboardHarness();
const board = { ...pane.resolveBoardView(), activeTabId: "research" };
const expanded = openDashboardPresentation({ columns: [] }, "expanded");
expect(pane.fullscreenBoardWidgetMenu(expanded, board)?.widget.name).toBe("source-map");
expect(
pane.fullscreenBoardWidgetMenu(openDashboardPresentation(expanded, "split"), board),
).toBeUndefined();
expect(
pane.fullscreenBoardWidgetMenu(expanded, { ...board, activeTabId: "main" }),
).toBeUndefined();
const narrow = {
...board,
snapshot: {
...board.snapshot,
widgets: board.snapshot.widgets.map((widget) =>
widget.name === "source-map" ? { ...widget, sizeW: 6 } : widget,
),
},
};
expect(pane.fullscreenBoardWidgetMenu(expanded, narrow)).toBeUndefined();
pane.visuallyPresented = false;
expect(pane.fullscreenBoardWidgetMenu(expanded, board)).toBeUndefined();
pane.visuallyPresented = true;
pane.state.sessionsResult = null;
expect(pane.fullscreenBoardWidgetMenu(expanded, board)).toBeUndefined();
});
it.each(["shared", "personal"] as const)(
"opens the %s expanded preference through the registered keyboard handler",
(kind) => {
+31
View File
@@ -565,6 +565,37 @@ openclaw-board-widget-cell {
}
}
/* Page controls live in the task header on mouse and touch alike. */
.board-widget--page-chrome {
grid-template-rows: minmax(0, 1fr);
}
.board-widget__page-menu-heading {
align-items: center;
display: flex;
gap: 8px;
padding: 8px 12px;
}
.board-widget__page-menu-heading strong {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-widget__menu-capabilities {
color: var(--muted);
font-size: 11px;
overflow-wrap: anywhere;
padding: 0 12px 8px;
}
.board-widget__menu-capabilities ul {
margin: 4px 0 0;
padding-left: 16px;
}
/* A lone full-width widget becomes the focused dashboard's page. Keep the
regular grid sizing, subtracting only the shell removed from auto heights. */
:is(.sidebar-region--expanded, .sidebar-region:not(.sidebar-region--open), .board-document__content)