mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user