From 3fb9e9f581c4ef0e26b5015cf530ccdf1bdd4403 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Mon, 17 Aug 2026 07:50:53 +0000 Subject: [PATCH] fix(dsh-plugin-browserskill): compact overlay toolbar and four-corner resize Use icon-only actions with a short hover tip, swap the interrupt glyph to RiStopCircleLine, and let every corner resize the card without a grip icon. --- packages/dsh-plugin-browserskill/README.md | 7 +- .../src/client/ObservationOverlay.module.css | 106 ++++++-- .../src/client/ObservationOverlay.tsx | 238 ++++++++++++------ .../tests/client/observation-overlay.test.tsx | 80 +++++- 4 files changed, 317 insertions(+), 114 deletions(-) diff --git a/packages/dsh-plugin-browserskill/README.md b/packages/dsh-plugin-browserskill/README.md index 57db443..5123b11 100644 --- a/packages/dsh-plugin-browserskill/README.md +++ b/packages/dsh-plugin-browserskill/README.md @@ -111,7 +111,7 @@ shell's theme cannot bleed back in. - **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on stage while the next one loads, and is kept on errors so the card does not flash), - and the action row (Interrupt + Pop out). + and a compact icon toolbar (Interrupt + Pop out, hover for the label). - **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics as the chat Stop button — the current action fails, the agent run may continue). Strip items carry their own hover interrupt button. @@ -120,8 +120,9 @@ shell's theme cannot bleed back in. again to release); errored sessions get a red edge without stealing focus; sessions the daemon lost are greyed out; prolonged daemon/browser outage shows "browser unavailable" and greys the interrupt button until captures recover. -- **Drag & resize**: drag the header to move the card, drag the corner handle to resize - (min 240×180, max 80% of the viewport); both are remembered for the page lifetime. +- **Drag & resize**: drag the header to move the card; drag any of the four + corners to resize (min 240×180, max 80% of the viewport; no visible grip). + Both are remembered for the page lifetime. - **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user gesture, per browser rules), sized from the current card; closing the PiP falls back to the in-page card with state intact. Browsers without Document PiP simply hide the button. diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css index 288478a..161c5f9 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -118,53 +118,116 @@ } .actions { + position: relative; + z-index: 3; display: flex; align-items: center; justify-content: space-between; - gap: 8px; - padding: 6px 8px; + gap: 4px; + /* A little inset so icons are not flush with the resize corners. */ + padding: 4px 8px; + min-height: 32px; border-top: 1px solid var(--border); } -.interrupt-wrap { +.tool-wrap { position: relative; display: inline-flex; } +.tool-button { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: calc(var(--radius) - 4px); + background: none; + color: var(--muted-foreground); + cursor: pointer; +} + +.tool-button:disabled { + opacity: 0.4; + cursor: default; +} + +.tool-danger { + color: var(--destructive); +} + +/* Hover the wrap (not just :hover on the button) so the tooltip target and + the left-corner resize overlay still light the icon up. */ +.tool-wrap:hover .tool-button:not(:disabled) { + color: var(--foreground); + background: var(--accent); +} + +.tool-wrap:hover .tool-danger:not(:disabled) { + color: var(--destructive); + background: color-mix(in oklch, var(--destructive) 18%, var(--card)); +} + +.tool-wrap:hover .tool-button:disabled { + background: var(--accent); +} + .hint { position: absolute; - bottom: calc(100% + 6px); + bottom: calc(100% + 4px); left: 0; - width: 220px; - padding: 6px 8px; + width: max-content; + max-width: 220px; + padding: 4px 6px; border-radius: calc(var(--radius) - 4px); background: var(--card); border: 1px solid var(--border); color: var(--muted-foreground); font-size: 11px; + line-height: 1.35; box-shadow: 0 4px 14px rgb(0 0 0 / 16%); z-index: 41; } -.resize-handle { - position: absolute; +.hint[data-align="end"] { + left: auto; right: 0; - bottom: 0; - width: 14px; - height: 14px; - cursor: nwse-resize; - touch-action: none; } -.resize-handle::after { - content: ""; +/* Invisible hit targets — no grip glyph; all four corners resize. */ +.resize-handle { position: absolute; - right: 3px; - bottom: 3px; - width: 7px; - height: 7px; - border-right: 2px solid var(--muted-foreground); - border-bottom: 2px solid var(--muted-foreground); + width: 16px; + height: 16px; + touch-action: none; + z-index: 2; +} + +.resize-handle[data-corner="nw"] { + top: 0; + left: 0; + cursor: nwse-resize; +} + +.resize-handle[data-corner="ne"] { + top: 0; + right: 0; + cursor: nesw-resize; +} + +.resize-handle[data-corner="sw"] { + bottom: 0; + left: 0; + cursor: nesw-resize; +} + +.resize-handle[data-corner="se"] { + bottom: 0; + right: 0; + cursor: nwse-resize; } .capsule { @@ -288,6 +351,7 @@ } .icon-button:focus-visible, +.tool-button:focus-visible, .capsule:focus-visible, .strip-main:focus-visible, .strip-interrupt:focus-visible { diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index e6b82a3..1ab0a2e 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -7,20 +7,20 @@ // and oklch tokens on a .bsk-obs scope root, so the card reads as BSK's own // surface without leaking styles into (or inheriting themes from) the shell. -import { Button, cn } from "@browser-skill/ui"; +import { cn } from "@browser-skill/ui"; import { RiArrowDownSLine, RiErrorWarningLine, RiPictureInPicture2Line, RiPushpinFill, - RiStopMiniFill, + RiStopCircleLine, } from "@remixicon/react"; // remixicon's component types target @types/react 19 while the dsh shell // runs react 18 — a compile-time-only recast keeps the 18 typecheck happy. type IconComponent = (props: { size?: number | string; className?: string }) => ReactNode; const asIcon = (component: unknown): IconComponent => component as IconComponent; -const IconStop = asIcon(RiStopMiniFill); +const IconStop = asIcon(RiStopCircleLine); const IconPip = asIcon(RiPictureInPicture2Line); const IconDown = asIcon(RiArrowDownSLine); const IconWarn = asIcon(RiErrorWarningLine); @@ -82,6 +82,31 @@ function clampPos(pos: Point, size: Size, viewport: Size): Point { }; } +export type ResizeCorner = "nw" | "ne" | "sw" | "se"; + +/** Grow/shrink from one corner, keeping the opposite corner planted. */ +export function applyResize( + base: Point & Size, + corner: ResizeCorner, + dx: number, + dy: number, + viewport: Size, +): { pos: Point; size: Size } { + const nextW = corner === "ne" || corner === "se" ? base.w + dx : base.w - dx; + const nextH = corner === "sw" || corner === "se" ? base.h + dy : base.h - dy; + const size = clampSize({ w: nextW, h: nextH }, viewport); + const x = corner === "nw" || corner === "sw" ? base.x + base.w - size.w : base.x; + const y = corner === "nw" || corner === "ne" ? base.y + base.h - size.h : base.y; + return { pos: clampPos({ x, y }, size, viewport), size }; +} + +const CORNER_LABEL: Record = { + nw: "top left", + ne: "top right", + sw: "bottom left", + se: "bottom right", +}; + function formatElapsed(sinceMs: number, nowMs: number): string { const total = Math.max(0, Math.floor((nowMs - sinceMs) / 1000)); const mm = String(Math.floor(total / 60)).padStart(2, "0"); @@ -106,6 +131,41 @@ export function statusOf(obs: SessionObservation): "active" | "idle" | "error" { return obs.action === "idle" ? "idle" : "active"; } +/** Compact toolbar icon: no label, hover bubble for the name / semantics. */ +function IconAction(props: { + label: string; + hint: string; + disabled?: boolean; + danger?: boolean; + align?: "start" | "end"; + onClick: () => void; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( + setOpen(true)} + onPointerLeave={() => setOpen(false)} + > + + {open ? ( + + {props.hint} + + ) : null} + + ); +} + /** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */ function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) { const color = @@ -179,7 +239,7 @@ function StripItem(props: { void store.interrupt(obs.sessionId); }} > - + ) : null} @@ -214,9 +274,6 @@ function OverlayBody(props: { onHeaderPointerDown, } = props; const [interrupting, setInterrupting] = useState(false); - // One-time semantics hint bubble, retired after the first interrupt. - const [hintOpen, setHintOpen] = useState(false); - const [hintSeen, setHintSeen] = useState(false); const thumbId = focus?.thumbnailAttachmentId; useEffect(() => { @@ -236,7 +293,6 @@ function OverlayBody(props: { focus.dead !== true; const onInterrupt = (): void => { if (!canInterrupt || focus === undefined) return; - setHintSeen(true); setInterrupting(true); void store.interrupt(focus.sessionId).finally(() => setInterrupting(false)); }; @@ -306,43 +362,24 @@ function OverlayBody(props: { ) : null}
- { - if (!hintSeen) setHintOpen(true); - }} - onPointerLeave={() => setHintOpen(false)} + - - {/* BSK ships no shared tooltip component; this bubble follows the BSK - card spec (bg-card, border, 12px radius, caption text). */} - {hintOpen && !hintSeen ? ( - - Interrupt stops only the current browser action — the agent run continues (use the - chat Stop button to halt the run). - - ) : null} - + + {onPopOut !== undefined ? ( - + + ) : null}
@@ -370,6 +407,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) const [pinnedId, setPinnedId] = useState(null); const dragRef = useRef<{ kind: "move" | "resize"; + corner?: ResizeCorner; startX: number; startY: number; base: Point & Size; @@ -407,8 +445,10 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) viewport(), ), ); - } else { - setSize(clampSize({ w: drag.base.w + dx, h: drag.base.h + dy }, viewport())); + } else if (drag.corner !== undefined) { + const next = applyResize(drag.base, drag.corner, dx, dy, viewport()); + setPos(next.pos); + setSize(next.size); } }, []); const onPointerUp = useCallback(() => { @@ -417,19 +457,55 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) document.removeEventListener("pointerup", onPointerUp); }, [onPointerMove]); - const beginDrag = (kind: "move" | "resize") => (event: ReactPointerEvent) => { + const cardOrigin = (): Point & Size => { + const vp = viewport(); + return { + x: pos?.x ?? vp.w - size.w - EDGE_MARGIN, + y: pos?.y ?? TOP_OFFSET, + w: size.w, + h: size.h, + }; + }; + + const beginMove = (event: ReactPointerEvent) => { event.preventDefault(); const rect = ( event.currentTarget.closest("[data-obs-card]") as HTMLElement | null )?.getBoundingClientRect(); + const origin = cardOrigin(); const base = { - x: rect?.left ?? pos?.x ?? viewport().w - size.w - EDGE_MARGIN, - y: rect?.top ?? pos?.y ?? TOP_OFFSET, - w: rect?.width ?? size.w, - h: rect?.height ?? size.h, + x: rect?.left ?? origin.x, + y: rect?.top ?? origin.y, + w: rect !== undefined && rect.width > 0 ? rect.width : origin.w, + h: rect !== undefined && rect.height > 0 ? rect.height : origin.h, }; - dragRef.current = { kind, startX: event.clientX, startY: event.clientY, base }; - if (kind === "move") setPos({ x: base.x, y: base.y }); + dragRef.current = { kind: "move", startX: event.clientX, startY: event.clientY, base }; + setPos({ x: base.x, y: base.y }); + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + }; + + const beginResize = (corner: ResizeCorner) => (event: ReactPointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + const rect = ( + event.currentTarget.closest("[data-obs-card]") as HTMLElement | null + )?.getBoundingClientRect(); + const origin = cardOrigin(); + const base = { + x: rect?.left ?? origin.x, + y: rect?.top ?? origin.y, + w: rect !== undefined && rect.width > 0 ? rect.width : origin.w, + h: rect !== undefined && rect.height > 0 ? rect.height : origin.h, + }; + dragRef.current = { + kind: "resize", + corner, + startX: event.clientX, + startY: event.clientY, + base, + }; + setPos({ x: base.x, y: base.y }); document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); }; @@ -463,7 +539,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) inPip={pipWindow !== null} onPopOut={pipWindow === null && pipApi() !== undefined ? () => void popOut() : undefined} onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined} - onHeaderPointerDown={pipWindow === null ? beginDrag("move") : undefined} + onHeaderPointerDown={pipWindow === null ? beginMove : undefined} /> ); @@ -499,34 +575,38 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) return (
{body} -
{ - const step = 16; - const delta = - event.key === "ArrowRight" - ? { w: step, h: 0 } - : event.key === "ArrowLeft" - ? { w: -step, h: 0 } - : event.key === "ArrowDown" - ? { w: 0, h: step } - : event.key === "ArrowUp" - ? { w: 0, h: -step } - : undefined; - if (delta === undefined) return; - event.preventDefault(); - setSize(clampSize({ w: size.w + delta.w, h: size.h + delta.h }, viewport())); - }} - /> + {(["nw", "ne", "sw", "se"] as const).map((corner) => ( +
{ + const step = 16; + const dx = + event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0; + const dy = + event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0; + if (dx === 0 && dy === 0) return; + event.preventDefault(); + const next = applyResize(cardOrigin(), "se", dx, dy, viewport()); + setPos(next.pos); + setSize(next.size); + } + : undefined + } + /> + ))}
); } diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx index ea8ab77..87a4de4 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx @@ -1,12 +1,12 @@ // @vitest-environment happy-dom // ObservationOverlay: visibility lifecycle, focus view, interrupt interaction, -// one-time hint, drag move/resize with clamps, collapse capsule, and PiP +// hover tooltip, drag move/resize from every corner with clamps, collapse capsule, and PiP // upgrade/fallback (mocked documentPictureInPicture). NOTE: use RTL's waitFor // (act-flushing), not vi.waitFor, when asserting store-driven UI updates. import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ObservationOverlay } from "../../src/client/ObservationOverlay"; +import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay"; import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store"; import type { SessionObservation } from "../../src/observation"; @@ -130,16 +130,18 @@ describe("ObservationOverlay", () => { ); }); - it("shows the semantics hint once", async () => { + it("shows a hover tooltip on the interrupt icon and keeps the button icon-only", async () => { const h = makeHarness([BUSY]); render(); const button = await screen.findByRole("button", { name: /Interrupt the current/ }); - fireEvent.pointerEnter(button); - await screen.findByRole("tooltip"); - fireEvent.pointerLeave(button); - fireEvent.click(button); - fireEvent.pointerEnter(button); + expect(button.textContent ?? "").not.toMatch(/Interrupt/i); + fireEvent.pointerEnter(button.parentElement as HTMLElement); + const tip = await screen.findByRole("tooltip"); + expect(tip.textContent).toBe("Stop the current browser action."); + fireEvent.pointerLeave(button.parentElement as HTMLElement); expect(screen.queryByRole("tooltip")).toBeNull(); + fireEvent.pointerEnter(button.parentElement as HTMLElement); + expect(screen.getByRole("tooltip")).toBeTruthy(); }); it("collapses to a capsule and expands back", async () => { @@ -159,17 +161,38 @@ describe("ObservationOverlay", () => { const h = makeHarness([BUSY]); render(); const card = await screen.findByTestId("obs-card"); + card.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + left: 0, + top: 0, + width: 320, + height: 240, + right: 320, + bottom: 240, + toJSON: () => {}, + }) as DOMRect; expect(Number.parseFloat(card.style.width)).toBe(320); - fireEvent.pointerDown(screen.getByTestId("obs-resize"), { clientX: 100, clientY: 100 }); - fireEvent.pointerMove(document, { clientX: 40, clientY: 40 }); + fireEvent.pointerDown(screen.getByTestId("obs-resize-se"), { clientX: 100, clientY: 100 }); + fireEvent.pointerMove(document, { clientX: 0, clientY: 0 }); fireEvent.pointerUp(document); expect(Number.parseFloat(card.style.width)).toBe(240); // min width clamp - fireEvent.pointerDown(screen.getByTestId("obs-resize"), { clientX: 100, clientY: 100 }); + fireEvent.pointerDown(screen.getByTestId("obs-resize-se"), { clientX: 100, clientY: 100 }); fireEvent.pointerMove(document, { clientX: 5000, clientY: 5000 }); fireEvent.pointerUp(document); expect(Number.parseFloat(card.style.width)).toBe(window.innerWidth * 0.8); }); + it("exposes an invisible resize hit target on every corner", async () => { + const h = makeHarness([BUSY]); + render(); + await screen.findByTestId("obs-card"); + for (const corner of ["nw", "ne", "sw", "se"]) { + expect(screen.getByTestId(`obs-resize-${corner}`)).toBeTruthy(); + } + }); + it("moves the card with header drags, clamped to the viewport", async () => { const h = makeHarness([BUSY]); render(); @@ -315,3 +338,38 @@ describe("ObservationOverlay", () => { expect(screen.getByText(/s2 · idle/)).toBeTruthy(); }); }); + +describe("applyResize", () => { + const base = { x: 200, y: 100, w: 320, h: 240 }; + const viewport = { w: 1000, h: 800 }; + + it("grows the planted opposite corner from each handle", () => { + expect(applyResize(base, "se", 20, 10, viewport)).toEqual({ + pos: { x: 200, y: 100 }, + size: { w: 340, h: 250 }, + }); + expect(applyResize(base, "nw", -20, -10, viewport)).toEqual({ + pos: { x: 180, y: 90 }, + size: { w: 340, h: 250 }, + }); + expect(applyResize(base, "ne", 20, -10, viewport)).toEqual({ + pos: { x: 200, y: 90 }, + size: { w: 340, h: 250 }, + }); + expect(applyResize(base, "sw", -20, 10, viewport)).toEqual({ + pos: { x: 180, y: 100 }, + size: { w: 340, h: 250 }, + }); + }); + + it("clamps to the minimum size without drifting the planted corner", () => { + expect(applyResize(base, "se", -200, -200, viewport)).toEqual({ + pos: { x: 200, y: 100 }, + size: { w: 240, h: 180 }, + }); + expect(applyResize(base, "nw", 200, 200, viewport)).toEqual({ + pos: { x: 280, y: 160 }, + size: { w: 240, h: 180 }, + }); + }); +});