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.
This commit is contained in:
BB-fat
2026-08-17 07:50:53 +00:00
parent 9854ee2f45
commit 3fb9e9f581
4 changed files with 317 additions and 114 deletions
+4 -3
View File
@@ -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.
@@ -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 {
@@ -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<ResizeCorner, string> = {
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 (
<span
className={css["tool-wrap"]}
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => setOpen(false)}
>
<button
type="button"
className={cn(css["tool-button"], props.danger === true && css["tool-danger"])}
disabled={props.disabled}
aria-label={props.label}
onClick={props.onClick}
>
{props.children}
</button>
{open ? (
<span className={css.hint} data-align={props.align} role="tooltip">
{props.hint}
</span>
) : null}
</span>
);
}
/** 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);
}}
>
<IconStop size={9} />
<IconStop size={10} />
</button>
) : null}
</div>
@@ -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: {
</div>
) : null}
<div className={css.actions}>
<span
className={css["interrupt-wrap"]}
onPointerEnter={() => {
if (!hintSeen) setHintOpen(true);
}}
onPointerLeave={() => setHintOpen(false)}
>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/60 hover:bg-destructive/10"
<IconAction
label={interrupting ? "Interrupting…" : "Interrupt the current browser action"}
hint="Stop the current browser action."
disabled={!canInterrupt}
aria-label="Interrupt the current browser action"
danger
onClick={onInterrupt}
>
<IconStop size={14} />
{interrupting ? "Interrupting…" : "Interrupt"}
</Button>
{/* BSK ships no shared tooltip component; this bubble follows the BSK
card spec (bg-card, border, 12px radius, caption text). */}
{hintOpen && !hintSeen ? (
<span className={css.hint} role="tooltip">
Interrupt stops only the current browser action — the agent run continues (use the
chat Stop button to halt the run).
</span>
) : null}
</span>
<IconStop size={16} />
</IconAction>
{onPopOut !== undefined ? (
<Button
variant="ghost"
size="sm"
aria-label="Pop out into a mini window"
<IconAction
label="Pop out into a mini window"
hint="Pop out into a mini window"
align="end"
onClick={onPopOut}
>
<IconPip size={14} />
Pop out
</Button>
<IconPip size={16} />
</IconAction>
) : null}
</div>
</div>
@@ -370,6 +407,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
const [pinnedId, setPinnedId] = useState<string | null>(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 (
<div className={cn(css.card, "bsk-obs")} style={style} data-obs-card data-testid="obs-card">
{body}
{(["nw", "ne", "sw", "se"] as const).map((corner) => (
<div
key={corner}
className={css["resize-handle"]}
data-testid="obs-resize"
aria-label="Resize overlay"
data-corner={corner}
data-testid={`obs-resize-${corner}`}
aria-label={`Resize overlay from the ${CORNER_LABEL[corner]}`}
role="separator"
aria-valuenow={size.w}
aria-valuetext={`${Math.round(size.w)} by ${Math.round(size.h)} pixels`}
aria-valuemin={MIN_SIZE.w}
aria-valuemax={Math.round(viewport().w * 0.8)}
tabIndex={0}
onPointerDown={beginDrag("resize")}
onKeyDown={(event) => {
tabIndex={corner === "se" ? 0 : -1}
onPointerDown={beginResize(corner)}
onKeyDown={
corner === "se"
? (event) => {
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;
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();
setSize(clampSize({ w: size.w + delta.w, h: size.h + delta.h }, viewport()));
}}
const next = applyResize(cardOrigin(), "se", dx, dy, viewport());
setPos(next.pos);
setSize(next.size);
}
: undefined
}
/>
))}
</div>
);
}
@@ -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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
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(<ObservationOverlay store={h.store} />);
@@ -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 },
});
});
});