diff --git a/packages/dsh-plugin-browserskill/src/archive-cleanup.ts b/packages/dsh-plugin-browserskill/src/archive-cleanup.ts new file mode 100644 index 00000000..c919b80e --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/archive-cleanup.ts @@ -0,0 +1,108 @@ +/** + * Archive-triggered bsk session cleanup. DSH conversation archival writes + * the workspace registry's durable global state, which the storage domain + * broadcasts as `domain/changed` ({domain: 'workspace', table: ''}) with + * the new `archivedSessionIds`. The watcher diffs that set and stops every + * bsk session owned by a freshly archived conversation — archived sessions + * are hidden from every surface, so their Agent Windows would otherwise + * linger with no way back to them. + * + * Ownership lineage: browser_session_start records the calling agent's + * session id PLUS every ancestor along `header.parentSession` (a subagent's + * browsers are reaped when any ancestor is archived, the root conversation + * included). Lineage is resolved through the host session store; an + * unloaded ancestor simply ends the walk. + */ + +import type { Context } from "@deepseek-ai/cordis"; +import type { ObservationService } from "./observation"; +import type { SessionRegistry } from "./sessions"; + +/** The wire shape of a `domain/changed` frame (see dsh-storage-domain). */ +interface DomainChange { + domain?: string; + table?: string; + value?: unknown; +} + +/** The workspace registry's durable global singleton slice we read. */ +interface WorkspaceGlobal { + archivedSessionIds?: unknown; +} + +/** Structural host session store face: only the lineage read is needed. */ +interface SessionStoreLike { + get(id: string): { header: { parentSession?: string } } | undefined; +} + +/** Cap on lineage walks — defensive against a malformed parent chain. */ +const MAX_LINEAGE_DEPTH = 16; + +/** + * The DSH session ids that own a tool call's browser sessions: the calling + * agent's own session plus every ancestor along the seed lineage. Empty + * when the call carried no agent identity (those sessions outlive any + * archive cleanup by design — nothing can name their owner). + */ +export function ownerSessionIds(ctx: Context, agentId: string | undefined): string[] { + if (agentId === undefined) return []; + const store = ctx.get("sessions") as SessionStoreLike | undefined; + const ids: string[] = []; + let current: string | undefined = agentId; + for (let depth = 0; current !== undefined && depth < MAX_LINEAGE_DEPTH; depth += 1) { + if (ids.includes(current)) break; // a cycle in stored headers must not loop + ids.push(current); + current = store?.get(current)?.header.parentSession; + } + return ids; +} + +/** + * Watch conversation archival and stop the bsk sessions it opened. Returns + * the disposer (plugin unload). In compositions without the workspace + * domain (headless), the event simply never fires — and a context without + * the events mixin degrades to a no-op like the other optional seams. + */ +export function armArchiveCleanup( + ctx: Context, + registry: SessionRegistry, + observation: ObservationService, +): () => void { + // 'domain/changed' lives outside the vendored Events type map, so the + // listener goes through a structural view of the events mixin. + const on = ( + ctx as { on?: (event: string, listener: (change: DomainChange) => void) => () => void } + ).on; + if (typeof on !== "function") return () => {}; + + /** Archived ids already accounted for; lazily seeded from the registry. */ + let seen: Set | undefined; + const initialize = (): Set => { + if (seen === undefined) { + const registryService = ctx.get("workspaceRegistry") as + | { archivedSessionIds?: readonly string[] } + | undefined; + seen = new Set(registryService?.archivedSessionIds ?? []); + } + return seen; + }; + + return on.call(ctx, "domain/changed", (change: DomainChange) => { + if (change?.domain !== "workspace" || change?.table !== "") return; + const archived = (change.value as WorkspaceGlobal | undefined)?.archivedSessionIds; + if (!Array.isArray(archived)) return; + const previous = initialize(); + const fresh = archived.filter( + (id): id is string => typeof id === "string" && !previous.has(id), + ); + seen = new Set(archived.filter((id): id is string => typeof id === "string")); + for (const dshSessionId of fresh) { + for (const sessionId of registry.ownedByDsh(dshSessionId)) { + // stopSession owns the full teardown (kill in-flight tools, queue + // the daemon stop, drop registry + observation entries); a failure + // just leaves the session for idle timeout or unload cleanup. + void observation.stopSession(sessionId).catch(() => {}); + } + } + }); +} diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css index c640261f..5bc6c413 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -30,17 +30,51 @@ color: var(--card-foreground); } +/* Sidebar carrier: fill the better-sidebar tab area and let the body flex + (the tab content container owns the outer sizing). */ +.sidebar-tab { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.sidebar-tab > * { + flex: 1; + min-height: 0; +} + +/* The BSK product mark in the sidebar tab strip (the source artwork already + carries rounded corners — just soften the square to match). */ +.brand-icon { + display: block; + border-radius: 22%; +} + .header { display: flex; align-items: center; gap: 6px; padding: 6px 8px; - cursor: move; user-select: none; touch-action: none; border-bottom: 1px solid var(--border); } +/* Only the floating card drags by its header — the sidebar tab does not. */ +.header[data-draggable] { + cursor: move; +} + +/* In the sidebar the status row is chrome, not a card edge: drop its bottom + border. A fixed-height bordered row can never track the shell header's + hairline across the panel boundary (its height varies per window), and + the BSK warm border tint clashes with the sidebar's neutral hairlines — + borderless, the stage's own background provides the separation. */ +.sidebar-tab .header { + border-bottom: none; +} + .status-text { flex: 1; min-width: 0; @@ -131,6 +165,12 @@ border-top: 1px solid var(--border); } +.actions-group { + display: inline-flex; + align-items: center; + gap: 4px; +} + .tool-wrap { position: relative; display: inline-flex; @@ -165,8 +205,14 @@ 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. */ +/* Stop session: danger-tinted like interrupt. */ +.tool-stop { + color: var(--destructive); +} + +/* Hover the wrap (not just :hover on the button) so the tooltip target + lights the icon up too. The corner resize handles sit above this bar, so + the outer sliver of the corner-most buttons starts a resize instead. */ .tool-wrap:hover .tool-button:not(:disabled) { color: var(--foreground); background: var(--accent); @@ -181,6 +227,15 @@ background: var(--accent); } +/* The armed (confirm) state of the stop button: solid destructive fill so + the second click reads as final. Written at the wrap-hover specificity so + the hover rules above never wash it out. */ +.tool-button.tool-stop-armed, +.tool-wrap:hover .tool-button.tool-stop-armed { + color: var(--destructive-foreground); + background: var(--destructive); +} + .hint { position: absolute; bottom: calc(100% + 4px); @@ -203,13 +258,16 @@ right: 0; } -/* Invisible hit targets — no grip glyph; all four corners resize. */ +/* Invisible hit targets — no grip glyph; all four corners resize. Must sit + above the bottom .actions bar (z-index 3): it spans the full card width + and would otherwise swallow every pointer event aimed at the sw/se + corners. The top corners already win because .header is not positioned. */ .resize-handle { position: absolute; width: 16px; height: 16px; touch-action: none; - z-index: 2; + z-index: 4; } .resize-handle[data-corner="nw"] { diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index d500aeb1..edca403f 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -3,13 +3,18 @@ // 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. -// 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. +// 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, RiErrorWarningLine, RiPictureInPicture2Line, RiPushpinFill, @@ -25,6 +30,8 @@ const IconPip = asIcon(RiPictureInPicture2Line); const IconDown = asIcon(RiArrowDownSLine); const IconWarn = asIcon(RiErrorWarningLine); const IconPin = asIcon(RiPushpinFill); +const IconCloseSession = asIcon(RiCloseCircleLine); +const IconCheck = asIcon(RiCheckLine); import { type ReactNode, @@ -39,11 +46,12 @@ 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 } from "./observation-view"; +import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode"; -/** Minimal Document PiP surface (TS lib.dom lacks it). */ -interface DocumentPip { - requestWindow(options?: { width?: number; height?: number }): Promise; -} +// 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; @@ -61,11 +69,6 @@ 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 pipApi(): DocumentPip | undefined { - if (typeof window === "undefined") return undefined; - return (window as unknown as { documentPictureInPicture?: DocumentPip }).documentPictureInPicture; -} - function clampSize(size: Size, viewport: Size): Size { const maxW = viewport.w * 0.8; const maxH = viewport.h * 0.8; @@ -114,23 +117,6 @@ function formatElapsed(sinceMs: number, nowMs: number): string { return `${mm}:${ss}`; } -/** - * Auto-follow focus: the most recently touched session, but never yank focus - * to a session whose latest action failed (errors flag the strip item, they - * do not steal the stage) or one already reported dead. - */ -export function focusOf(sessions: readonly SessionObservation[]): SessionObservation | undefined { - if (sessions.length === 0) return undefined; - const byRecency = [...sessions].sort((a, b) => b.since - a.since); - const healthy = byRecency.find((s) => s.lastError === undefined && s.dead !== true); - return healthy ?? byRecency[0]; -} - -export function statusOf(obs: SessionObservation): "active" | "idle" | "error" { - if (obs.lastError !== undefined && obs.action === "idle") return "error"; - return obs.action === "idle" ? "idle" : "active"; -} - /** Compact toolbar icon: no label, hover bubble for the name / semantics. */ function IconAction(props: { label: string; @@ -166,6 +152,83 @@ function IconAction(props: { ); } +/** 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 = @@ -246,8 +309,8 @@ function StripItem(props: { ); } -/** The floating card / PiP shared content. */ -function OverlayBody(props: { +/** The floating card / PiP / sidebar-tab shared content. */ +export function OverlayBody(props: { store: ObservationClientStore; focus: SessionObservation | undefined; sessions: readonly SessionObservation[]; @@ -310,6 +373,7 @@ function OverlayBody(props: {
@@ -363,15 +427,21 @@ function OverlayBody(props: {
) : null}
- - - +
+ + + + store.stopSession(sessionId)} + /> +
{onPopOut !== undefined ? ( { - store.start(); - return () => store.stop(); - }, [store]); + 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, setPipWindow] = useState(null); - const [pinnedId, setPinnedId] = useState(null); + const { pipWindow, pipSupported, popOut } = usePip(); const dragRef = useRef<{ kind: "move" | "resize"; corner?: ResizeCorner; @@ -414,23 +476,6 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) base: Point & Size; } | null>(null); - // Elapsed-time ticker: 1s while anything is active. - const anyActive = snapshot.sessions.some((s) => s.action !== "idle"); - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - if (!anyActive) return; - const timer = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [anyActive]); - - // Focus: the pinned session wins while it still exists; otherwise auto-follow. - const pinned = - pinnedId !== null ? snapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined; - const focus = pinned ?? focusOf(snapshot.sessions); - const onTogglePin = useCallback((sessionId: string) => { - setPinnedId((current) => (current === sessionId ? null : sessionId)); - }, []); - const viewport = (): Size => ({ w: window.innerWidth, h: window.innerHeight }); const onPointerMove = useCallback((event: PointerEvent) => { @@ -511,34 +556,21 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) document.addEventListener("pointerup", onPointerUp); }; - const popOut = async (): Promise => { - const pip = pipApi(); - if (pip === undefined) return; - try { - const win = await pip.requestWindow({ width: size.w, height: size.h }); - cloneStylesInto(win); - win.addEventListener("pagehide", () => setPipWindow(null)); - setPipWindow(win); - } catch { - // requestWindow rejects without a user gesture (or when the window was - // closed mid-request): stay on the in-page card, no state change. - } - }; - - // Hidden while no owned session exists (and no PiP is up). - if (snapshot.sessions.length === 0 && pipWindow === null) return null; - const body = ( void popOut() : undefined} + onPopOut={ + pipWindow === null && pipSupported + ? () => popOut({ width: size.w, height: size.h }) + : undefined + } onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined} onHeaderPointerDown={pipWindow === null ? beginMove : undefined} /> @@ -548,6 +580,12 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) 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 ( diff --git a/packages/dsh-plugin-browserskill/src/client/brand-icon.ts b/packages/dsh-plugin-browserskill/src/client/brand-icon.ts new file mode 100644 index 00000000..1db8d536 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/brand-icon.ts @@ -0,0 +1,8 @@ +/** + * The BrowserSkill product mark (apps/extension/assets/logo.png, downscaled + * to 32px and inlined): the sidebar tab icon, so the tracking view reads as + * BSK's own surface next to better-sidebar's built-in "browser" tab. + * Regenerate with: resize the source to 32x32 PNG and replace the payload. + */ +export const BSK_LOGO_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAJGklEQVR42m2X248c2V3HP79zTlV19/TcPOOZWY+vsdmQFdngTQJswgs3hQ15iCJslAey+8xDAIHgH+CBNxIpj0gkEXnAKyKQWITEJlKWXSssEMfJRmuMHa/W9njHMz3Tl+nprsv5/XiomlugpdN16lR1n9/1e75fAbAbeLlO3Pz2ly4vjO7+sRbF58oiP49FEREOPwaIYQjS3IvUy0fP66kAZoYJIM7SpPVY0uy14cyzXzn9u9+4c+MG/vp1otiNa16uvxr7X/+1L3bK3lcTyU9P80ilR/uKCGYHOxxd6s2kmdaGIVYb1qyY1e8HD63UUblstwirf9J95fW/sRvXvAD0v/6Z6/O68Xf5/pS8kigiTsTkuCfIwfzAxQN37chjaAyt594JIXE4gbKMhog6Nd/qthi7lVe6L3/nG7Jz4/fPJ9u3b4U4WSwiKog/EcsTLh+PSuO3GXYs/D44QnBgyniQs7OV0+8bOxPPM6c9l660VEyF0Nmrlp9/IYT+/S/PUJ4a5C46EW8HnjVhBKjXaHJfz9Xq95xzhMThHZR5xXBrwtPNnP6eRzvzhJWzrF1d5UI759//9iYra5lrt1ycDeXsaOf+n4Y4HX8+r9SciasLS/6P12LHoyF47wipYKpM9gqePpyyvVMxjBn+1DILH17l0rkZlhYcLetT3n/A5FGfTtvTCh4ibjpVI81/J0il62V1rNStLp6j4jowxwiJh2iMevtsbU3pjYQim6Nz9hJLH13i8mrGbJgSxjuUvQfEjT2GOw4Zddh0BakPtFPH/jRKqaCSrwetKi9mKIIYhyFuOg6TurCcF7ae5ty5t48/vcTiz3+Ei+szLC06WnGA7TykfNCnmObk5nAtj+53yDcTZteMwZOChbkMVa3/14DCJBBBFBxyopndUUBqwwxu/XjAJ778Bc4ul+R3fgyj9yg/GDMpFZxHgoe0hQ9QDRzFI48zqHzJeKhcvNKiLAyiQ1BwgsOcqNZ9Xo96rgaVgmqdiBACH740y/1/+j4b7w1x80tUkwLM4VptJAQMcN6o+p7iYQIqhBQmsUIrR7cd0NI12ODAwJnWTpvZoRGqdRrS1CECeR7Z2NzHRBjeecgbX/kut39UUlx5ATe3gFUlAM5Tb/4oHCJl0oadUcFsK8E5sFh7ZaqgEFBr3KyhxAySxDMplDv3BuztlmhhZC6QqONDrXkW28Kbf/1d7A9+i6vPr7N/e5vQDZQ7jvxxUqdPwFRwHaP3sGR1boZYGabWwHdd7MHMMBUMh5mRJp6tnZx33tllJbS53J1jZj6BKpKPpkhZ8e5mj3Nf/FV+8ZOnmNz6Pr6TUvUcxeOAc0cN6xwUoWIyUhZXMqqq6bKmrQ0hWBQzdXX+nGOwV3LnJ30+trjE8mxGUUVG/Qn7owkt7/hpb5fwmat84qXz5P95EwmOqh8oNkKTSkFqICTJYDgpCOppZYGyjIfgdoApQRScCtEgDZ5b93dxuWd3OuWD8T5pqbSLyEyWcHdrh+S3r/Kpz15i/z/eQhIh9hOKjQRTyBYgFhAndSpDW+gNC+ZbGR6jjM3BVsNsXQNqhqpiOPIicuXcLFvdKT7xtNXTG+X0Bopt9Wi9dJUXX7rI5O23kNQRB4F8I0CEzgr0/B7t2YB/2iJOQDrK8HHJ+dkZqqI+JfUAYRXMGc6pM1MBBa2Ubttz5ews51c6XFjr8OJHT5N0HPIbz/Ppz11h/+03IXVUg4T8ccAidFaNp7LHzdd2uXdvj866gQq5L8nHMD+TUJUG0aAyJBqidZQcUfFqeK2vlEo1rYh5iUXl6eMB5eoKv37tOUb/9q+4LBD7gXKj3ry7Ak/iHrdeH/ArC7P07ykPekMWzkFvkNMmkHkgagNoDciogYKzaGg0YgSNYLEJj0ndfiGj6o/Z3i5or5+j2BTKJwlawcwKbMQ9fvK9Ib+QtgitQMg8d384wVYLdocls1mCad1+Fg1TDgeV4UxFTB2ow44NjQ5rwcwZ+MjqmLe/dZOBLOHHCeUUZteED+Ie73xvyHNpmyx1TERozXieW1/mO/+yzWhTWVtsU5aCqKvRT4+GmRDEHGJ6yPPMBHNGeqbCdwqs02X9l14g/a8H3PzWm/zyp1ZZXgn8tDfgnbeGPN/qIEDSyhh7w2OcPZWRsESWCq3EoVFPksZDMuUIps7qLjDUQBxkZ0tcu8KSGdIXPo3OLbOSdfj4YMIPbg6ZXxux9wRmWimFKe0k0J7v8H5vQHchpSoiC536bKhKEDlCJzM7JLJqhqtxoEYoUcEFw/Yduh8Jl38OP7eAlBOm29ssziV87JlluqMOL15ZZWVlhu0yMrfYxQQmZUU3TbCmnog1mRE1xOp9xOoaEwVRI9QnUGOECLoPNlcRlucJ6xeIgx3K/3kX23pINWnRdnBxtYNWkbkkMJ5JSLKEwaSgRFlsJWh1mNBDfsEBzZGaYIiBIgRtImCmmArhlBFOR1Q9xZ3b6OOHWFnifEq1U/84TpXgHL4hUh7hv3t91pY6eCeUpTYs2o6RisYEO6B5hpoSRCWaanNAGP5UVZ+r+YB4dxcbZ4QVx/Q9wSYg/uDYNvKyoorwgyfbtDuBC6c65HmFa5Jsh56f3NgO4iGeYC68n8Ty2WkphAUVv2hoLuggoXgUcJkRh0rVF7wHi3Vw1YxuSMh1zMpcmw8tdynzCmmCL1JjzQG3lpNU37xAdNnjYC79xywUfzbJNboEX214iqeeuN/IrkKw6BAvNUiJ1a2K0HKOT144jQBFoTWRayrc9LhWa1TTUTNqJ038wCf/LO//xZfWWw9u/zCt8uWpSiTixZ3UeJzQPkerdkyq1RryZwVN87CJBALR0ExULMtG4/VnPy4AG3/4m19YGG/9fT7NmZpEhzg5lCNHOo//774u7UYp2WEt1XrSftYMTcF32ynbrfmXz3ztjW86u3bNn/nq69/eaZ/6PUlbm/PB+YSapUozMG2uB0M5fI7WSKoKasjBu6qHczEjwWQ2iJc03XmaLb5y5mtvfNOuNeLUruHlVeK7f/7yxbXBu39U5sXnq7I8L2pH6rwRgCZyUpeKnZSOB5K2EbT1l5hPk0c+zV7bXXzmry7/5T/cvXENf/1V4v8CSYIrURx3kHgAAAAASUVORK5CYII="; diff --git a/packages/dsh-plugin-browserskill/src/client/index.ts b/packages/dsh-plugin-browserskill/src/client/index.ts index ad22171c..8befe320 100644 --- a/packages/dsh-plugin-browserskill/src/client/index.ts +++ b/packages/dsh-plugin-browserskill/src/client/index.ts @@ -1,7 +1,9 @@ /** * dsh-plugin-browserskill browser half: the `browser_screenshot` keyed - * toolview plus the observation overlay (live thumbnails + interrupt) floating - * over the shell via the `shell.overlay` seat. + * toolview plus the observation overlay (live thumbnails + interrupt). The + * default carrier is a floating card on the `shell.overlay` seat; when the + * dsh-better-sidebar plugin provides its `betterSidebar` service, the view + * moves into a sidebar tab instead (see observation-sidebar.tsx). */ import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment"; @@ -15,6 +17,7 @@ import { createElement } from "react"; import "./bsk-tokens.nomodule.css"; import "./bsk-ui.nomodule.css"; import { ObservationOverlay } from "./ObservationOverlay"; +import { type BetterSidebarLike, registerObservationSidebar } from "./observation-sidebar"; import { type EventSourceLike, ObservationClientStore } from "./observation-store"; import { ScreenshotToolView } from "./ScreenshotToolView"; @@ -81,4 +84,14 @@ export function apply(ctx: ClientContext): void { createElement(ObservationOverlay, { store }), ), ); + // Optional carrier upgrade: when the dsh-better-sidebar plugin is installed, + // its service moves the tracking view into a sidebar tab (the floating + // overlay hides itself through the sidebar-mode flag). In profiles without + // the sidebar plugin this fiber never runs and nothing changes. + ctx.inject(["betterSidebar"], (injected) => + registerObservationSidebar( + (injected as unknown as { betterSidebar: BetterSidebarLike }).betterSidebar, + store, + ), + ); } diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx new file mode 100644 index 00000000..96095d8b --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -0,0 +1,256 @@ +// better-sidebar carrier for the observation view. When the +// dsh-better-sidebar plugin is installed its client publishes a +// `betterSidebar` cordis service; the client entry then runs the +// registration below and the tracking view moves from the floating card +// into a single-instance sidebar tab (Document PiP pop-out stays available +// from inside the tab). Detection is purely service-based — profiles +// without the sidebar plugin never start this fiber and keep the floating +// overlay. + +import { createElement, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { BSK_LOGO_URL } from "./brand-icon"; +import { OverlayBody } from "./ObservationOverlay"; +import css from "./ObservationOverlay.module.css"; +import type { ObservationClientStore } from "./observation-store"; +import { useObservationView, usePip, visibleToScope } from "./observation-view"; +import { setSidebarMode } from "./sidebar-mode"; + +/** The tab title — "Browser Skill", distinct from the sidebar's built-in "browser" tab. */ +const TAB_TITLE = "Browser Skill"; + +/** Tab strip icon: the BrowserSkill product mark at the requested size. */ +function TabIcon({ size }: { size: number }) { + return ( + + ); +} + +/** + * Structural mirrors of dsh-better-sidebar's client service surface (only + * the slices this integration touches — see the upstream + * lib/types/client/service.d.ts). Declared locally so the plugin carries no + * dependency on the sidebar package; the service contract has been stable + * since v0.4.0 and newer capabilities arrive behind its `features` list. + */ +export interface SidebarTabLike { + id: string; + type: string; + title: string; +} + +export interface SidebarLeafLike { + kind: "leaf"; + id: string; + tabs: SidebarTabLike[]; + active: string | null; +} + +export interface SidebarSplitLike { + kind: "split"; + id: string; + dir: "row" | "col"; + sizes: number[]; + children: SidebarNodeLike[]; +} + +export type SidebarNodeLike = SidebarLeafLike | SidebarSplitLike; + +export interface SidebarStateLike { + splits: SidebarNodeLike; + bottomSplits: SidebarNodeLike; + /** Whether the right panel is expanded (the merged drawer on narrow screens). */ + panelOpen?: boolean; +} + +export interface SidebarSnapshotLike { + sessionId?: string; + state?: SidebarStateLike; +} + +/** Props every tab component receives (the slices we read). */ +export interface TabComponentPropsLike { + /** The conversation this sidebar instance belongs to. */ + scope: { sessionId: string }; +} + +export interface TabDescriptorLike { + id: string; + title: string | (() => string); + icon?: ReactNode | ((size: number) => ReactNode); + order?: number; + /** Single-instance: opening focuses the existing tab instead of duplicating. */ + single?: boolean; + /** Small pill on the tab strip; null/undefined hides it. */ + badge?: ( + ctx: unknown, + scope: { sessionId: string }, + state: unknown, + ) => string | number | null | undefined; + component: (props: TabComponentPropsLike) => ReactNode; +} + +export interface BetterSidebarLike { + registerTab(descriptor: TabDescriptorLike): () => void; + openTab(seed: { + type: string; + title?: string; + /** Content seed (lands on tab.path); content opens expand the panel. */ + path?: string; + }): void; + getSnapshot(): SidebarSnapshotLike; + /** Sidebar state feed (v0.12+): session switches, state and prefs changes. */ + subscribeState?: (listener: () => void) => () => void; + isTabEnabled(id: string): boolean; +} + +/** The registered tab type id (also the SidebarTab.type value). */ +export const OBSERVATION_TAB_TYPE = "browserskill:observation"; + +/** + * Inert content seed carried on auto-opened tabs: its mere presence makes + * the sidebar treat the open as a content open (expanding the hosting panel + * so the tracking view lands in sight). Never read by our component. + */ +export const OBSERVATION_TAB_PATH = "browser-skill:observation"; + +/** + * The observation tab body: the same OverlayBody the floating card renders, + * minus the card chrome (no drag header, no collapse — the sidebar tab bar + * owns those), plus the PiP pop-out upgrade. The view is scoped to the + * sidebar's conversation: only browser sessions started by it (or its + * descendants) show here — the floating card keeps the global view. + */ +export function ObservationSidebarTab({ + store, + scopeId, +}: { + store: ObservationClientStore; + scopeId: string; +}) { + const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store, scopeId); + const { pipWindow, pipSupported, popOut } = usePip(); + + const body = ( + popOut() : undefined} + /> + ); + + if (pipWindow !== null) { + return createPortal(body, pipWindow.document.body); + } + return
{body}
; +} + +function* leafNodes(node: SidebarNodeLike): Generator { + if (node.kind === "leaf") { + yield node; + return; + } + for (const child of node.children) yield* leafNodes(child); +} + +/** Whether a tab of our type is already open in either sidebar workbench. */ +export function observationTabOpen(state: SidebarStateLike | undefined): boolean { + if (state === undefined) return false; + for (const root of [state.splits, state.bottomSplits]) { + for (const leaf of leafNodes(root)) { + if (leaf.tabs.some((tab) => tab.type === OBSERVATION_TAB_TYPE)) return true; + } + } + return false; +} + +/** + * Register the observation sidebar tab and flip the carrier flag. Returns + * the disposer the cordis fiber invokes when the sidebar service goes away + * (plugin unload/HMR): the floating overlay then resumes as the carrier. + */ +export function registerObservationSidebar( + service: BetterSidebarLike, + store: ObservationClientStore, +): () => void { + setSidebarMode(true); + // Hold the feed for the whole sidebar lifetime so the auto-open watcher + // sees new sessions even while the tab itself is closed. + store.acquire(); + const disposeTab = service.registerTab({ + id: OBSERVATION_TAB_TYPE, + title: TAB_TITLE, + icon: (size: number) => createElement(TabIcon, { size }), + single: true, + // The badge counts only sessions visible to the tab strip's own + // conversation (global sessions from other conversations stay hidden, + // mirroring the tab body's scoped view). + badge: (_ctx, scope) => { + const count = store + .getSnapshot() + .sessions.filter((s) => visibleToScope(s, scope.sessionId)).length; + return count > 0 ? count : null; + }, + component: (props) => + createElement(ObservationSidebarTab, { store, scopeId: props.scope.sessionId }), + }); + + // Auto-open the tab when a browser session VISIBLE TO the active + // conversation appears (and right away when one is already live). The + // evaluation runs on observation publishes AND on sidebar state changes: + // the latter covers the sidebar store's async init (its state is + // undefined for the first beats after page load) and conversation + // switches. The open carries a content seed (`path`): only content opens + // land in sight — the sidebar expands the hosting panel for them. While + // the panel is OPEN an existing tab is never re-focused — the user may + // be reading another page on purpose; while it is collapsed a NEW + // session (0→N) nudges the tab back into sight, mirroring how the + // floating card used to reappear. + const activeVisibleCount = (): number => { + const activeId = service.getSnapshot().sessionId; + if (activeId === undefined) return 0; + return store.getSnapshot().sessions.filter((s) => visibleToScope(s, activeId)).length; + }; + let previousVisible = activeVisibleCount(); + const evaluate = (): void => { + const count = activeVisibleCount(); + const { state, sessionId } = service.getSnapshot(); + if ( + state !== undefined && + sessionId !== undefined && + service.isTabEnabled(OBSERVATION_TAB_TYPE) + ) { + const open = observationTabOpen(state); + if (count > 0 && !open) { + service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH }); + } else if (previousVisible === 0 && count > 0 && open && state.panelOpen === false) { + service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH }); + } + } + previousVisible = count; + }; + evaluate(); + const unsubscribe = store.subscribe(evaluate); + const unsubscribeState = service.subscribeState?.(evaluate); + + return () => { + unsubscribe(); + unsubscribeState?.(); + disposeTab(); + store.release(); + setSidebarMode(false); + }; +} diff --git a/packages/dsh-plugin-browserskill/src/client/observation-store.ts b/packages/dsh-plugin-browserskill/src/client/observation-store.ts index 1387bc23..2ec6846e 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-store.ts +++ b/packages/dsh-plugin-browserskill/src/client/observation-store.ts @@ -53,6 +53,7 @@ export interface OverlaySnapshot { const STATE_URL = "/bsk-observation/state"; const EVENTS_URL = "/bsk-observation/events"; const INTERRUPT_URL = "/bsk-observation/interrupt"; +const STOP_URL = "/bsk-observation/stop"; function revoke(url: string | undefined): void { if (url !== undefined && typeof URL.revokeObjectURL === "function") { @@ -78,6 +79,8 @@ export class ObservationClientStore { }; private available = true; private started = false; + /** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */ + private consumers = 0; constructor(private readonly deps: ObservationClientDeps) {} @@ -189,6 +192,23 @@ export class ObservationClientStore { this.publish(); } + /** + * Hold the feed for one consumer's lifetime: the stream starts with the + * first holder and stops with the last release. Several carriers can share + * the store (the floating card, the better-sidebar tab, and the sidebar + * integration fiber) without one unmount killing the others' updates. + */ + acquire(): void { + this.consumers += 1; + if (this.consumers === 1) this.start(); + } + + release(): void { + if (this.consumers === 0) return; + this.consumers -= 1; + if (this.consumers === 0) this.stop(); + } + /** Forget one session's tracked + held frames, revoking their blob URLs. */ private dropThumb(sessionId: string): void { const attachmentId = this.thumbBySession.get(sessionId); @@ -292,4 +312,23 @@ export class ObservationClientStore { return false; } } + + /** + * Stop one session and close its Agent Window. The session's removal + * arrives through the SSE remove event — no local state is touched here. + */ + async stopSession(sessionId: string): Promise { + try { + const res = await this.deps.fetchFn(STOP_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + if (!res.ok) return false; + const body = (await res.json()) as { stopped?: boolean }; + return body.stopped === true; + } catch { + return false; + } + } } diff --git a/packages/dsh-plugin-browserskill/src/client/observation-view.ts b/packages/dsh-plugin-browserskill/src/client/observation-view.ts new file mode 100644 index 00000000..987ddc62 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/observation-view.ts @@ -0,0 +1,148 @@ +/** + * Shared view logic for the observation carriers (the floating overlay card + * and the better-sidebar tab): the store-backed view model (snapshot, focus + * pinning, elapsed ticker) and the Document PiP pop-out. Extracted from + * ObservationOverlay so both carriers run the same focus/interrupt behavior + * without duplicating hooks. + */ + +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import type { SessionObservation } from "../observation"; +import type { ObservationClientStore, OverlaySnapshot } from "./observation-store"; + +/** + * Auto-follow focus: the most recently touched session, but never yank focus + * to a session whose latest action failed (errors flag the strip item, they + * do not steal the stage) or one already reported dead. + */ +export function focusOf(sessions: readonly SessionObservation[]): SessionObservation | undefined { + if (sessions.length === 0) return undefined; + const byRecency = [...sessions].sort((a, b) => b.since - a.since); + const healthy = byRecency.find((s) => s.lastError === undefined && s.dead !== true); + return healthy ?? byRecency[0]; +} + +export function statusOf(obs: SessionObservation): "active" | "idle" | "error" { + if (obs.lastError !== undefined && obs.action === "idle") return "error"; + return obs.action === "idle" ? "idle" : "active"; +} + +/** Minimal Document PiP surface (TS lib.dom lacks it). */ +export interface DocumentPip { + requestWindow(options?: { width?: number; height?: number }): Promise; +} + +export function pipApi(): DocumentPip | undefined { + if (typeof window === "undefined") return undefined; + return (window as unknown as { documentPictureInPicture?: DocumentPip }).documentPictureInPicture; +} + +/** Clone the host document's style/link nodes into a PiP window. */ +export function cloneStylesInto(pipWindow: Window): void { + for (const node of document.querySelectorAll('link[rel="stylesheet"], style')) { + pipWindow.document.head.appendChild(node.cloneNode(true)); + } +} + +export interface ObservationView { + readonly snapshot: OverlaySnapshot; + /** Pinned session wins while it still exists; otherwise auto-follow. */ + readonly focus: SessionObservation | undefined; + readonly pinnedId: string | null; + readonly onTogglePin: (sessionId: string) => void; + /** 1s ticker while anything is active (drives the elapsed readout). */ + readonly now: number; +} + +/** + * Whether one session is visible on a surface scoped to one DSH + * conversation: sessions started by that conversation or its descendants + * (the lineage ancestors ride along on the entry). Untracked sessions (no + * owner recorded) are hidden from scoped surfaces but stay in global views. + */ +export function visibleToScope(obs: SessionObservation, scopeId: string): boolean { + return obs.dshSessionIds?.includes(scopeId) === true; +} + +/** + * The store-backed observation view model. Holds the feed for the component + * lifetime (refcounted — overlapping carriers never kill each other's + * stream). `scopeId` narrows the view to one DSH conversation's sessions + * (the better-sidebar tab); undefined keeps the global view (floating + * card, PiP). + */ +export function useObservationView( + store: ObservationClientStore, + scopeId?: string, +): ObservationView { + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + useEffect(() => { + store.acquire(); + return () => store.release(); + }, [store]); + + const scopedSnapshot: OverlaySnapshot = + scopeId === undefined + ? snapshot + : { + ...snapshot, + sessions: snapshot.sessions.filter((s) => visibleToScope(s, scopeId)), + }; + + const [pinnedId, setPinnedId] = useState(null); + const pinned = + pinnedId !== null ? scopedSnapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined; + const focus = pinned ?? focusOf(scopedSnapshot.sessions); + const onTogglePin = useCallback((sessionId: string) => { + setPinnedId((current) => (current === sessionId ? null : sessionId)); + }, []); + + const anyActive = scopedSnapshot.sessions.some((s) => s.action !== "idle"); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!anyActive) return; + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [anyActive]); + + return { + snapshot: scopedSnapshot, + focus, + pinnedId: pinned !== undefined ? pinnedId : null, + onTogglePin, + now, + }; +} + +export interface PipHandle { + /** The open PiP window, or null while the content renders in its carrier. */ + readonly pipWindow: Window | null; + /** Whether the browser exposes Document PiP (Chrome-only today). */ + readonly pipSupported: boolean; + /** + * Upgrade the content into a native PiP window. Rejects silently without a + * user gesture (or when closed mid-request): the carrier keeps the content. + */ + readonly popOut: (size?: { width: number; height: number }) => void; +} + +export function usePip(): PipHandle { + const [pipWindow, setPipWindow] = useState(null); + const popOut = useCallback((size?: { width: number; height: number }): void => { + const pip = pipApi(); + if (pip === undefined) return; + void pip + .requestWindow(size) + .then((win) => { + cloneStylesInto(win); + win.addEventListener("pagehide", () => setPipWindow(null)); + setPipWindow(win); + }) + .catch(() => {}); + }, []); + // Unmounting the carrier (tab close, conversation switch, plugin HMR) does + // not destroy the PiP browsing context: close it ourselves or it survives + // as a blank window. close() on an already-closed window is a no-op. + useEffect(() => () => pipWindow?.close(), [pipWindow]); + return { pipWindow, pipSupported: pipApi() !== undefined, popOut }; +} diff --git a/packages/dsh-plugin-browserskill/src/client/sidebar-mode.ts b/packages/dsh-plugin-browserskill/src/client/sidebar-mode.ts new file mode 100644 index 00000000..c10e7ea0 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/sidebar-mode.ts @@ -0,0 +1,26 @@ +/** + * Carrier switch for the observation view: while the better-sidebar + * integration fiber is alive (the dsh-better-sidebar plugin provides its + * `betterSidebar` service), the tracking view lives in a sidebar tab and the + * floating overlay card/capsule hides itself. The flag is a tiny external + * store so the overlay can read it through useSyncExternalStore and flip + * without a remount when the sidebar plugin (un)loads. + */ + +let active = false; +const listeners = new Set<() => void>(); + +export function getSidebarMode(): boolean { + return active; +} + +export function subscribeSidebarMode(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function setSidebarMode(next: boolean): void { + if (active === next) return; + active = next; + for (const listener of [...listeners]) listener(); +} diff --git a/packages/dsh-plugin-browserskill/src/index.ts b/packages/dsh-plugin-browserskill/src/index.ts index ace19a99..45946d33 100644 --- a/packages/dsh-plugin-browserskill/src/index.ts +++ b/packages/dsh-plugin-browserskill/src/index.ts @@ -12,6 +12,7 @@ import type { Context } from "@deepseek-ai/cordis"; import Schema from "@deepseek-ai/schemastery"; +import { armArchiveCleanup } from "./archive-cleanup"; import { armLazyTools } from "./lazy-tools"; import { ObservationService } from "./observation"; import { registerObservationRoutes } from "./observation-http"; @@ -107,6 +108,10 @@ export function apply( removeRoutes = registerObservationRoutes(injected, observation); return () => removeRoutes(); }); + // Reap a conversation's browsers when the conversation itself is archived: + // archived sessions are hidden from every surface, so their Agent Windows + // would otherwise linger unreachable until idle timeout or unload. + const disarmArchiveCleanup = armArchiveCleanup(ctx, registry, observation); // Non-blocking install probe: warn early when bsk is missing instead of // failing the first tool call with a bare spawn error. Uses --version on @@ -132,6 +137,7 @@ export function apply( removeSuite(); unregisterSkill(); removeRoutes(); + disarmArchiveCleanup(); observation.dispose(); runner.killAll(); const stops = registry @@ -144,6 +150,7 @@ export function apply( }); } +export { armArchiveCleanup, ownerSessionIds } from "./archive-cleanup"; export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation"; export { ObservationService } from "./observation"; export { registerObservationRoutes } from "./observation-http"; diff --git a/packages/dsh-plugin-browserskill/src/observation-http.ts b/packages/dsh-plugin-browserskill/src/observation-http.ts index 3225e188..09add137 100644 --- a/packages/dsh-plugin-browserskill/src/observation-http.ts +++ b/packages/dsh-plugin-browserskill/src/observation-http.ts @@ -8,6 +8,7 @@ * GET /bsk-observation/state → { sessions, available } * GET /bsk-observation/events → SSE stream of ObservationEvent * POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean} + * POST /bsk-observation/stop → body {sessionId} → {stopped: boolean} * GET /bsk-observation/thumbnail/ → image bytes * * Routes exist only when a webServer service is mounted (web composition); @@ -166,6 +167,42 @@ export function registerObservationRoutes( }); }, }), + webServer.register({ + kind: "exact", + path: `${ROUTE_BASE}/stop`, + handler: (req, res) => { + if (req.method !== "POST") { + sendJson(res, 405, { error: "method not allowed" }); + return; + } + if (fenceRejected(req, res)) return; + let body = ""; + req.on("data", (chunk: Buffer | string) => { + body += chunk; + }); + req.on("end", () => { + let sessionId: string | undefined; + try { + const parsed = JSON.parse(body || "{}") as { sessionId?: unknown }; + if (typeof parsed.sessionId === "string" && parsed.sessionId !== "") { + sessionId = parsed.sessionId; + } + } catch { + sendJson(res, 400, { error: "invalid JSON body" }); + return; + } + // A destructive call always names its target (never "current"). + if (sessionId === undefined) { + sendJson(res, 400, { error: "sessionId required" }); + return; + } + void observation.stopSession(sessionId).then( + (stopped) => sendJson(res, 200, { stopped }), + () => sendJson(res, 500, { error: "stop failed" }), + ); + }); + }, + }), webServer.register({ kind: "prefix", path: `${ROUTE_BASE}/thumbnail`, diff --git a/packages/dsh-plugin-browserskill/src/observation.ts b/packages/dsh-plugin-browserskill/src/observation.ts index 78991b6d..645e7015 100644 --- a/packages/dsh-plugin-browserskill/src/observation.ts +++ b/packages/dsh-plugin-browserskill/src/observation.ts @@ -39,6 +39,13 @@ export interface SessionObservation { * greys it out until it is stopped/removed. No more frames are requested. */ dead?: boolean; + /** + * The DSH conversations this session belongs to (the starting agent's + * session plus its seed-lineage ancestors), recorded at start. Scoped + * surfaces (the better-sidebar tab) filter by it; absent means untracked + * ownership — visible only in the global (unscoped) view. + */ + dshSessionIds?: string[]; } /** Incremental event carried to subscribers (SSE on the wire). */ @@ -152,11 +159,13 @@ export class ObservationService { /** Register a fresh owned session (called from browser_session_start). */ addSession(sessionId: string, url?: string): void { if (!this.deps.options.enabled || this.disposed) return; + const dshSessionIds = this.deps.registry.dshOwnersOf(sessionId); this.put({ sessionId, ...(url !== undefined ? { url } : {}), action: "idle", since: this.scheduler.now(), + ...(dshSessionIds.length > 0 ? { dshSessionIds } : {}), }); this.lastActivity.set(sessionId, this.scheduler.now()); this.scheduleCapture(sessionId, 0); @@ -227,6 +236,36 @@ export class ObservationService { return this.deps.runner.killFor(target) > 0; } + /** + * Stop one owned session and close its Agent Window (the overlay's stop + * button — same end state as `browser_session_stop`). Never waits behind a + * hung in-flight command: tool children are killed first so the session's + * keyed queue drains immediately, and no further captures queue up. A + * session the daemon already forgot (dead strip entries) stops + * idempotently — the goal state (entry gone) is identical. + * @returns whether the session ended up stopped/removed. + */ + async stopSession(sessionId: string): Promise { + if (!this.deps.registry.isOwned(sessionId)) return false; + this.cancelCapture(sessionId); + this.deps.runner.killFor(sessionId); + const result = await this.deps.queue.run(sessionId, () => + this.deps.runner.run(["session", "stop", sessionId], { timeoutMs: 30_000 }), + ); + if (result.code !== 0) { + let code: string | undefined; + try { + code = (JSON.parse(result.stdout) as { code?: string }).code; + } catch { + code = undefined; + } + if (code !== "session_not_found") return false; + } + this.deps.registry.remove(sessionId); + this.removeSession(sessionId); + return true; + } + /** * Read one captured thumbnail from the in-process ring. Powers the plugin's * own HTTP thumbnail route — frames are plugin-owned runtime data, never diff --git a/packages/dsh-plugin-browserskill/src/sessions.ts b/packages/dsh-plugin-browserskill/src/sessions.ts index a9aca981..0fac4a2c 100644 --- a/packages/dsh-plugin-browserskill/src/sessions.ts +++ b/packages/dsh-plugin-browserskill/src/sessions.ts @@ -24,6 +24,13 @@ export interface TrackedSession { export class SessionRegistry { private readonly sessions = new Map(); private currentId: string | undefined; + /** + * bsk session id → the DSH session ids that may clean it up: the agent + * session that started it plus every ancestor along the seed lineage, so + * archiving a conversation at ANY level of the chain reaps its browsers + * (see archive-cleanup.ts). + */ + private readonly dshOwners = new Map>(); /** * Slots reserved by in-flight starts. reserveStart/completeStart/abandonStart * run synchronously around the async spawn, so concurrent starts can never @@ -72,12 +79,37 @@ export class SessionRegistry { /** Forget a session; falls back to the most recent remaining one. */ remove(sessionId: string): void { this.sessions.delete(sessionId); + this.dshOwners.delete(sessionId); if (this.currentId === sessionId) { const rest = [...this.sessions.values()]; this.currentId = rest.length > 0 ? rest[rest.length - 1].sessionId : undefined; } } + /** + * Record which DSH conversation(s) a freshly started bsk session belongs + * to (the starting agent's session plus its ancestors). No-op without ids + * — e.g. a start whose caller carried no agent identity. + */ + trackOwner(sessionId: string, dshSessionIds: readonly string[]): void { + if (dshSessionIds.length === 0 || !this.sessions.has(sessionId)) return; + this.dshOwners.set(sessionId, new Set(dshSessionIds)); + } + + /** bsk session ids owned by the given DSH conversation (or its descendants). */ + ownedByDsh(dshSessionId: string): string[] { + const owned: string[] = []; + for (const [sessionId, owners] of this.dshOwners) { + if (owners.has(dshSessionId)) owned.push(sessionId); + } + return owned; + } + + /** The DSH conversation ids owning one bsk session (empty when untracked). */ + dshOwnersOf(sessionId: string): string[] { + return [...(this.dshOwners.get(sessionId) ?? [])]; + } + /** Mark an owned session as most recently used (recency order refresh). */ private touch(sessionId: string): void { const existing = this.sessions.get(sessionId); diff --git a/packages/dsh-plugin-browserskill/src/tools.ts b/packages/dsh-plugin-browserskill/src/tools.ts index a7e91393..1d1ce7d7 100644 --- a/packages/dsh-plugin-browserskill/src/tools.ts +++ b/packages/dsh-plugin-browserskill/src/tools.ts @@ -17,6 +17,7 @@ import { type ToolResult, type ToolRunContext, } from "@deepseek-ai/dsh-tools"; +import { ownerSessionIds } from "./archive-cleanup"; import { trySaveScreenshot } from "./image"; import { actionForLabel, type ObservationService } from "./observation"; import type { KeyedExecutor } from "./queue"; @@ -245,6 +246,9 @@ export function registerTools(deps: ToolDeps): () => void { browserInstanceId: reply.browser_instance_id, startedAtMs: Date.now(), }); + // Ownership for archive cleanup: the calling conversation and its + // ancestors reap this session when any of them is archived. + registry.trackOwner(reply.session_id, ownerSessionIds(deps.ctx, exec.agent?.id)); deps.observation.addSession(reply.session_id, args.url); try { if (args.device !== undefined) { diff --git a/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts b/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts new file mode 100644 index 00000000..9115faf7 --- /dev/null +++ b/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts @@ -0,0 +1,157 @@ +// Archive-triggered cleanup: lineage resolution for browser-session +// ownership, the registry's owner index, and the domain/changed watcher +// that reaps a freshly archived conversation's bsk sessions. + +import { describe, expect, it, vi } from "vitest"; +import { armArchiveCleanup, ownerSessionIds } from "../src/archive-cleanup"; +import type { ObservationService } from "../src/observation"; +import { SessionRegistry } from "../src/sessions"; + +/** A ctx stub carrying a session store with the given lineage headers. */ +function ctxWithSessions(headers: Record) { + return { + get: (key: string) => + key === "sessions" + ? { + get: (id: string) => (headers[id] === undefined ? undefined : { header: headers[id] }), + } + : undefined, + } as never; +} + +describe("ownerSessionIds", () => { + it("walks the seed lineage to the root; empty without an agent identity", () => { + expect(ownerSessionIds(ctxWithSessions({}), undefined)).toEqual([]); + const ctx = ctxWithSessions({ + child: { parentSession: "parent" }, + parent: { parentSession: "root" }, + root: {}, + }); + expect(ownerSessionIds(ctx, "child")).toEqual(["child", "parent", "root"]); + }); + + it("stops the walk at an unloaded ancestor", () => { + const ctx = ctxWithSessions({ child: { parentSession: "gone" } }); + expect(ownerSessionIds(ctx, "child")).toEqual(["child", "gone"]); + }); + + it("never loops on a malformed parent cycle", () => { + const ctx = ctxWithSessions({ + a: { parentSession: "b" }, + b: { parentSession: "a" }, + }); + expect(ownerSessionIds(ctx, "a")).toEqual(["a", "b"]); + }); +}); + +describe("SessionRegistry owner tracking", () => { + function start(registry: SessionRegistry, sessionId: string): void { + registry.reserveStart(); + registry.completeStart({ sessionId, startedAtMs: 1 }); + } + + it("indexes owners and forgets them on remove; ignores empty/unknown ownership", () => { + const registry = new SessionRegistry(5); + start(registry, "bsk1"); + start(registry, "bsk2"); + registry.trackOwner("bsk1", ["conv-a", "root"]); + registry.trackOwner("bsk2", ["conv-b"]); + expect(registry.ownedByDsh("root")).toEqual(["bsk1"]); + expect(registry.ownedByDsh("conv-a")).toEqual(["bsk1"]); + expect(registry.ownedByDsh("conv-b")).toEqual(["bsk2"]); + expect(registry.ownedByDsh("nobody")).toEqual([]); + registry.remove("bsk1"); + expect(registry.ownedByDsh("root")).toEqual([]); + // Empty owner lists and unknown session ids record nothing. + registry.trackOwner("bsk2", []); + registry.trackOwner("ghost", ["conv-c"]); + expect(registry.ownedByDsh("conv-c")).toEqual([]); + }); +}); + +describe("armArchiveCleanup", () => { + function harness(opts: { archived?: string[] } = {}) { + const registry = new SessionRegistry(5); + const observation = { stopSession: vi.fn(async () => true) }; + const listeners = new Set<(change: unknown) => void>(); + const ctx = { + get: (key: string) => + key === "workspaceRegistry" ? { archivedSessionIds: opts.archived ?? [] } : undefined, + on: (event: string, listener: (change: unknown) => void) => { + if (event === "domain/changed") listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const emit = (change: unknown) => { + for (const listener of [...listeners]) listener(change); + }; + return { + registry, + observation: observation as unknown as ObservationService, + stopSession: observation.stopSession, + emit, + arm: () => + armArchiveCleanup(ctx as never, registry, observation as unknown as ObservationService), + }; + } + + function startOwned(registry: SessionRegistry, bskId: string, owners: string[]): void { + registry.reserveStart(); + registry.completeStart({ sessionId: bskId, startedAtMs: 1 }); + registry.trackOwner(bskId, owners); + } + + it("stops every bsk session owned by a freshly archived conversation, until disarmed", () => { + const h = harness(); + startOwned(h.registry, "bsk1", ["conv-a", "root"]); + startOwned(h.registry, "bsk2", ["conv-b"]); + const disarm = h.arm(); + h.emit({ + domain: "workspace", + table: "", + value: { archivedSessionIds: ["root"] }, + }); + expect(h.stopSession).toHaveBeenCalledTimes(1); + expect(h.stopSession).toHaveBeenCalledWith("bsk1"); + // After the disposer runs the watcher is silent again. + disarm(); + h.emit({ + domain: "workspace", + table: "", + value: { archivedSessionIds: ["root", "conv-b"] }, + }); + expect(h.stopSession).toHaveBeenCalledTimes(1); + }); + + it("ignores pre-archived ids, foreign domains, and malformed frames", () => { + const h = harness({ archived: ["old-conv"] }); + startOwned(h.registry, "bsk1", ["old-conv"]); + startOwned(h.registry, "bsk2", ["conv-a"]); + h.arm(); + // Seeded from the registry: the pre-archived id must not retro-fire. + h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["old-conv"] } }); + h.emit({ domain: "settings", table: "", value: { archivedSessionIds: ["conv-a"] } }); + h.emit({ domain: "workspace", table: "rows", value: { archivedSessionIds: ["conv-a"] } }); + h.emit({ domain: "workspace", table: "", value: {} }); + expect(h.stopSession).not.toHaveBeenCalled(); + // A genuinely new archive still fires. + h.emit({ + domain: "workspace", + table: "", + value: { archivedSessionIds: ["old-conv", "conv-a"] }, + }); + expect(h.stopSession).toHaveBeenCalledWith("bsk2"); + }); + + it("treats a re-archived session as fresh again after unarchive", () => { + const h = harness(); + startOwned(h.registry, "bsk1", ["conv-a"]); + h.arm(); + h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } }); + expect(h.stopSession).toHaveBeenCalledTimes(1); + // Unarchive, then re-archive: the second archival cleans up again. + h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: [] } }); + h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } }); + expect(h.stopSession).toHaveBeenCalledTimes(2); + }); +}); 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 87a4de4b..7290296f 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx @@ -4,7 +4,7 @@ // 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 { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay"; import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store"; @@ -157,6 +157,45 @@ describe("ObservationOverlay", () => { await screen.findByText(/s1 · clicking/); }); + it("stop session: the first click only arms the confirm, the second posts", async () => { + const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]); + render(); + await screen.findByText(/s1 · idle/); + const stop = screen.getByRole("button", { name: "Stop session s1" }); + fireEvent.click(stop); + // Armed: a plain click must never close an Agent Window by itself. + expect(h.fetches.some((f) => f.url === "/bsk-observation/stop")).toBe(false); + const confirm = await screen.findByRole("button", { name: "Confirm stop session s1" }); + expect(confirm.getAttribute("aria-pressed")).toBe("true"); + fireEvent.click(confirm); + await waitFor(() => + expect( + h.fetches.some( + (f) => f.url === "/bsk-observation/stop" && f.init?.body === '{"sessionId":"s1"}', + ), + ).toBe(true), + ); + }); + + it("disarms the stop confirm after the expiry window", async () => { + const h = makeHarness([{ sessionId: "s1", action: "idle", since: Date.now() }]); + render(); + const stop = await screen.findByRole("button", { name: "Stop session s1" }); + vi.useFakeTimers(); + try { + fireEvent.click(stop); + // Synchronous queries only: waitFor-style finds hang under fake timers. + expect(screen.getByRole("button", { name: "Confirm stop session s1" })).toBeTruthy(); + act(() => { + vi.advanceTimersByTime(3100); + }); + expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy(); + expect(h.fetches.some((f) => f.url === "/bsk-observation/stop")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it("resizes within min/max clamps", async () => { const h = makeHarness([BUSY]); render(); @@ -222,6 +261,7 @@ describe("ObservationOverlay", () => { addEventListener: (name: string, fn: () => void) => { listeners.set(name, [...(listeners.get(name) ?? []), fn]); }, + close: vi.fn(), } as unknown as Window; }); (window as unknown as Record).documentPictureInPicture = { requestWindow }; @@ -236,6 +276,28 @@ describe("ObservationOverlay", () => { await screen.findByTestId("obs-card"); }); + it("closes the PiP window when the carrier unmounts", async () => { + const h = makeHarness([BUSY]); + const pipDoc = document.implementation.createHTMLDocument("pip"); + const close = vi.fn(); + const requestWindow = vi.fn(async () => { + return { + document: pipDoc, + addEventListener: () => {}, + close, + } as unknown as Window; + }); + (window as unknown as Record).documentPictureInPicture = { requestWindow }; + const { unmount } = render(); + const popout = await screen.findByRole("button", { name: /Pop out/ }); + fireEvent.click(popout); + await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); + // Unmounting (tab close, conversation switch, plugin HMR) closes the PiP + // instead of leaving a blank window behind. + unmount(); + expect(close).toHaveBeenCalledTimes(1); + }); + it("renders the strip for two sessions and pins focus on click", async () => { const older: SessionObservation = { sessionId: "s1", diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx new file mode 100644 index 00000000..efbfb0a9 --- /dev/null +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -0,0 +1,331 @@ +// @vitest-environment happy-dom +// better-sidebar carrier: tab registration + auto-open, the sidebar-mode +// flag hiding the floating overlay, PiP pop-out from the tab, and the +// store's refcounted acquire/release across overlapping carriers. + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ObservationOverlay } from "../../src/client/ObservationOverlay"; +import { + type BetterSidebarLike, + OBSERVATION_TAB_PATH, + OBSERVATION_TAB_TYPE, + ObservationSidebarTab, + observationTabOpen, + registerObservationSidebar, + type SidebarNodeLike, + type SidebarStateLike, + type TabDescriptorLike, +} from "../../src/client/observation-sidebar"; +import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store"; +import { getSidebarMode, setSidebarMode } from "../../src/client/sidebar-mode"; +import type { SessionObservation } from "../../src/observation"; + +// Owned by the sidebar mock's active conversation ("conv-1") so the scoped +// visibility logic lets it through; FOREIGN belongs to another conversation. +const BUSY: SessionObservation = { + sessionId: "s1", + action: "clicking", + since: Date.now() - 7000, + dshSessionIds: ["conv-1"], +}; +const FOREIGN: SessionObservation = { + sessionId: "s9", + action: "clicking", + since: Date.now() - 5000, + dshSessionIds: ["other-conv"], +}; + +interface Harness { + store: ObservationClientStore; + es: () => EventSourceLike | undefined; + push: (sessions: SessionObservation[]) => void; +} + +function makeHarness(initial: SessionObservation[]): Harness { + let es: EventSourceLike | undefined; + let current = initial; + const store = new ObservationClientStore({ + fetchFn: async (url: string) => { + if (url === "/bsk-observation/state") { + return { ok: true, json: async () => ({ sessions: current }) }; + } + return { ok: true, json: async () => ({ interrupted: true }) }; + }, + eventSourceFactory: () => { + es = { onmessage: null, close: vi.fn() }; + return es; + }, + loadImage: async (id: string) => `blob:${id}`, + }); + return { + store, + es: () => es, + push: (sessions) => { + current = sessions; + for (const s of sessions) { + es?.onmessage?.({ data: JSON.stringify({ type: "upsert", session: s }) }); + } + if (sessions.length === 0) es?.onmessage?.({ data: JSON.stringify({ type: "reset" }) }); + }, + }; +} + +function leafWith(type: string, panelOpen = true): SidebarStateLike { + return { + panelOpen, + splits: { + kind: "split", + id: "root", + dir: "row", + sizes: [1], + children: [ + { + kind: "leaf", + id: "pane-1", + active: type, + tabs: [{ id: type, type, title: type }], + }, + ], + }, + bottomSplits: { kind: "leaf", id: "pane-2", active: null, tabs: [] }, + }; +} + +interface SidebarMock { + service: BetterSidebarLike; + descriptor: () => TabDescriptorLike; + openTab: ReturnType; + disposeTab: ReturnType; + setState: (state: SidebarStateLike | undefined) => void; +} + +/** Insert a tab into the first leaf of the main tree (mirrors a real open). */ +function withTabOpened(state: SidebarStateLike, type: string): SidebarStateLike { + let done = false; + const walk = (node: SidebarNodeLike): SidebarNodeLike => { + if (done) return node; + if (node.kind === "leaf") { + done = true; + return { ...node, active: type, tabs: [...node.tabs, { id: type, type, title: type }] }; + } + return { ...node, children: node.children.map(walk) }; + }; + return { ...state, splits: walk(state.splits) }; +} + +function makeSidebar( + initialState: SidebarStateLike | undefined = leafWith("explorer"), +): SidebarMock { + let descriptor: TabDescriptorLike | undefined; + let state: SidebarStateLike | undefined = initialState; + const openTab = vi.fn((seed: { type: string }) => { + // Mirror the real feedback loop: a created tab lands in the state, so + // later evaluations see it open (single-instance dedupe re-focuses). + if (state !== undefined && !observationTabOpen(state)) { + state = withTabOpened(state, seed.type); + } + }); + const disposeTab = vi.fn(); + const service: BetterSidebarLike = { + registerTab: (next) => { + descriptor = next; + return disposeTab; + }, + openTab, + getSnapshot: () => ({ sessionId: "conv-1", state }), + isTabEnabled: () => true, + }; + return { + service, + descriptor: () => { + if (descriptor === undefined) throw new Error("no tab registered"); + return descriptor; + }, + openTab, + disposeTab, + setState: (next) => { + state = next; + }, + }; +} + +afterEach(() => { + cleanup(); + setSidebarMode(false); +}); + +describe("registerObservationSidebar", () => { + it("registers a single-instance tab and holds the feed for its lifetime", () => { + const h = makeHarness([]); + const sidebar = makeSidebar(); + expect(getSidebarMode()).toBe(false); + const dispose = registerObservationSidebar(sidebar.service, h.store); + expect(getSidebarMode()).toBe(true); + // The fiber holds the observation feed itself, so the auto-open watcher + // sees new sessions even while the tab is closed. + expect(h.es()).toBeDefined(); + const descriptor = sidebar.descriptor(); + expect(descriptor.id).toBe(OBSERVATION_TAB_TYPE); + expect(descriptor.single).toBe(true); + expect(descriptor.title).toBe("Browser Skill"); + dispose(); + expect(getSidebarMode()).toBe(false); + expect(sidebar.disposeTab).toHaveBeenCalledTimes(1); + expect(h.es()?.close).toHaveBeenCalled?.(); + }); + + it("opens the tab right away when a session is already live at activation", async () => { + const h = makeHarness([BUSY]); + const sidebar = makeSidebar(); + registerObservationSidebar(sidebar.service, h.store); + // The initial state fetch lands asynchronously; the 0→N watcher then fires. + await waitFor(() => + expect(sidebar.openTab).toHaveBeenCalledWith({ + type: OBSERVATION_TAB_TYPE, + path: OBSERVATION_TAB_PATH, + }), + ); + }); + + it("auto-opens on the first session but never re-focuses an open tab", async () => { + const h = makeHarness([]); + const sidebar = makeSidebar(); + registerObservationSidebar(sidebar.service, h.store); + expect(sidebar.openTab).not.toHaveBeenCalled(); + // First session arrives: the tab lands in the sidebar. + h.push([BUSY]); + await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1)); + // The sidebar now reports the tab open (persisted/restored): further + // session arrivals must not steal focus from whatever the user reads. + sidebar.setState(leafWith(OBSERVATION_TAB_TYPE)); + h.push([]); + h.push([{ ...BUSY, sessionId: "s2" }]); + h.push([]); + h.push([{ ...BUSY, sessionId: "s3" }]); + await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1)); + expect(sidebar.openTab).toHaveBeenCalledTimes(1); + }); + + it("does not open while the sidebar has no active session state", () => { + const h = makeHarness([BUSY]); + const sidebar = makeSidebar(undefined); + registerObservationSidebar(sidebar.service, h.store); + expect(sidebar.openTab).not.toHaveBeenCalled(); + }); + + it("nudges an existing tab back into sight when the panel is collapsed", async () => { + const h = makeHarness([]); + // Tab already open (persisted), but the panel is collapsed: a new + // session should surface the tracking view again (focus + expand via a + // content open), like the floating card reappearing. + const sidebar = makeSidebar(leafWith(OBSERVATION_TAB_TYPE, false)); + registerObservationSidebar(sidebar.service, h.store); + h.push([BUSY]); + await waitFor(() => + expect(sidebar.openTab).toHaveBeenCalledWith({ + type: OBSERVATION_TAB_TYPE, + path: OBSERVATION_TAB_PATH, + }), + ); + }); + + it("shows the visible session count as the tab badge", () => { + const h = makeHarness([]); + const sidebar = makeSidebar(); + registerObservationSidebar(sidebar.service, h.store); + const badge = sidebar.descriptor().badge; + const scope = { sessionId: "conv-1" }; + expect(badge?.(undefined, scope, undefined)).toBeNull(); + h.store.acquire(); + h.push([BUSY, FOREIGN]); + expect(badge?.(undefined, scope, undefined)).toBe(1); + expect(badge?.(undefined, { sessionId: "other-conv" }, undefined)).toBe(1); + expect(badge?.(undefined, { sessionId: "nobody" }, undefined)).toBeNull(); + h.store.release(); + }); + + it("does not auto-open for sessions owned by other conversations", async () => { + const h = makeHarness([]); + const sidebar = makeSidebar(); + registerObservationSidebar(sidebar.service, h.store); + h.push([FOREIGN]); + await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1)); + expect(sidebar.openTab).not.toHaveBeenCalled(); + // A session visible to the active conversation does open the tab. + h.push([BUSY]); + await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1)); + }); +}); + +describe("observationTabOpen", () => { + it("finds the tab in either workbench tree", () => { + expect(observationTabOpen(undefined)).toBe(false); + expect(observationTabOpen(leafWith("explorer"))).toBe(false); + expect(observationTabOpen(leafWith(OBSERVATION_TAB_TYPE))).toBe(true); + const inBottom: SidebarStateLike = { + splits: { kind: "leaf", id: "p1", active: null, tabs: [] }, + bottomSplits: { + kind: "leaf", + id: "p2", + active: OBSERVATION_TAB_TYPE, + tabs: [{ id: OBSERVATION_TAB_TYPE, type: OBSERVATION_TAB_TYPE, title: "Browser" }], + }, + }; + expect(observationTabOpen(inBottom)).toBe(true); + }); +}); + +describe("ObservationSidebarTab", () => { + it("renders the tracking view without card chrome (no collapse, no drag header)", async () => { + const h = makeHarness([BUSY]); + render(); + await screen.findByText(/s1 · clicking/); + expect(screen.queryByRole("button", { name: "Collapse" })).toBeNull(); + expect(screen.getByTestId("obs-header").dataset.draggable).toBeUndefined(); + // No resize handles — the sidebar owns the geometry. + expect(screen.queryByTestId("obs-resize-se")).toBeNull(); + // Session controls ride along: interrupt + stop-with-confirm. + expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy(); + }); + + it("scopes the view to the sidebar's conversation", async () => { + const h = makeHarness([BUSY, FOREIGN]); + render(); + // Focus and content belong to the owned session; the foreign one never + // reaches the stage, the status line, or the strip (which needs 2+). + await screen.findByText(/s1 · clicking/); + expect(screen.queryByText(/s9/)).toBeNull(); + expect(screen.queryByTestId("obs-strip")).toBeNull(); + // Scoping to the other conversation swaps what's visible. + render(); + await screen.findByText(/s9 · clicking/); + }); +}); + +describe("carrier switching", () => { + it("hides the floating overlay while sidebar mode is active", async () => { + const h = makeHarness([]); + const { container } = render(); + h.push([BUSY]); + await screen.findByTestId("obs-card"); + setSidebarMode(true); + await waitFor(() => expect(container.firstChild).toBeNull()); + setSidebarMode(false); + await screen.findByTestId("obs-card"); + }); + + it("keeps the feed alive across overlapping carriers (refcount)", async () => { + const h = makeHarness([]); + const overlay = render(); + await waitFor(() => expect(h.es()).toBeDefined()); + // A second carrier mounts (the sidebar tab), then the overlay unmounts: + // the stream must survive for the remaining consumer. + const tab = render(); + overlay.unmount(); + h.push([BUSY]); + await screen.findByText(/s1 · clicking/); + expect(h.es()?.close ?? vi.fn()).not.toHaveBeenCalled(); + tab.unmount(); + }); +}); diff --git a/packages/dsh-plugin-browserskill/tests/client/screenshot-toolview.test.tsx b/packages/dsh-plugin-browserskill/tests/client/screenshot-toolview.test.tsx index a5832dc3..48622f40 100644 --- a/packages/dsh-plugin-browserskill/tests/client/screenshot-toolview.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/screenshot-toolview.test.tsx @@ -137,6 +137,9 @@ describe("client plugin registration", () => { const sessions = { binding: () => undefined }; const ctx = { get: (key: string) => (key === "sessions" ? sessions : undefined), + // cordis ctx.inject: the betterSidebar carrier upgrade stays dormant in + // this composition (the callback only runs once the service exists). + inject: (_deps: string[], _fn: (injected: unknown) => unknown) => {}, slots: { inject: (_name: string, fn: () => unknown) => fn(), register: (slot: { name: string; key?: string; id?: string }, view: unknown) => { diff --git a/packages/dsh-plugin-browserskill/tests/observation.test.ts b/packages/dsh-plugin-browserskill/tests/observation.test.ts index e54a15d3..01b4d7ac 100644 --- a/packages/dsh-plugin-browserskill/tests/observation.test.ts +++ b/packages/dsh-plugin-browserskill/tests/observation.test.ts @@ -61,6 +61,8 @@ function fakeRunner( opts: { screenshotFails?: boolean; screenshotNotFound?: boolean; + stopNotFound?: boolean; + stopFails?: boolean; killCount?: number; screenshotBytes?: () => Uint8Array; } = {}, @@ -72,6 +74,21 @@ function fakeRunner( killed, async run(args: string[], options: BskRunOptions = {}): Promise { calls.push({ args, options }); + if (args[0] === "session" && args[1] === "stop") { + if (opts.stopNotFound) { + return { + code: 4, + stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }), + stderr: "", + timedOut: false, + aborted: false, + }; + } + if (opts.stopFails) { + return { code: 1, stdout: "", stderr: "boom", timedOut: false, aborted: false }; + } + return { code: 0, stdout: "{}", stderr: "", timedOut: false, aborted: false }; + } if (args[0] === "screenshot") { if (opts.screenshotNotFound) { return { @@ -180,6 +197,23 @@ describe("state machine", () => { expect(events[events.length - 1].type).toBe("reset"); }); + it("stamps the registry-recorded DSH owners onto new entries", () => { + const registry = new SessionRegistry(5); + own(registry, "s1"); + own(registry, "s2"); + registry.trackOwner("s1", ["conv-a", "root"]); + const { service } = setup({ registry }); + service.addSession("s1"); + service.addSession("s2"); + expect(service.getState()).toEqual([ + { sessionId: "s1", action: "idle", since: 1_000_000, dshSessionIds: ["conv-a", "root"] }, + { sessionId: "s2", action: "idle", since: 1_000_000 }, + ]); + // The owner stamp survives later upserts (action/url/frame churn). + service.beginAction("s1", "clicking"); + expect(service.getState()[0].dshSessionIds).toEqual(["conv-a", "root"]); + }); + it("ignores instrumentation for unknown sessions (owned-only by construction)", () => { const { service } = setup({}); service.beginAction("foreign", "clicking"); @@ -307,6 +341,57 @@ describe("interrupt routing", () => { }); }); +describe("stopSession", () => { + it("stops an owned session: kills in-flight tools, runs session stop, removes the entry", async () => { + const registry = new SessionRegistry(5); + own(registry, "s1"); + const runner = fakeRunner(); + const { service, events } = setup({ registry, runner }); + service.addSession("s1"); + expect(registry.isOwned("s1")).toBe(true); + + await expect(service.stopSession("s1")).resolves.toBe(true); + expect(runner.killed).toEqual(["s1"]); + const stop = runner.calls.find((c) => c.args[0] === "session" && c.args[1] === "stop"); + expect(stop?.args).toEqual(["session", "stop", "s1"]); + expect(registry.isOwned("s1")).toBe(false); + expect(service.getState()).toEqual([]); + expect(events[events.length - 1]).toMatchObject({ type: "remove" }); + }); + + it("refuses foreign sessions without touching the runner", async () => { + const registry = new SessionRegistry(5); + own(registry, "s1"); + const runner = fakeRunner(); + const { service } = setup({ registry, runner }); + await expect(service.stopSession("foreign")).resolves.toBe(false); + expect(runner.killed).toEqual([]); + expect(runner.calls).toEqual([]); + }); + + it("stops idempotently when the daemon already forgot the session (dead entries)", async () => { + const registry = new SessionRegistry(5); + own(registry, "s1"); + const runner = fakeRunner({ stopNotFound: true }); + const { service, events } = setup({ registry, runner }); + service.addSession("s1"); + await expect(service.stopSession("s1")).resolves.toBe(true); + expect(registry.isOwned("s1")).toBe(false); + expect(events[events.length - 1]).toMatchObject({ type: "remove" }); + }); + + it("returns false and keeps the entry when the stop itself fails", async () => { + const registry = new SessionRegistry(5); + own(registry, "s1"); + const runner = fakeRunner({ stopFails: true }); + const { service } = setup({ registry, runner }); + service.addSession("s1"); + await expect(service.stopSession("s1")).resolves.toBe(false); + expect(registry.isOwned("s1")).toBe(true); + expect(service.getState().map((s) => s.sessionId)).toEqual(["s1"]); + }); +}); + describe("HTTP/SSE interface", () => { interface RecordedRoute { path: string; @@ -405,11 +490,45 @@ describe("HTTP/SSE interface", () => { await interruptRoute?.handler(postReq, interruptRes.res as never); expect(JSON.parse(interruptRes.res.body())).toEqual({ interrupted: true }); + // stop (POST {sessionId}): owned session stops and leaves the state + const stopRoute = routes.get("/bsk-observation/stop"); + const stopRes = fakeRes(); + await stopRoute?.handler(postReq, stopRes.res as never); + // The stop is async behind the response-less handler — poll the state. + await waitFor(() => service.getState().length === 0); + expect(JSON.parse(stopRes.res.body())).toEqual({ stopped: true }); + dispose(); expect(routes.size).toBe(0); service.dispose(); }); + it("refuses a stop without a sessionId", async () => { + const registry = new SessionRegistry(5); + own(registry, "s1"); + const { service } = setup({ registry }); + const { routes, webServer } = routeHarness(); + const ctx = { get: (key: string) => (key === "webServer" ? webServer : undefined) } as never; + const dispose = registerObservationRoutes(ctx, service); + + const stopRoute = routes.get("/bsk-observation/stop"); + const res = fakeRes(); + const emptyReq = fakeReq({ + method: "POST", + headers: { host: "127.0.0.1:3999", "content-type": "application/json" }, + on: (event: string, fn: (chunk?: string) => void) => { + if (event === "data") fn("{}"); + if (event === "end") fn(); + }, + }); + await stopRoute?.handler(emptyReq, res.res as never); + expect(res.res.status).toBe(400); + expect(registry.isOwned("s1")).toBe(true); + + dispose(); + service.dispose(); + }); + it("fences off non-loopback, cross-site, and simple-request traffic", async () => { const registry = new SessionRegistry(5); own(registry, "s1");