diff --git a/src/gateway/server-methods/chat.abort-persistence.test.ts b/src/gateway/server-methods/chat.abort-persistence.test.ts index 991bea77e1f2..1d6d61638057 100644 --- a/src/gateway/server-methods/chat.abort-persistence.test.ts +++ b/src/gateway/server-methods/chat.abort-persistence.test.ts @@ -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", () => { diff --git a/ui/src/components/board/board-view-sizing.test.ts b/ui/src/components/board/board-view-sizing.test.ts index acc37acac06b..29b51616b1e2 100644 --- a/ui/src/components/board/board-view-sizing.test.ts +++ b/ui/src/components/board/board-view-sizing.test.ts @@ -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(".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 }) }); diff --git a/ui/src/components/board/board-view.browser.test.ts b/ui/src/components/board/board-view.browser.test.ts index 6e8e5cb1387c..31bdd279ce6d 100644 --- a/ui/src/components/board/board-view.browser.test.ts +++ b/ui/src/components/board/board-view.browser.test.ts @@ -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(".board-widget__drag-handle"); - const trigger = bar!.querySelector(".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('[data-test-id="board-widget"]'); + const bar = widget!.querySelector(".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('[data-test-id="board-widget"]'); + const bar = widget!.querySelector(".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(".board-widget__title"); + const kind = bar!.querySelector(".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(".board-widget__body"); diff --git a/ui/src/components/board/board-view.ts b/ui/src/components/board/board-view.ts index 41c420832b24..feacca37b996 100644 --- a/ui/src/components/board/board-view.ts +++ b/ui/src/components/board/board-view.ts @@ -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 { .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; }