// Observation overlay: the default in-app carrier for live session watching. // Expanded = draggable/resizable floating card (top-right, out of the // composer's way); collapsed = a status capsule; "pop out" upgrades the same // content to a native Document PiP window (user gesture required by the // browser). Multi-session renders a meeting-style strip under the focus view. // When the dsh-better-sidebar plugin is installed the tracking view moves // into a sidebar tab instead (see observation-sidebar.tsx) and this floating // card hides itself via the sidebar-mode flag. Visuals follow the // BrowserSkill product family: @browser-skill/ui components 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 { cn } from "@browser-skill/ui"; import { RiArrowDownSLine, RiCheckLine, RiCloseCircleLine, RiCloseLine, RiErrorWarningLine, RiPictureInPicture2Line, RiPushpinFill, 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(RiStopCircleLine); const IconPip = asIcon(RiPictureInPicture2Line); const IconDown = asIcon(RiArrowDownSLine); const IconClose = asIcon(RiCloseLine); const IconWarn = asIcon(RiErrorWarningLine); const IconPin = asIcon(RiPushpinFill); const IconCloseSession = asIcon(RiCloseCircleLine); const IconCheck = asIcon(RiCheckLine); import { type ReactNode, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState, useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; import type { SessionObservation } from "../observation"; import css from "./ObservationOverlay.module.css"; import type { ObservationClientStore } from "./observation-store"; import { focusOf, statusOf, useObservationView, usePip, useThumbnailObservation, } from "./observation-view"; import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode"; // The pure view helpers live in observation-view (shared with the sidebar // tab); re-export so existing imports of this module keep working. export { focusOf, statusOf }; interface Point { x: number; y: number; } interface Size { w: number; h: number; } const DEFAULT_SIZE: Size = { w: 320, h: 240 }; const MIN_SIZE: Size = { w: 240, h: 180 }; const EDGE_MARGIN = 16; /** Default dock: top-right, just under the shell's top bar (no spacing tokens exist in dsh 0.1). */ const TOP_OFFSET = 64; function clampSize(size: Size, viewport: Size): Size { const maxW = viewport.w * 0.8; const maxH = viewport.h * 0.8; return { w: Math.min(Math.max(size.w, MIN_SIZE.w), maxW), h: Math.min(Math.max(size.h, MIN_SIZE.h), maxH), }; } function clampPos(pos: Point, size: Size, viewport: Size): Point { return { x: Math.min(Math.max(pos.x, 0), Math.max(0, viewport.w - size.w)), y: Math.min(Math.max(pos.y, 0), Math.max(0, viewport.h - size.h)), }; } 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"); const ss = String(total % 60).padStart(2, "0"); return `${mm}:${ss}`; } /** 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} ); } /** How long the stop button stays armed before the confirm click expires. */ const STOP_ARM_MS = 3000; /** * Stop-session button with a lightweight two-click confirm: the first click * arms the button (solid red, check icon, short expiry), the second executes * the stop. No dialog, no layout shift — and a stray single click can never * close an Agent Window. */ function StopSessionAction(props: { sessionId: string | undefined; onStop: (sessionId: string) => Promise; }) { const { sessionId, onStop } = props; const [hover, setHover] = useState(false); const [armed, setArmed] = useState(false); const [stopping, setStopping] = useState(false); useEffect(() => { if (!armed) return; const timer = setTimeout(() => setArmed(false), STOP_ARM_MS); return () => clearTimeout(timer); }, [armed]); // The focused session can vanish mid-arm (stopped elsewhere): disarm. useEffect(() => { if (sessionId === undefined) setArmed(false); }, [sessionId]); const disabled = sessionId === undefined || stopping; const label = stopping ? "Stopping session…" : armed ? `Confirm stop session ${sessionId ?? ""}` : `Stop session ${sessionId ?? ""}`; const hint = stopping ? "Closing the Agent Window…" : armed ? `Click again to stop session ${sessionId ?? ""} and close its Agent Window.` : `Stop session ${sessionId ?? ""} and close its Agent Window.`; return ( setHover(true)} onPointerLeave={() => setHover(false)} > {hover ? ( {hint} ) : null} ); } /** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */ function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) { const color = state === "active" ? "bg-emerald-500" : state === "error" ? "bg-red-500" : state === "dead" ? "bg-amber-500" : "bg-muted-foreground/40"; return ( ); } /** One strip item: mini frame, id, status dot, hover interrupt, pin toggle. */ function StripItem(props: { store: ObservationClientStore; obs: SessionObservation; pinned: boolean; focused: boolean; onTogglePin: (sessionId: string) => void; }) { const { store, obs, pinned, focused, onTogglePin } = props; const [hover, setHover] = useState(false); const thumbId = obs.thumbnailAttachmentId; useEffect(() => { store.ensureThumbnail(thumbId); }, [store, thumbId]); const thumb = store.getSnapshot().displayFrames[obs.sessionId]; const state = obs.dead === true ? "dead" : statusOf(obs); return (
setHover(true)} onPointerLeave={() => setHover(false)} > {hover && !pinned && obs.dead !== true ? ( ) : null}
); } /** The floating card / PiP / sidebar-tab shared content. */ export function OverlayBody(props: { store: ObservationClientStore; focus: SessionObservation | undefined; sessions: readonly SessionObservation[]; available: boolean; pinnedId: string | null; onTogglePin: (sessionId: string) => void; now: number; onPopOut?: (() => void) | undefined; onCollapse?: (() => void) | undefined; onClosePip?: (() => void) | undefined; inPip: boolean; onHeaderPointerDown?: (event: ReactPointerEvent) => void; }) { const { store, focus, sessions, available, pinnedId, onTogglePin, now, onPopOut, onCollapse, onClosePip, inPip, onHeaderPointerDown, } = props; const [interrupting, setInterrupting] = useState(false); const viewRef = useThumbnailObservation(store, sessions.length > 0); const thumbId = focus?.thumbnailAttachmentId; useEffect(() => { store.ensureThumbnail(thumbId); }, [store, thumbId]); // Paint the last good frame while the next attachment decodes — swapping // to a placeholder (and remounting with a fade) is what made the // card flash on every breath / action-end capture. const thumb = focus !== undefined ? store.getSnapshot().displayFrames[focus.sessionId] : undefined; const displayUrl = thumb?.url; const canInterrupt = available && !interrupting && focus !== undefined && focus.action !== "idle" && focus.dead !== true; const onInterrupt = (): void => { if (!canInterrupt || focus === undefined) return; setInterrupting(true); void store.interrupt(focus.sessionId).finally(() => setInterrupting(false)); }; const statusText = !available ? "browser unavailable" : focus === undefined ? "no session" : `${focus.sessionId} · ${focus.action === "idle" ? "idle" : focus.action} · ${formatElapsed(focus.since, now)}`; const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle"; return (
{statusText} {onCollapse !== undefined ? ( ) : null} {onClosePip !== undefined ? ( ) : null}
{displayUrl !== undefined ? ( {`session ) : (
{!available ? "last frame kept" : thumb?.status === "error" ? "frame unavailable" : "waiting for page"}
)} {thumb?.status === "error" ? ( ) : null}
{sessions.length >= 2 ? (
{sessions.map((obs) => ( ))}
) : null}
store.stopSession(sessionId)} />
{onPopOut !== undefined ? ( ) : null}
); } export function ObservationOverlay({ store }: { store: ObservationClientStore }) { const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store); // While the better-sidebar plugin carries the tracking view, this floating // card (and its capsule) stays out of the way; an already-open PiP window // keeps its portal until the user closes it. const sidebarMode = useSyncExternalStore(subscribeSidebarMode, getSidebarMode); const [collapsed, setCollapsed] = useState(false); const [pos, setPos] = useState(null); const [size, setSize] = useState(DEFAULT_SIZE); const { pipWindow, pipSupported, popOut } = usePip(); const dragRef = useRef<{ kind: "move" | "resize"; corner?: ResizeCorner; startX: number; startY: number; base: Point & Size; } | null>(null); const viewport = (): Size => ({ w: window.innerWidth, h: window.innerHeight }); const onPointerMove = useCallback((event: PointerEvent) => { const drag = dragRef.current; if (drag === null) return; const dx = event.clientX - drag.startX; const dy = event.clientY - drag.startY; if (drag.kind === "move") { setPos( clampPos( { x: drag.base.x + dx, y: drag.base.y + dy }, { w: drag.base.w, h: drag.base.h }, 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(() => { dragRef.current = null; document.removeEventListener("pointermove", onPointerMove); document.removeEventListener("pointerup", onPointerUp); }, [onPointerMove]); 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 ?? 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: "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); }; const body = ( popOut({ width: size.w, height: size.h }) : undefined } onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined} onClosePip={pipWindow !== null ? () => pipWindow.close() : undefined} onHeaderPointerDown={pipWindow === null ? beginMove : undefined} /> ); if (pipWindow !== null) { return createPortal(body, pipWindow.document.body); } // The sidebar tab is the carrier now — no floating card, no capsule. if (sidebarMode) return null; // Hidden while no owned session exists (and no PiP is up). if (snapshot.sessions.length === 0) return null; if (collapsed) { const state = focus !== undefined ? statusOf(focus) : "idle"; return ( ); } const style: React.CSSProperties = pos !== null ? { left: pos.x, top: pos.y, width: size.w, height: size.h } : { right: EDGE_MARGIN, top: TOP_OFFSET, width: size.w, height: size.h }; return (
{body} {(["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 } /> ))}
); }