mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
fix(ui): dashboard widgets leave a gap below content and hover chrome blocks widget buttons (#114012)
* fix(ui): hug auto widget cards to content and compact board chrome into a top-left pill * test(gateway): retry temp-dir cleanup in chat abort persistence suite to absorb ENOTEMPTY race * fix(ui): strip board pill to move and menu on narrow widgets so the action corner stays widget-owned
This commit is contained in:
committed by
GitHub
parent
5284408470
commit
36404eaa3b
@@ -248,7 +248,11 @@ afterEach(async () => {
|
||||
transcriptFixtures.clear();
|
||||
const dirs = [...fixtureDirs];
|
||||
fixtureDirs.clear();
|
||||
await Promise.all(dirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
// Abort persistence can still be flushing SQLite sidecar files when cleanup
|
||||
// starts; retries absorb the ENOTEMPTY window instead of failing the test.
|
||||
await Promise.all(
|
||||
dirs.map((dir) => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("chat abort transcript persistence", () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { effectiveBoardWidgetRows } from "../../lib/board/grid.ts";
|
||||
import {
|
||||
boardChromeRowPx,
|
||||
effectiveBoardWidgetRows,
|
||||
exactBoardWidgetHeightPx,
|
||||
} from "../../lib/board/grid.ts";
|
||||
// Side-effect import: test-support only type-imports the component, so the
|
||||
// custom element must be registered here for mount() to render anything.
|
||||
import "./board-view.ts";
|
||||
@@ -29,6 +33,46 @@ describe("board widget sizing", () => {
|
||||
expect(effectiveBoardWidgetRows(card, 150, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it("hugs auto cards to their exact content height inside the quantized cell", () => {
|
||||
const card = boardWidget({ sizeH: 6 });
|
||||
// Card inset adds 12px per edge; frameless/full-bleed report bare content.
|
||||
expect(exactBoardWidgetHeightPx(card, 300)).toBe(324);
|
||||
expect(exactBoardWidgetHeightPx({ ...card, presentation: "frameless" }, 300)).toBe(300);
|
||||
// Short content hugs below the 2-row cell minimum: the cell keeps its
|
||||
// minimum span, only the card shrinks.
|
||||
expect(exactBoardWidgetHeightPx(card, 60)).toBe(84);
|
||||
// At the 20-row cap the card fills the cell exactly and the body clips.
|
||||
expect(exactBoardWidgetHeightPx(card, 10_000)).toBe(20 * 56 + 19 * 12);
|
||||
// Coarse-pointer layouts keep the 38px bar in flow, joining the height.
|
||||
expect(exactBoardWidgetHeightPx(card, 300, 38)).toBe(362);
|
||||
expect(exactBoardWidgetHeightPx({ ...card, heightMode: "fixed" }, 300)).toBeUndefined();
|
||||
expect(exactBoardWidgetHeightPx(card, undefined)).toBeUndefined();
|
||||
expect(exactBoardWidgetHeightPx({ ...card, contentKind: "mcp-app" }, 300)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pins the exact reported height on the card and re-fills while dragging", async () => {
|
||||
const view = await mount();
|
||||
const cell = view.querySelector("openclaw-board-widget-cell");
|
||||
const frame = cell?.querySelector("iframe");
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame?.contentWindow ?? null,
|
||||
data: { type: "openclaw:widget-size", height: 300 },
|
||||
}),
|
||||
);
|
||||
const widget = boardWidget();
|
||||
const expected = exactBoardWidgetHeightPx(widget, 300, boardChromeRowPx());
|
||||
const section = () => cell?.querySelector<HTMLElement>(".board-widget");
|
||||
await vi.waitFor(() => {
|
||||
expect(section()?.getAttribute("style")).toContain(`height: ${expected}px`);
|
||||
expect(section()?.getAttribute("style")).toContain("align-self: start");
|
||||
});
|
||||
// Gestures manipulate the quantized cell, so the card fills it again.
|
||||
Reflect.set(cell ?? {}, "dragging", true);
|
||||
await cell?.updateComplete;
|
||||
expect(section()?.getAttribute("style")).not.toContain("align-self");
|
||||
});
|
||||
|
||||
it("pins preset resizing and toggles height mode from the menu", async () => {
|
||||
const applyOps = vi.fn(async () => undefined);
|
||||
const view = await mount({ callbacks: callbacks({ applyOps }) });
|
||||
|
||||
@@ -133,19 +133,80 @@ describe.skipIf(!hasBrowserLayout)("openclaw-board-view browser layout", () => {
|
||||
|
||||
widget!.focus();
|
||||
expect(getComputedStyle(bar!).visibility).toBe("visible");
|
||||
// The revealed strip must not steal clicks from widget content under it;
|
||||
// only real controls (drag handle, kebab) stay interactive.
|
||||
expect(getComputedStyle(bar!).pointerEvents).toBe("none");
|
||||
const handle = bar!.querySelector<HTMLElement>(".board-widget__drag-handle");
|
||||
const trigger = bar!.querySelector<HTMLElement>(".board-widget__menu-trigger");
|
||||
expect(getComputedStyle(handle!).pointerEvents).toBe("auto");
|
||||
expect(getComputedStyle(trigger!).pointerEvents).toBe("auto");
|
||||
// Chrome is a compact top-left pill, not a full-width strip: it must not
|
||||
// stretch across the card, so widget-owned top-right actions stay clear.
|
||||
const barBounds = bar!.getBoundingClientRect();
|
||||
const widgetBounds = widget!.getBoundingClientRect();
|
||||
expect(barBounds.width).toBeLessThan(widgetBounds.width * 0.75);
|
||||
expect(barBounds.left - widgetBounds.left).toBeLessThan(widgetBounds.right - barBounds.right);
|
||||
|
||||
sink.focus();
|
||||
expect(widget!.matches(":focus-within")).toBe(false);
|
||||
await vi.waitFor(() => expectChromeHidden(widget!, bar!));
|
||||
});
|
||||
|
||||
it("reserves the top-right action corner even for narrow long-titled widgets", async () => {
|
||||
const view = await mount();
|
||||
view.snapshot = {
|
||||
...structuredClone(source),
|
||||
widgets: [
|
||||
{
|
||||
...source.widgets[0]!,
|
||||
sizeW: 6,
|
||||
title: "An extremely long widget title that wants the whole bar",
|
||||
grantState: "granted",
|
||||
},
|
||||
],
|
||||
};
|
||||
await view.updateComplete;
|
||||
const cell = view.querySelector("openclaw-board-widget-cell");
|
||||
await cell?.updateComplete;
|
||||
const widget = view.querySelector<HTMLElement>('[data-test-id="board-widget"]');
|
||||
const bar = widget!.querySelector<HTMLElement>(".board-widget__bar");
|
||||
widget!.focus();
|
||||
expect(getComputedStyle(bar!).visibility).toBe("visible");
|
||||
// The interactive pill reserves a widget-owned right-hand region and none
|
||||
// of its children may overflow the capped box into that corner.
|
||||
const widgetBounds = widget!.getBoundingClientRect();
|
||||
expect(widgetBounds.right - bar!.getBoundingClientRect().right).toBeGreaterThanOrEqual(88);
|
||||
for (const child of bar!.children) {
|
||||
expect(child.getBoundingClientRect().right).toBeLessThanOrEqual(
|
||||
bar!.getBoundingClientRect().right + 1,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips the pill to move + menu on widgets too narrow for the reservation", async () => {
|
||||
const view = await mount();
|
||||
view.snapshot = {
|
||||
...structuredClone(source),
|
||||
widgets: [
|
||||
{
|
||||
...source.widgets[0]!,
|
||||
sizeW: 3,
|
||||
title: "An extremely long widget title that wants the whole bar",
|
||||
grantState: "granted",
|
||||
},
|
||||
],
|
||||
};
|
||||
await view.updateComplete;
|
||||
const cell = view.querySelector("openclaw-board-widget-cell");
|
||||
await cell?.updateComplete;
|
||||
const widget = view.querySelector<HTMLElement>('[data-test-id="board-widget"]');
|
||||
const bar = widget!.querySelector<HTMLElement>(".board-widget__bar");
|
||||
widget!.focus();
|
||||
expect(getComputedStyle(bar!).visibility).toBe("visible");
|
||||
// Below the 184px container threshold the display-only pieces disappear so
|
||||
// the pill is the irreducible move + menu pair and the rest of the card
|
||||
// stays widget-owned.
|
||||
expect(widget!.getBoundingClientRect().width).toBeLessThan(184);
|
||||
const title = bar!.querySelector<HTMLElement>(".board-widget__title");
|
||||
const kind = bar!.querySelector<HTMLElement>(".board-widget__kind");
|
||||
expect(getComputedStyle(title!).display).toBe("none");
|
||||
expect(getComputedStyle(kind!).display).toBe("none");
|
||||
expect(bar!.getBoundingClientRect().width).toBeLessThanOrEqual(76);
|
||||
});
|
||||
|
||||
it("keeps widget chrome visible while its menu is open", async () => {
|
||||
const view = await mount();
|
||||
const sink = focusSink();
|
||||
@@ -272,11 +333,9 @@ describe.skipIf(!hasBrowserLayout)("openclaw-board-view browser layout", () => {
|
||||
data: { type: "openclaw:widget-size", height: 300 },
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(Math.round(first.getBoundingClientRect().height)).toBe(
|
||||
BOARD_GRID_ROW_HEIGHT * 5 + BOARD_GRID_GAP * 4,
|
||||
),
|
||||
);
|
||||
// The card hugs its exact content height (300px + 2x12px card inset); the
|
||||
// ceil-to-row slack stays outside the card as grid background.
|
||||
await vi.waitFor(() => expect(Math.round(first.getBoundingClientRect().height)).toBe(324));
|
||||
expect(second.getBoundingClientRect().top).toBeGreaterThan(secondTopBefore);
|
||||
|
||||
const cardBody = first.querySelector<HTMLElement>(".board-widget__body");
|
||||
|
||||
@@ -259,14 +259,10 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
if (!widget || widget.contentKind !== "html") {
|
||||
return;
|
||||
}
|
||||
const chromeRowPx = boardChromeRowPx();
|
||||
const previousRows = effectiveBoardWidgetRows(
|
||||
widget,
|
||||
this.contentHeights.get(name),
|
||||
chromeRowPx,
|
||||
);
|
||||
this.contentHeights.set(name, height);
|
||||
if (effectiveBoardWidgetRows(widget, height, chromeRowPx) !== previousRows) {
|
||||
// Any pixel change matters: the cell renders the exact reported height,
|
||||
// not just the quantized row span.
|
||||
if (this.contentHeights.get(name) !== height) {
|
||||
this.contentHeights.set(name, height);
|
||||
this.requestUpdate();
|
||||
}
|
||||
},
|
||||
@@ -654,6 +650,7 @@ class OpenClawBoardView extends OpenClawLightDomElement {
|
||||
<openclaw-board-widget-cell
|
||||
.widget=${widget}
|
||||
.rect=${rect}
|
||||
.contentHeightPx=${this.contentHeights.get(widget.name)}
|
||||
.tabs=${tabs}
|
||||
.sessionKey=${sessionKey}
|
||||
.widgetFrameUrl=${this.widgetFrameUrl}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { applicationContext, type ApplicationContext } from "../../app/context.t
|
||||
import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { BoardGridDirection, BoardGridRect } from "../../lib/board/grid.ts";
|
||||
import { toCssPlacement } from "../../lib/board/grid.ts";
|
||||
import {
|
||||
boardChromeRowPx,
|
||||
exactBoardWidgetHeightPx,
|
||||
toCssPlacement,
|
||||
} from "../../lib/board/grid.ts";
|
||||
import type { BoardWidgetAppViewState } from "../../lib/board/provider.ts";
|
||||
import type { BoardTab } from "../../lib/board/types.ts";
|
||||
import type {
|
||||
@@ -64,6 +68,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
|
||||
@property({ attribute: false }) widget?: BoardViewWidget;
|
||||
@property({ attribute: false }) rect?: BoardGridRect;
|
||||
@property({ attribute: false }) contentHeightPx?: number;
|
||||
@property({ attribute: false }) tabs: readonly BoardTab[] = [];
|
||||
@property({ attribute: false }) sessionKey = "";
|
||||
@property({ attribute: false }) widgetFrameUrl?: BoardWidgetFrameUrl;
|
||||
@@ -390,10 +395,17 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
bodyScrollable || widget.contentKind === "mcp-app" || widget.contentKind === "plugin";
|
||||
const presentation =
|
||||
widget.contentKind === "html" ? (widget.presentation ?? "card") : undefined;
|
||||
// While a move/resize gesture runs, the card fills its (preview) cell so
|
||||
// the user manipulates the quantized rect they will actually commit.
|
||||
const exactHeightPx = this.dragging
|
||||
? undefined
|
||||
: exactBoardWidgetHeightPx(widget, this.contentHeightPx, boardChromeRowPx());
|
||||
const exactHeightStyle =
|
||||
exactHeightPx === undefined ? "" : ` height: ${exactHeightPx}px; align-self: start;`;
|
||||
return html`
|
||||
<section
|
||||
class=${`board-widget ${this.dragging ? "board-widget--dragging" : ""} ${presentation ? `board-widget--${presentation}` : ""}`}
|
||||
style=${toCssPlacement(rect)}
|
||||
style=${`${toCssPlacement(rect)}${exactHeightStyle}`}
|
||||
role="listitem"
|
||||
tabindex=${this.focusTabIndex}
|
||||
aria-posinset=${this.positionInSet}
|
||||
|
||||
@@ -216,11 +216,11 @@ export function boardChromeRowPx(): number {
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function effectiveBoardWidgetRows(
|
||||
function autoBoardWidgetHeightPx(
|
||||
widget: BoardWidgetSizingInput,
|
||||
contentHeightPx: number | undefined,
|
||||
chromeRowPx = 0,
|
||||
): number {
|
||||
chromeRowPx: number,
|
||||
): number | undefined {
|
||||
// Absent heightMode means auto BY DESIGN, including widgets pinned before
|
||||
// the contract existed: retroactive content-fitting is the product goal, and
|
||||
// one menu click re-pins. Only an explicit "fixed" preserves stored height.
|
||||
@@ -231,18 +231,57 @@ export function effectiveBoardWidgetRows(
|
||||
!Number.isFinite(contentHeightPx) ||
|
||||
contentHeightPx <= 0
|
||||
) {
|
||||
return widget.sizeH;
|
||||
return undefined;
|
||||
}
|
||||
const requiredHeight =
|
||||
return (
|
||||
contentHeightPx +
|
||||
chromeRowPx +
|
||||
((widget.presentation ?? "card") === "card" ? BOARD_WIDGET_FRAME_INSET * 2 : 0);
|
||||
((widget.presentation ?? "card") === "card" ? BOARD_WIDGET_FRAME_INSET * 2 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function boardRowSpanPx(rows: number): number {
|
||||
return rows * BOARD_GRID_ROW_HEIGHT + (rows - 1) * BOARD_GRID_GAP;
|
||||
}
|
||||
|
||||
export function effectiveBoardWidgetRows(
|
||||
widget: BoardWidgetSizingInput,
|
||||
contentHeightPx: number | undefined,
|
||||
chromeRowPx = 0,
|
||||
): number {
|
||||
const requiredHeight = autoBoardWidgetHeightPx(widget, contentHeightPx, chromeRowPx);
|
||||
if (requiredHeight === undefined) {
|
||||
return widget.sizeH;
|
||||
}
|
||||
const rows = Math.ceil(
|
||||
(requiredHeight + BOARD_GRID_GAP) / (BOARD_GRID_ROW_HEIGHT + BOARD_GRID_GAP),
|
||||
);
|
||||
return Math.min(BOARD_WIDGET_AUTO_MAX_ROWS, Math.max(BOARD_WIDGET_AUTO_MIN_ROWS, rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact card height for auto-sized HTML widgets. The grid cell stays quantized
|
||||
* to rows (`effectiveBoardWidgetRows`), but the card hugs its content so the
|
||||
* ceil-to-row slack lands outside the card as background spacing instead of
|
||||
* dead space inside it. Undefined means "fill the cell" (fixed/non-HTML).
|
||||
*/
|
||||
export function exactBoardWidgetHeightPx(
|
||||
widget: BoardWidgetSizingInput,
|
||||
contentHeightPx: number | undefined,
|
||||
chromeRowPx = 0,
|
||||
): number | undefined {
|
||||
const requiredHeight = autoBoardWidgetHeightPx(widget, contentHeightPx, chromeRowPx);
|
||||
if (requiredHeight === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// Never exceed the quantized cell: at the row cap the content is taller than
|
||||
// the cell, so the card fills the cell and the body scrolls/clips as before.
|
||||
return Math.min(
|
||||
requiredHeight,
|
||||
boardRowSpanPx(effectiveBoardWidgetRows(widget, contentHeightPx, chromeRowPx)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Structural sizing inputs so pure grid math stays free of view-type imports. */
|
||||
type BoardWidgetSizingInput = {
|
||||
contentKind: string;
|
||||
|
||||
+63
-21
@@ -743,49 +743,94 @@ openclaw-workboard-mini-widget {
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
/* Focus keeps keyboard actions reachable; menu-open and dragging keep chrome
|
||||
pinned when interaction leaves the card bounds. */
|
||||
pinned when interaction leaves the card bounds. Inline-size containment
|
||||
lets the pill drop to its minimal form on narrow cards; grid tracks set
|
||||
the card's width, so containment changes no layout. */
|
||||
.board-widget {
|
||||
container-type: inline-size;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Chrome is a compact top-left pill, not a full-width strip: widgets put
|
||||
their own actions top-right, so that corner must stay visible and
|
||||
clickable even while host chrome is revealed. The 30px pill height is
|
||||
overlay-only; coarse pointers keep the base 38px in-flow grid row that
|
||||
BOARD_WIDGET_TOUCH_BAR_PX mirrors. */
|
||||
.board-widget__bar {
|
||||
backdrop-filter: blur(6px);
|
||||
background: color-mix(in srgb, var(--board-surface) 92%, transparent);
|
||||
border-radius: 11px 11px 0 0;
|
||||
height: 38px;
|
||||
left: 0;
|
||||
border: 1px solid var(--board-line);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 18%);
|
||||
height: 30px;
|
||||
left: 7px;
|
||||
/* Reserve a widget-owned top-right action region: the interactive pill may
|
||||
never stretch across the card, or it re-intercepts the exact corner this
|
||||
chrome exists to keep clickable. The 88px floor keeps the drag handle and
|
||||
menu usable on very narrow widgets; below 184px the container query strips
|
||||
the pill to that irreducible pair so the reservation stays maximal. */
|
||||
max-width: max(88px, calc(100% - 96px));
|
||||
opacity: 0;
|
||||
padding: 0 5px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
top: 7px;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
visibility 0s linear 120ms;
|
||||
opacity 100ms ease,
|
||||
visibility 0s linear 100ms;
|
||||
visibility: hidden;
|
||||
z-index: 5;
|
||||
/* The overlay strip must not steal clicks from widget content underneath:
|
||||
only real controls stay interactive, everything else passes through. */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Capabilities chip stays interactive for its granted-permissions tooltip;
|
||||
the kind badge and title are display-only and pass clicks through. */
|
||||
.board-widget__bar > .board-widget__title {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* Every flexible child must shrink inside the capped pill; content that
|
||||
overflowed the box would carry pointer events into the reserved corner. */
|
||||
.board-widget__bar > .board-widget__drag-handle,
|
||||
.board-widget__bar > .board-widget__capabilities,
|
||||
.board-widget__bar > .board-widget__menu {
|
||||
pointer-events: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-widget__bar > .board-widget__kind,
|
||||
.board-widget__bar > .board-widget__capabilities {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Below 184px the 96px reservation and the 88px pill cannot coexist, so the
|
||||
pill sheds its display-only pieces and keeps just move + menu — the two
|
||||
controls without which a widget cannot be managed. The card's aria-label
|
||||
and the drag handle tooltip still carry the title. */
|
||||
@container (max-width: 184px) {
|
||||
.board-widget__bar > .board-widget__title,
|
||||
.board-widget__bar > .board-widget__kind,
|
||||
.board-widget__bar > .board-widget__capabilities {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.board-widget__resize-handle {
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
visibility 0s linear 120ms;
|
||||
opacity 100ms ease,
|
||||
visibility 0s linear 100ms;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Hover-intent: the pointer must rest on the card briefly before chrome
|
||||
appears, so quick clicks into widget content never race the overlay.
|
||||
Delay-only overrides keep the base fade, and reduced-motion's
|
||||
transition: none stays authoritative because no transition-property is
|
||||
redeclared here. */
|
||||
.board-widget:hover .board-widget__bar,
|
||||
.board-widget:hover .board-widget__resize-handle,
|
||||
.board-widget:hover .board-widget__resize-handle {
|
||||
opacity: 1;
|
||||
transition-delay: 350ms;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Keyboard focus, an open menu, and active drags need chrome immediately. */
|
||||
.board-widget:focus-within .board-widget__bar,
|
||||
.board-widget:focus-within .board-widget__resize-handle,
|
||||
.board-widget--dragging .board-widget__bar,
|
||||
@@ -793,9 +838,6 @@ openclaw-workboard-mini-widget {
|
||||
.board-widget:has(.board-widget__menu[open]) .board-widget__bar,
|
||||
.board-widget:has(.board-widget__menu[open]) .board-widget__resize-handle {
|
||||
opacity: 1;
|
||||
/* Delay-only override: reveal flips visibility immediately while the base
|
||||
shorthand keeps the fade, and reduced-motion's transition: none stays
|
||||
authoritative because no transition-property is redeclared here. */
|
||||
transition-delay: 0s;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user