From 030fd7bb56a8912e1d1c2e177e9ade5ac1831eb2 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Wed, 19 Aug 2026 13:56:21 +0000 Subject: [PATCH 01/12] fix(dsh-plugin-browserskill): make bottom overlay resize corners draggable The expanded observation overlay's bottom-left (sw) and bottom-right (se) resize handles sat at z-index 2, below the full-width bottom actions bar (z-index 3) inside the card's stacking context, so every pointer event in the bottom corners landed on the toolbar and resize never started. The top corners worked only because the header is unpositioned. Raise the corner hit targets to z-index 4 so all four corners receive pointerdown again. --- .../src/client/ObservationOverlay.module.css | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css index c640261f..cd4afd6d 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -165,8 +165,9 @@ 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. */ +/* 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); @@ -203,13 +204,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"] { From bb952a63708e1667fef8500e083f56d2514b31a4 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Thu, 20 Aug 2026 04:06:53 +0000 Subject: [PATCH 02/12] feat(dsh-plugin-browserskill): carry the observation view in a better-sidebar tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dsh-better-sidebar plugin is installed, its client publishes a betterSidebar cordis service; the observation tracking view now moves from the floating card into a single-instance sidebar tab registered through that service (detected via ctx.inject, so profiles without the sidebar plugin keep the floating overlay untouched): - observation-sidebar.tsx: tab descriptor (globe icon, live session-count badge, single instance) + ObservationSidebarTab rendering the shared OverlayBody without card chrome; auto-opens the tab when the first browser session appears but never re-focuses an already-open tab; the Document PiP pop-out upgrade stays available from inside the tab. - sidebar-mode.ts: carrier flag the floating overlay subscribes to — while sidebar mode is active the card/capsule hides (an open PiP window keeps its portal), and it resumes the moment the sidebar service unloads. - observation-view.ts: the view model (snapshot/focus/pin/ticker) and the PiP plumbing extracted from ObservationOverlay so both carriers share them; focusOf/statusOf move here and are re-exported. - observation-store.ts: refcounted acquire/release so overlapping carriers (overlay, tab, integration fiber) never kill each other's SSE feed. - OverlayBody is exported; the header only shows the move cursor when the carrier actually drags (data-draggable). --- .../src/client/ObservationOverlay.module.css | 20 +- .../src/client/ObservationOverlay.tsx | 113 ++----- .../src/client/index.ts | 17 +- .../src/client/observation-sidebar.tsx | 186 ++++++++++++ .../src/client/observation-store.ts | 19 ++ .../src/client/observation-view.ts | 115 ++++++++ .../src/client/sidebar-mode.ts | 26 ++ .../tests/client/observation-sidebar.test.tsx | 277 ++++++++++++++++++ .../tests/client/screenshot-toolview.test.tsx | 3 + 9 files changed, 692 insertions(+), 84 deletions(-) create mode 100644 packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx create mode 100644 packages/dsh-plugin-browserskill/src/client/observation-view.ts create mode 100644 packages/dsh-plugin-browserskill/src/client/sidebar-mode.ts create mode 100644 packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css index cd4afd6d..aabd8703 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -30,17 +30,35 @@ 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; +} + .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; +} + .status-text { flex: 1; min-width: 0; diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index d500aeb1..0ed0b098 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -3,9 +3,12 @@ // 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 { @@ -39,11 +42,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 +65,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 +113,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; @@ -246,8 +228,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 +292,7 @@ function OverlayBody(props: {
@@ -387,25 +370,17 @@ function OverlayBody(props: { ); } -/** Clone the host document's style/link nodes into a PiP window. */ -function cloneStylesInto(pipWindow: Window): void { - for (const node of document.querySelectorAll('link[rel="stylesheet"], style')) { - pipWindow.document.head.appendChild(node.cloneNode(true)); - } -} - export function ObservationOverlay({ store }: { store: ObservationClientStore }) { - const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); - useEffect(() => { - 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 +389,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 +469,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 +493,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/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..67d21f41 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -0,0 +1,186 @@ +// 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 { RiGlobalLine } from "@remixicon/react"; +import { createElement, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { OverlayBody } from "./ObservationOverlay"; +import css from "./ObservationOverlay.module.css"; +import type { ObservationClientStore } from "./observation-store"; +import { useObservationView, usePip } from "./observation-view"; +import { setSidebarMode } from "./sidebar-mode"; + +// remixicon's component types target @types/react 19 while the dsh shell +// runs react 18 — the same compile-time-only recast as the overlay. +const IconGlobe = RiGlobalLine as unknown as (props: { + size?: number | string; + className?: string; +}) => ReactNode; + +/** + * 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; +} + +export interface SidebarSnapshotLike { + sessionId?: string; + state?: SidebarStateLike; +} + +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?: () => string | number | null | undefined; + component: () => ReactNode; +} + +export interface BetterSidebarLike { + registerTab(descriptor: TabDescriptorLike): () => void; + openTab(seed: { type: string; title?: string }): void; + getSnapshot(): SidebarSnapshotLike; + isTabEnabled(id: string): boolean; +} + +/** The registered tab type id (also the SidebarTab.type value). */ +export const OBSERVATION_TAB_TYPE = "browserskill: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. + */ +export function ObservationSidebarTab({ store }: { store: ObservationClientStore }) { + const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store); + 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: "Browser", + icon: (size: number) => createElement(IconGlobe, { size }), + single: true, + badge: () => { + const count = store.getSnapshot().sessions.length; + return count > 0 ? count : null; + }, + component: () => createElement(ObservationSidebarTab, { store }), + }); + + // Open the tab when the first browser session appears (and right away + // when one is already live at activation). An already-open tab is never + // re-focused — the user may be reading another page on purpose. Type-only + // opens never force the panel open, so this stays as unobtrusive as the + // floating card's collapsed capsule was. + let previousCount = store.getSnapshot().sessions.length; + const maybeOpen = (): void => { + if (service.getSnapshot().state === undefined) return; + if (!service.isTabEnabled(OBSERVATION_TAB_TYPE)) return; + if (observationTabOpen(service.getSnapshot().state)) return; + service.openTab({ type: OBSERVATION_TAB_TYPE }); + }; + if (previousCount > 0) maybeOpen(); + const unsubscribe = store.subscribe(() => { + const count = store.getSnapshot().sessions.length; + if (previousCount === 0 && count > 0) maybeOpen(); + previousCount = count; + }); + + return () => { + unsubscribe(); + 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..66b73f57 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-store.ts +++ b/packages/dsh-plugin-browserskill/src/client/observation-store.ts @@ -78,6 +78,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 +191,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); 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..f0568c62 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/client/observation-view.ts @@ -0,0 +1,115 @@ +/** + * 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; +} + +/** + * The store-backed observation view model. Holds the feed for the component + * lifetime (refcounted — overlapping carriers never kill each other's + * stream). + */ +export function useObservationView(store: ObservationClientStore): ObservationView { + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + useEffect(() => { + store.acquire(); + return () => store.release(); + }, [store]); + + const [pinnedId, setPinnedId] = useState(null); + 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 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]); + + return { snapshot, 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(() => {}); + }, []); + 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/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx new file mode 100644 index 00000000..c52844be --- /dev/null +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -0,0 +1,277 @@ +// @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, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ObservationOverlay } from "../../src/client/ObservationOverlay"; +import { + type BetterSidebarLike, + OBSERVATION_TAB_TYPE, + ObservationSidebarTab, + observationTabOpen, + registerObservationSidebar, + 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"; + +const BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: Date.now() - 7000 }; + +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): SidebarStateLike { + return { + 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; +} + +function makeSidebar( + initialState: SidebarStateLike | undefined = leafWith("explorer"), +): SidebarMock { + let descriptor: TabDescriptorLike | undefined; + let state: SidebarStateLike | undefined = initialState; + const openTab = vi.fn(); + 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 flips the carrier flag", () => { + const h = makeHarness([]); + const sidebar = makeSidebar(); + expect(getSidebarMode()).toBe(false); + const dispose = registerObservationSidebar(sidebar.service, h.store); + expect(getSidebarMode()).toBe(true); + const descriptor = sidebar.descriptor(); + expect(descriptor.id).toBe(OBSERVATION_TAB_TYPE); + expect(descriptor.single).toBe(true); + expect(descriptor.title).toBe("Browser"); + dispose(); + expect(getSidebarMode()).toBe(false); + expect(sidebar.disposeTab).toHaveBeenCalledTimes(1); + }); + + it("holds the observation feed for the sidebar lifetime", () => { + const h = makeHarness([]); + const sidebar = makeSidebar(); + const dispose = registerObservationSidebar(sidebar.service, h.store); + expect(h.es()).toBeDefined(); + dispose(); + 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 }), + ); + }); + + 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("shows the live session count as the tab badge", () => { + const h = makeHarness([]); + const sidebar = makeSidebar(); + registerObservationSidebar(sidebar.service, h.store); + const badge = sidebar.descriptor().badge; + expect(badge?.()).toBeNull(); + h.store.acquire(); + h.push([BUSY]); + expect(badge?.()).toBe(1); + h.store.release(); + }); +}); + +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", () => { + beforeEach(() => { + delete (window as unknown as Record).documentPictureInPicture; + }); + + 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(); + }); + + it("still pops out into a PiP window and returns on pagehide", async () => { + const h = makeHarness([BUSY]); + const pipDoc = document.implementation.createHTMLDocument("pip"); + const listeners = new Map void)[]>(); + (window as unknown as Record).documentPictureInPicture = { + requestWindow: vi.fn(async () => { + return { + document: pipDoc, + addEventListener: (name: string, fn: () => void) => { + listeners.set(name, [...(listeners.get(name) ?? []), fn]); + }, + } as unknown as Window; + }), + }; + render(); + const popout = await screen.findByRole("button", { name: /Pop out/ }); + fireEvent.click(popout); + await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); + for (const fn of listeners.get("pagehide") ?? []) fn(); + await screen.findByText(/s1 · 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) => { From 2b18178b3c7f986b1eb6d5950f2a13b983dbd6d9 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Thu, 20 Aug 2026 08:10:49 +0000 Subject: [PATCH 03/12] feat(dsh-plugin-browserskill): brand the sidebar tab as Browser Skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar already ships a built-in "browser" tab, so retitle the observation tab to "Browser Skill" and swap the remixicon globe for the BSK product mark (apps/extension/assets/logo.png downscaled to 32px and inlined as a data URL — 2.4 kB, no route or asset serving needed). --- .../src/client/ObservationOverlay.module.css | 7 +++++ .../src/client/brand-icon.ts | 8 ++++++ .../src/client/observation-sidebar.tsx | 28 +++++++++++++------ .../tests/client/observation-sidebar.test.tsx | 14 +++++++++- 4 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 packages/dsh-plugin-browserskill/src/client/brand-icon.ts diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css index aabd8703..a8ecac40 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -44,6 +44,13 @@ 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; 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/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index 67d21f41..2472685f 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -7,21 +7,31 @@ // without the sidebar plugin never start this fiber and keep the floating // overlay. -import { RiGlobalLine } from "@remixicon/react"; 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 } from "./observation-view"; import { setSidebarMode } from "./sidebar-mode"; -// remixicon's component types target @types/react 19 while the dsh shell -// runs react 18 — the same compile-time-only recast as the overlay. -const IconGlobe = RiGlobalLine as unknown as (props: { - size?: number | string; - className?: string; -}) => ReactNode; +/** 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 @@ -148,8 +158,8 @@ export function registerObservationSidebar( store.acquire(); const disposeTab = service.registerTab({ id: OBSERVATION_TAB_TYPE, - title: "Browser", - icon: (size: number) => createElement(IconGlobe, { size }), + title: TAB_TITLE, + icon: (size: number) => createElement(TabIcon, { size }), single: true, badge: () => { const count = store.getSnapshot().sessions.length; diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx index c52844be..0b8565b7 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -4,6 +4,7 @@ // store's refcounted acquire/release across overlapping carriers. import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ObservationOverlay } from "../../src/client/ObservationOverlay"; import { @@ -129,7 +130,7 @@ describe("registerObservationSidebar", () => { const descriptor = sidebar.descriptor(); expect(descriptor.id).toBe(OBSERVATION_TAB_TYPE); expect(descriptor.single).toBe(true); - expect(descriptor.title).toBe("Browser"); + expect(descriptor.title).toBe("Browser Skill"); dispose(); expect(getSidebarMode()).toBe(false); expect(sidebar.disposeTab).toHaveBeenCalledTimes(1); @@ -191,6 +192,17 @@ describe("registerObservationSidebar", () => { expect(badge?.()).toBe(1); h.store.release(); }); + + it("uses the BrowserSkill product mark as the tab icon", () => { + const h = makeHarness([]); + const sidebar = makeSidebar(); + registerObservationSidebar(sidebar.service, h.store); + const icon = sidebar.descriptor().icon as (size: number) => ReactNode; + const { container } = render(<>{icon(16)}); + const img = container.querySelector("img"); + expect(img?.getAttribute("src")).toContain("data:image/png;base64,"); + expect(img?.getAttribute("width")).toBe("16"); + }); }); describe("observationTabOpen", () => { From af15705e183eacc503ef4b6cfa962bf1725e77c2 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Thu, 20 Aug 2026 08:30:53 +0000 Subject: [PATCH 04/12] fix(dsh-plugin-browserskill): make sidebar auto-open visible and align the status row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live-reported issues with the sidebar carrier: - Auto-open appeared not to work: the type-only openTab minted the tab but never expanded a collapsed panel, so nothing surfaced for the user. The open now carries an inert content seed (path), which the sidebar treats as a content open — it expands the hosting panel and lands the tracking view in sight. - The status header's bottom border sat ~7px above the shell header's hairline (tab strip 34px + compact 28px card header vs the builtin tabs' 36px first rows), breaking the window-wide line at the panel boundary. In the sidebar the header now follows the builtin first-row metrics (36px, matching paddings); the floating card keeps its compact header. --- .../src/client/ObservationOverlay.module.css | 10 ++++++++ .../src/client/observation-sidebar.tsx | 24 +++++++++++++++---- .../tests/client/observation-sidebar.test.tsx | 6 ++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css index a8ecac40..5dfcf2a5 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -66,6 +66,16 @@ cursor: move; } +/* Sidebar first-row metrics, mirroring the builtin tabs (explorerHeader et + al. are 36px): tab strip (34px) + a 36px status row lands the header's + bottom border exactly on the shell header's, keeping the window-wide + hairline continuous. The floating card keeps its compact header. */ +.sidebar-tab .header { + box-sizing: border-box; + height: 36px; + padding: 0 8px 0 12px; +} + .status-text { flex: 1; min-width: 0; diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index 2472685f..d7578f8b 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -87,7 +87,12 @@ export interface TabDescriptorLike { export interface BetterSidebarLike { registerTab(descriptor: TabDescriptorLike): () => void; - openTab(seed: { type: string; title?: string }): void; + openTab(seed: { + type: string; + title?: string; + /** Content seed (lands on tab.path); content opens expand the panel. */ + path?: string; + }): void; getSnapshot(): SidebarSnapshotLike; isTabEnabled(id: string): boolean; } @@ -95,6 +100,13 @@ export interface BetterSidebarLike { /** 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 @@ -170,15 +182,17 @@ export function registerObservationSidebar( // Open the tab when the first browser session appears (and right away // when one is already live at activation). An already-open tab is never - // re-focused — the user may be reading another page on purpose. Type-only - // opens never force the panel open, so this stays as unobtrusive as the - // floating card's collapsed capsule was. + // re-focused — the user may be reading another page on purpose. The open + // carries a content seed (`path`): only content opens land in sight — the + // sidebar expands the hosting panel for them, while type-only opens + // silently no-op behind a collapsed panel. The pseudo-path is inert (our + // component never reads tab.path). let previousCount = store.getSnapshot().sessions.length; const maybeOpen = (): void => { if (service.getSnapshot().state === undefined) return; if (!service.isTabEnabled(OBSERVATION_TAB_TYPE)) return; if (observationTabOpen(service.getSnapshot().state)) return; - service.openTab({ type: OBSERVATION_TAB_TYPE }); + service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH }); }; if (previousCount > 0) maybeOpen(); const unsubscribe = store.subscribe(() => { diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx index 0b8565b7..dd306c05 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ObservationOverlay } from "../../src/client/ObservationOverlay"; import { type BetterSidebarLike, + OBSERVATION_TAB_PATH, OBSERVATION_TAB_TYPE, ObservationSidebarTab, observationTabOpen, @@ -151,7 +152,10 @@ describe("registerObservationSidebar", () => { 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 }), + expect(sidebar.openTab).toHaveBeenCalledWith({ + type: OBSERVATION_TAB_TYPE, + path: OBSERVATION_TAB_PATH, + }), ); }); From 51c763dbbbcaf589ba314e164373a400ce26e5cf Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Thu, 20 Aug 2026 08:33:14 +0000 Subject: [PATCH 05/12] feat(dsh-plugin-browserskill): resurface the sidebar tab on new sessions behind a collapsed panel When the observation tab already exists but the panel is collapsed, a new browser session now issues the same content open: the sidebar dedupe- focuses the existing tab AND expands the panel (documented openTab semantics), so the tracking view resurfaces exactly like the floating card did. While the panel is open, an existing tab is still never re-focused. --- .../src/client/observation-sidebar.tsx | 22 ++++++++++++------- .../tests/client/observation-sidebar.test.tsx | 19 +++++++++++++++- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index d7578f8b..a7462291 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -66,6 +66,8 @@ 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 { @@ -181,17 +183,21 @@ export function registerObservationSidebar( }); // Open the tab when the first browser session appears (and right away - // when one is already live at activation). An already-open tab is never - // re-focused — the user may be reading another page on purpose. The open - // carries a content seed (`path`): only content opens land in sight — the - // sidebar expands the hosting panel for them, while type-only opens - // silently no-op behind a collapsed panel. The pseudo-path is inert (our - // component never reads tab.path). + // when one is already live at activation). The open carries a content + // seed (`path`): only content opens land in sight — the sidebar expands + // the hosting panel for them, while type-only opens silently no-op + // behind a collapsed panel. The pseudo-path is inert (our component + // never reads tab.path). 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 nudges the existing tab back into + // sight (a content open focuses + expands), mirroring how the floating + // card used to reappear. let previousCount = store.getSnapshot().sessions.length; const maybeOpen = (): void => { - if (service.getSnapshot().state === undefined) return; + const state = service.getSnapshot().state; + if (state === undefined) return; if (!service.isTabEnabled(OBSERVATION_TAB_TYPE)) return; - if (observationTabOpen(service.getSnapshot().state)) return; + if (observationTabOpen(state) && state.panelOpen !== false) return; service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH }); }; if (previousCount > 0) maybeOpen(); diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx index dd306c05..83475376 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -58,8 +58,9 @@ function makeHarness(initial: SessionObservation[]): Harness { }; } -function leafWith(type: string): SidebarStateLike { +function leafWith(type: string, panelOpen = true): SidebarStateLike { return { + panelOpen, splits: { kind: "split", id: "root", @@ -185,6 +186,22 @@ describe("registerObservationSidebar", () => { 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 live session count as the tab badge", () => { const h = makeHarness([]); const sidebar = makeSidebar(); From d46b2deabc8e7c95fe41893632c686cdea920575 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Thu, 20 Aug 2026 09:08:08 +0000 Subject: [PATCH 06/12] fix(dsh-plugin-browserskill): drop the sidebar status row's bottom border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixel analysis of the user's window showed the line still stepped ~6px at the panel boundary — the shell conversation header's height varies per window (35-38px), so no fixed row height can land a border on it — and the BSK warm border tint (oklch hue 60) visibly clashes with the sidebar's neutral hairlines. Borderless in the sidebar, the status row reads as plain chrome and the stage's own background separates it; the floating card keeps its bordered compact header. --- .../src/client/ObservationOverlay.module.css | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css index 5dfcf2a5..cdb3ee89 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -66,14 +66,13 @@ cursor: move; } -/* Sidebar first-row metrics, mirroring the builtin tabs (explorerHeader et - al. are 36px): tab strip (34px) + a 36px status row lands the header's - bottom border exactly on the shell header's, keeping the window-wide - hairline continuous. The floating card keeps its compact header. */ +/* 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 { - box-sizing: border-box; - height: 36px; - padding: 0 8px 0 12px; + border-bottom: none; } .status-text { From db547611e7a48d5e081c351968d7ae4b718fae5c Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Thu, 20 Aug 2026 09:27:43 +0000 Subject: [PATCH 07/12] feat(dsh-plugin-browserskill): stop a session from the tracking view with a two-click confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every carrier of the observation view (floating card, better-sidebar tab, PiP) now offers a stop button next to interrupt, driven by a lightweight armed-state confirm: the first click flips the button to a solid-red check (3s expiry, auto-disarm when the focus vanishes), the second executes — a stray single click can never close an Agent Window. - host: ObservationService.stopSession kills in-flight tool children so a hung command never delays the stop, funnels the stop through the session's keyed queue like the tool path, treats a daemon-forgotten session (dead strip entries) as an idempotent success, and removes the registry + observation entries; POST /bsk-observation/stop requires an explicit sessionId (destructive calls never default to "current"). - client: store.stopSession posts the stop; the session's removal arrives through the existing SSE remove event, so all carriers update at once. --- .../src/client/ObservationOverlay.module.css | 20 ++++ .../src/client/ObservationOverlay.tsx | 105 ++++++++++++++++-- .../src/client/observation-store.ts | 20 ++++ .../src/observation-http.ts | 37 ++++++ .../src/observation.ts | 30 +++++ .../tests/client/observation-overlay.test.tsx | 41 ++++++- .../tests/client/observation-sidebar.test.tsx | 2 + .../tests/observation.test.ts | 102 +++++++++++++++++ 8 files changed, 347 insertions(+), 10 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css index cdb3ee89..5bc6c413 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css @@ -165,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; @@ -199,6 +205,11 @@ color: var(--destructive); } +/* 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. */ @@ -216,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); diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index 0ed0b098..edca403f 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -13,6 +13,8 @@ import { cn } from "@browser-skill/ui"; import { RiArrowDownSLine, + RiCheckLine, + RiCloseCircleLine, RiErrorWarningLine, RiPictureInPicture2Line, RiPushpinFill, @@ -28,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, @@ -148,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 = @@ -346,15 +427,21 @@ export function OverlayBody(props: {
) : null}
- - - +
+ + + + store.stopSession(sessionId)} + /> +
{onPopOut !== undefined ? ( { + 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/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..27b02a72 100644 --- a/packages/dsh-plugin-browserskill/src/observation.ts +++ b/packages/dsh-plugin-browserskill/src/observation.ts @@ -227,6 +227,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/tests/client/observation-overlay.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx index 87a4de4b..5ec49dad 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(); diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx index 83475376..c204678c 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -257,6 +257,8 @@ describe("ObservationSidebarTab", () => { 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("still pops out into a PiP window and returns on pagehide", async () => { diff --git a/packages/dsh-plugin-browserskill/tests/observation.test.ts b/packages/dsh-plugin-browserskill/tests/observation.test.ts index e54a15d3..6f472b30 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 { @@ -307,6 +324,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 +473,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"); From 2494831c2ad1d18dcfbd46bcc3c55286596b674a Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Thu, 20 Aug 2026 10:13:27 +0000 Subject: [PATCH 08/12] feat(dsh-plugin-browserskill): reap a conversation's bsk sessions when it is archived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving a DSH conversation hides it from every surface, but any Agent Windows it opened kept running with no way back to them. The plugin now records which conversation started each bsk session — the calling agent's session id plus every ancestor along the seed lineage (a subagent's browsers belong to the root conversation too) — and watches the workspace registry's domain/changed broadcast for newly archived ids, stopping those sessions through the same kill/queue/teardown path as the overlay's stop button. Re-archiving after an unarchive cleans up again; headless compositions (no workspace domain) degrade to a no-op. --- .../src/archive-cleanup.ts | 105 +++++++++++ packages/dsh-plugin-browserskill/src/index.ts | 7 + .../dsh-plugin-browserskill/src/sessions.ts | 27 +++ packages/dsh-plugin-browserskill/src/tools.ts | 4 + .../tests/archive-cleanup.test.ts | 163 ++++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 packages/dsh-plugin-browserskill/src/archive-cleanup.ts create mode 100644 packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts 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..578dc765 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/archive-cleanup.ts @@ -0,0 +1,105 @@ +/** + * 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/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/sessions.ts b/packages/dsh-plugin-browserskill/src/sessions.ts index a9aca981..759463b6 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,32 @@ 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; + } + /** 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..a2d5ba05 --- /dev/null +++ b/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts @@ -0,0 +1,163 @@ +// 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("returns [] without an agent identity", () => { + expect(ownerSessionIds(ctxWithSessions({}), undefined)).toEqual([]); + }); + + it("walks the seed lineage to the root", () => { + 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", () => { + 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([]); + }); + + it("ignores empty ownership and unknown sessions", () => { + const registry = new SessionRegistry(5); + start(registry, "bsk1"); + registry.trackOwner("bsk1", []); + registry.trackOwner("ghost", ["conv-a"]); + expect(registry.ownedByDsh("conv-a")).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", () => { + 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"); + disarm(); + }); + + 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); + }); + + it("stops firing after the disposer runs", () => { + const h = harness(); + startOwned(h.registry, "bsk1", ["conv-a"]); + const disarm = h.arm(); + disarm(); + h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } }); + expect(h.stopSession).not.toHaveBeenCalled(); + }); +}); From 915a0a0b1fa8219bb70af758411c43cbcb862b8a Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 22 Aug 2026 12:39:37 +0000 Subject: [PATCH 09/12] feat(dsh-plugin-browserskill): scope sidebar visibility to the conversation's own bsk sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar tab used to show every bsk session in the profile. Each observation entry now carries the owning DSH conversations (the starting agent's session plus its seed-lineage ancestors, stamped at addSession), and the sidebar surface filters by them: - the tab body (focus view + strip) shows only sessions started by the sidebar's own conversation or its descendants; the floating card and PiP keep the global view; untracked (orphan) sessions stay global-only - the tab badge counts only sessions visible to its conversation - auto-open fires only when a session visible to the ACTIVE conversation appears, and re-evaluates on sidebar state changes (subscribeState) — which also covers the store's async init after page load and conversation switches --- .../src/client/observation-sidebar.tsx | 100 ++++++++++++------ .../src/client/observation-view.ts | 43 ++++++-- .../src/observation.ts | 9 ++ .../dsh-plugin-browserskill/src/sessions.ts | 5 + .../tests/client/observation-sidebar.test.tsx | 80 ++++++++++++-- .../tests/observation.test.ts | 17 +++ 6 files changed, 207 insertions(+), 47 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index a7462291..9e947ce8 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -13,7 +13,7 @@ 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 } from "./observation-view"; +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. */ @@ -75,6 +75,12 @@ export interface SidebarSnapshotLike { 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); @@ -83,8 +89,12 @@ export interface TabDescriptorLike { /** Single-instance: opening focuses the existing tab instead of duplicating. */ single?: boolean; /** Small pill on the tab strip; null/undefined hides it. */ - badge?: () => string | number | null | undefined; - component: () => ReactNode; + badge?: ( + ctx: unknown, + scope: { sessionId: string }, + state: unknown, + ) => string | number | null | undefined; + component: (props: TabComponentPropsLike) => ReactNode; } export interface BetterSidebarLike { @@ -96,6 +106,8 @@ export interface BetterSidebarLike { path?: string; }): void; getSnapshot(): SidebarSnapshotLike; + /** Sidebar state feed (v0.12+): session switches, state and prefs changes. */ + subscribeState?: (listener: () => void) => () => void; isTabEnabled(id: string): boolean; } @@ -112,10 +124,18 @@ 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. + * 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 }: { store: ObservationClientStore }) { - const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store); +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 = ( @@ -175,40 +195,56 @@ export function registerObservationSidebar( title: TAB_TITLE, icon: (size: number) => createElement(TabIcon, { size }), single: true, - badge: () => { - const count = store.getSnapshot().sessions.length; + // 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: () => createElement(ObservationSidebarTab, { store }), + component: (props) => + createElement(ObservationSidebarTab, { store, scopeId: props.scope.sessionId }), }); - // Open the tab when the first browser session appears (and right away - // when one is already live at activation). The open carries a content - // seed (`path`): only content opens land in sight — the sidebar expands - // the hosting panel for them, while type-only opens silently no-op - // behind a collapsed panel. The pseudo-path is inert (our component - // never reads tab.path). 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 nudges the existing tab back into - // sight (a content open focuses + expands), mirroring how the floating - // card used to reappear. - let previousCount = store.getSnapshot().sessions.length; - const maybeOpen = (): void => { - const state = service.getSnapshot().state; - if (state === undefined) return; - if (!service.isTabEnabled(OBSERVATION_TAB_TYPE)) return; - if (observationTabOpen(state) && state.panelOpen !== false) return; - service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH }); + // 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; }; - if (previousCount > 0) maybeOpen(); - const unsubscribe = store.subscribe(() => { - const count = store.getSnapshot().sessions.length; - if (previousCount === 0 && count > 0) maybeOpen(); - previousCount = count; - }); + 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-view.ts b/packages/dsh-plugin-browserskill/src/client/observation-view.ts index f0568c62..714f2b67 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-view.ts +++ b/packages/dsh-plugin-browserskill/src/client/observation-view.ts @@ -54,27 +54,52 @@ export interface ObservationView { 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). + * 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): ObservationView { +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 ? snapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined; - const focus = pinned ?? focusOf(snapshot.sessions); + 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 = snapshot.sessions.some((s) => s.action !== "idle"); + const anyActive = scopedSnapshot.sessions.some((s) => s.action !== "idle"); const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!anyActive) return; @@ -82,7 +107,13 @@ export function useObservationView(store: ObservationClientStore): ObservationVi return () => clearInterval(timer); }, [anyActive]); - return { snapshot, focus, pinnedId: pinned !== undefined ? pinnedId : null, onTogglePin, now }; + return { + snapshot: scopedSnapshot, + focus, + pinnedId: pinned !== undefined ? pinnedId : null, + onTogglePin, + now, + }; } export interface PipHandle { diff --git a/packages/dsh-plugin-browserskill/src/observation.ts b/packages/dsh-plugin-browserskill/src/observation.ts index 27b02a72..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); diff --git a/packages/dsh-plugin-browserskill/src/sessions.ts b/packages/dsh-plugin-browserskill/src/sessions.ts index 759463b6..0fac4a2c 100644 --- a/packages/dsh-plugin-browserskill/src/sessions.ts +++ b/packages/dsh-plugin-browserskill/src/sessions.ts @@ -105,6 +105,11 @@ export class SessionRegistry { 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/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx index c204678c..7ebf6a30 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -14,6 +14,7 @@ import { ObservationSidebarTab, observationTabOpen, registerObservationSidebar, + type SidebarNodeLike, type SidebarStateLike, type TabDescriptorLike, } from "../../src/client/observation-sidebar"; @@ -21,7 +22,20 @@ import { type EventSourceLike, ObservationClientStore } from "../../src/client/o import { getSidebarMode, setSidebarMode } from "../../src/client/sidebar-mode"; import type { SessionObservation } from "../../src/observation"; -const BUSY: SessionObservation = { sessionId: "s1", action: "clicking", since: Date.now() - 7000 }; +// 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; @@ -87,12 +101,32 @@ interface SidebarMock { 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(); + 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) => { @@ -202,18 +236,33 @@ describe("registerObservationSidebar", () => { ); }); - it("shows the live session count as the tab badge", () => { + 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; - expect(badge?.()).toBeNull(); + const scope = { sessionId: "conv-1" }; + expect(badge?.(undefined, scope, undefined)).toBeNull(); h.store.acquire(); - h.push([BUSY]); - expect(badge?.()).toBe(1); + 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)); + }); + it("uses the BrowserSkill product mark as the tab icon", () => { const h = makeHarness([]); const sidebar = makeSidebar(); @@ -251,7 +300,7 @@ describe("ObservationSidebarTab", () => { it("renders the tracking view without card chrome (no collapse, no drag header)", async () => { const h = makeHarness([BUSY]); - render(); + render(); await screen.findByText(/s1 · clicking/); expect(screen.queryByRole("button", { name: "Collapse" })).toBeNull(); expect(screen.getByTestId("obs-header").dataset.draggable).toBeUndefined(); @@ -261,6 +310,19 @@ describe("ObservationSidebarTab", () => { 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/); + }); + it("still pops out into a PiP window and returns on pagehide", async () => { const h = makeHarness([BUSY]); const pipDoc = document.implementation.createHTMLDocument("pip"); @@ -275,7 +337,7 @@ describe("ObservationSidebarTab", () => { } as unknown as Window; }), }; - render(); + render(); const popout = await screen.findByRole("button", { name: /Pop out/ }); fireEvent.click(popout); await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); @@ -302,7 +364,7 @@ describe("carrier switching", () => { 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(); + const tab = render(); overlay.unmount(); h.push([BUSY]); await screen.findByText(/s1 · clicking/); diff --git a/packages/dsh-plugin-browserskill/tests/observation.test.ts b/packages/dsh-plugin-browserskill/tests/observation.test.ts index 6f472b30..01b4d7ac 100644 --- a/packages/dsh-plugin-browserskill/tests/observation.test.ts +++ b/packages/dsh-plugin-browserskill/tests/observation.test.ts @@ -197,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"); From eadfb6bab8ce8de8e0579ebf989d015b25f2213b Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 22 Aug 2026 12:50:06 +0000 Subject: [PATCH 10/12] test(dsh-plugin-browserskill): prune redundant and cosmetic sidebar tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop the tab-icon data-URL assertion (cosmetic constant, no logic) - drop the sidebar PiP round-trip (already covered through the shared usePip path by the overlay's PiP test) - fold the feed-lifetime check into the registration lifecycle test - fold disarm-silence, empty-identity, and empty-ownership guards into their parent behavior tests No coverage lost: 156 tests (was 162, −6 cases). --- .../tests/archive-cleanup.test.ts | 37 +++++-------- .../tests/client/observation-sidebar.test.tsx | 55 ++----------------- 2 files changed, 20 insertions(+), 72 deletions(-) diff --git a/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts b/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts index a2d5ba05..5f525540 100644 --- a/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts +++ b/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts @@ -21,11 +21,8 @@ function ctxWithSessions(headers: Record) { } describe("ownerSessionIds", () => { - it("returns [] without an agent identity", () => { + it("walks the seed lineage to the root; empty without an agent identity", () => { expect(ownerSessionIds(ctxWithSessions({}), undefined)).toEqual([]); - }); - - it("walks the seed lineage to the root", () => { const ctx = ctxWithSessions({ child: { parentSession: "parent" }, parent: { parentSession: "root" }, @@ -54,7 +51,7 @@ describe("SessionRegistry owner tracking", () => { registry.completeStart({ sessionId, startedAtMs: 1 }); } - it("indexes owners and forgets them on remove", () => { + it("indexes owners and forgets them on remove; ignores empty/unknown ownership", () => { const registry = new SessionRegistry(5); start(registry, "bsk1"); start(registry, "bsk2"); @@ -66,14 +63,10 @@ describe("SessionRegistry owner tracking", () => { expect(registry.ownedByDsh("nobody")).toEqual([]); registry.remove("bsk1"); expect(registry.ownedByDsh("root")).toEqual([]); - }); - - it("ignores empty ownership and unknown sessions", () => { - const registry = new SessionRegistry(5); - start(registry, "bsk1"); - registry.trackOwner("bsk1", []); - registry.trackOwner("ghost", ["conv-a"]); - expect(registry.ownedByDsh("conv-a")).toEqual([]); + // Empty owner lists and unknown session ids record nothing. + registry.trackOwner("bsk2", []); + registry.trackOwner("ghost", ["conv-c"]); + expect(registry.ownedByDsh("conv-c")).toEqual([]); }); }); @@ -109,7 +102,7 @@ describe("armArchiveCleanup", () => { registry.trackOwner(bskId, owners); } - it("stops every bsk session owned by a freshly archived conversation", () => { + 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"]); @@ -121,7 +114,14 @@ describe("armArchiveCleanup", () => { }); 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", () => { @@ -151,13 +151,4 @@ describe("armArchiveCleanup", () => { h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } }); expect(h.stopSession).toHaveBeenCalledTimes(2); }); - - it("stops firing after the disposer runs", () => { - const h = harness(); - startOwned(h.registry, "bsk1", ["conv-a"]); - const disarm = h.arm(); - disarm(); - h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } }); - expect(h.stopSession).not.toHaveBeenCalled(); - }); }); diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx index 7ebf6a30..efbfb0a9 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-sidebar.test.tsx @@ -3,9 +3,8 @@ // flag hiding the floating overlay, PiP pop-out from the tab, and the // store's refcounted acquire/release across overlapping carriers. -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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, @@ -157,12 +156,15 @@ afterEach(() => { }); describe("registerObservationSidebar", () => { - it("registers a single-instance tab and flips the carrier flag", () => { + 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); @@ -170,14 +172,6 @@ describe("registerObservationSidebar", () => { dispose(); expect(getSidebarMode()).toBe(false); expect(sidebar.disposeTab).toHaveBeenCalledTimes(1); - }); - - it("holds the observation feed for the sidebar lifetime", () => { - const h = makeHarness([]); - const sidebar = makeSidebar(); - const dispose = registerObservationSidebar(sidebar.service, h.store); - expect(h.es()).toBeDefined(); - dispose(); expect(h.es()?.close).toHaveBeenCalled?.(); }); @@ -262,17 +256,6 @@ describe("registerObservationSidebar", () => { h.push([BUSY]); await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1)); }); - - it("uses the BrowserSkill product mark as the tab icon", () => { - const h = makeHarness([]); - const sidebar = makeSidebar(); - registerObservationSidebar(sidebar.service, h.store); - const icon = sidebar.descriptor().icon as (size: number) => ReactNode; - const { container } = render(<>{icon(16)}); - const img = container.querySelector("img"); - expect(img?.getAttribute("src")).toContain("data:image/png;base64,"); - expect(img?.getAttribute("width")).toBe("16"); - }); }); describe("observationTabOpen", () => { @@ -294,10 +277,6 @@ describe("observationTabOpen", () => { }); describe("ObservationSidebarTab", () => { - beforeEach(() => { - delete (window as unknown as Record).documentPictureInPicture; - }); - it("renders the tracking view without card chrome (no collapse, no drag header)", async () => { const h = makeHarness([BUSY]); render(); @@ -322,28 +301,6 @@ describe("ObservationSidebarTab", () => { render(); await screen.findByText(/s9 · clicking/); }); - - it("still pops out into a PiP window and returns on pagehide", async () => { - const h = makeHarness([BUSY]); - const pipDoc = document.implementation.createHTMLDocument("pip"); - const listeners = new Map void)[]>(); - (window as unknown as Record).documentPictureInPicture = { - requestWindow: vi.fn(async () => { - return { - document: pipDoc, - addEventListener: (name: string, fn: () => void) => { - listeners.set(name, [...(listeners.get(name) ?? []), fn]); - }, - } as unknown as Window; - }), - }; - render(); - const popout = await screen.findByRole("button", { name: /Pop out/ }); - fireEvent.click(popout); - await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); - for (const fn of listeners.get("pagehide") ?? []) fn(); - await screen.findByText(/s1 · clicking/); - }); }); describe("carrier switching", () => { From 1eb7be67fc4e24fdad65e5604a281d444a4fac16 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Sat, 22 Aug 2026 12:53:25 +0000 Subject: [PATCH 11/12] style(dsh-plugin-browserskill): fix biome formatting so pnpm lint passes --- .../dsh-plugin-browserskill/src/archive-cleanup.ts | 9 ++++++--- .../src/client/observation-sidebar.tsx | 12 ++++++++---- .../src/client/observation-view.ts | 4 +--- .../tests/archive-cleanup.test.ts | 9 ++++++--- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/archive-cleanup.ts b/packages/dsh-plugin-browserskill/src/archive-cleanup.ts index 578dc765..c919b80e 100644 --- a/packages/dsh-plugin-browserskill/src/archive-cleanup.ts +++ b/packages/dsh-plugin-browserskill/src/archive-cleanup.ts @@ -70,8 +70,9 @@ export function armArchiveCleanup( ): () => 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; + 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. */ @@ -91,7 +92,9 @@ export function armArchiveCleanup( 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)); + 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)) { diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index 9e947ce8..96095d8b 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -199,9 +199,9 @@ export function registerObservationSidebar( // 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; + const count = store + .getSnapshot() + .sessions.filter((s) => visibleToScope(s, scope.sessionId)).length; return count > 0 ? count : null; }, component: (props) => @@ -228,7 +228,11 @@ export function registerObservationSidebar( const evaluate = (): void => { const count = activeVisibleCount(); const { state, sessionId } = service.getSnapshot(); - if (state !== undefined && sessionId !== undefined && service.isTabEnabled(OBSERVATION_TAB_TYPE)) { + 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 }); diff --git a/packages/dsh-plugin-browserskill/src/client/observation-view.ts b/packages/dsh-plugin-browserskill/src/client/observation-view.ts index 714f2b67..2494fa37 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-view.ts +++ b/packages/dsh-plugin-browserskill/src/client/observation-view.ts @@ -91,9 +91,7 @@ export function useObservationView( const [pinnedId, setPinnedId] = useState(null); const pinned = - pinnedId !== null - ? scopedSnapshot.sessions.find((s) => s.sessionId === pinnedId) - : undefined; + 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)); diff --git a/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts b/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts index 5f525540..9115faf7 100644 --- a/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts +++ b/packages/dsh-plugin-browserskill/tests/archive-cleanup.test.ts @@ -13,8 +13,7 @@ function ctxWithSessions(headers: Record) { get: (key: string) => key === "sessions" ? { - get: (id: string) => - headers[id] === undefined ? undefined : { header: headers[id] }, + get: (id: string) => (headers[id] === undefined ? undefined : { header: headers[id] }), } : undefined, } as never; @@ -136,7 +135,11 @@ describe("armArchiveCleanup", () => { 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"] } }); + h.emit({ + domain: "workspace", + table: "", + value: { archivedSessionIds: ["old-conv", "conv-a"] }, + }); expect(h.stopSession).toHaveBeenCalledWith("bsk2"); }); From c64572c111a667ee6ae15d26b9f6c44286e7be94 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Tue, 25 Aug 2026 02:30:28 +0000 Subject: [PATCH 12/12] fix(dsh-plugin-browserskill): close the Document PiP window on carrier unmount Unmounting the carrier (tab close, conversation switch, plugin HMR) does not destroy the PiP browsing context, so the window survived as a blank panel after the portal content disappeared. Close it from a useEffect cleanup; close() on an already-closed window is a no-op. --- .../src/client/observation-view.ts | 4 ++++ .../tests/client/observation-overlay.test.tsx | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/dsh-plugin-browserskill/src/client/observation-view.ts b/packages/dsh-plugin-browserskill/src/client/observation-view.ts index 2494fa37..987ddc62 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-view.ts +++ b/packages/dsh-plugin-browserskill/src/client/observation-view.ts @@ -140,5 +140,9 @@ export function usePip(): PipHandle { }) .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/tests/client/observation-overlay.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx index 5ec49dad..7290296f 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx @@ -261,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 }; @@ -275,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",