mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70b851a3e8 | ||
|
|
da3534d066 | ||
|
|
a004291848 | ||
|
|
4b6cdde168 |
Generated
+2
-2
@@ -134,7 +134,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk"
|
name = "bsk"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bsk-protocol",
|
"bsk-protocol",
|
||||||
@@ -168,7 +168,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bsk-protocol"
|
name = "bsk-protocol"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"semver",
|
"semver",
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ members = [
|
|||||||
default-members = ["crates/bsk-cli"]
|
default-members = ["crates/bsk-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Archive-triggered bsk session cleanup. DSH conversation archival writes
|
||||||
|
* the workspace registry's durable global state, which the storage domain
|
||||||
|
* broadcasts as `domain/changed` ({domain: 'workspace', table: ''}) with
|
||||||
|
* the new `archivedSessionIds`. The watcher diffs that set and stops every
|
||||||
|
* bsk session owned by a freshly archived conversation — archived sessions
|
||||||
|
* are hidden from every surface, so their Agent Windows would otherwise
|
||||||
|
* linger with no way back to them.
|
||||||
|
*
|
||||||
|
* Ownership lineage: browser_session_start records the calling agent's
|
||||||
|
* session id PLUS every ancestor along `header.parentSession` (a subagent's
|
||||||
|
* browsers are reaped when any ancestor is archived, the root conversation
|
||||||
|
* included). Lineage is resolved through the host session store; an
|
||||||
|
* unloaded ancestor simply ends the walk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
|
import type { ObservationService } from "./observation";
|
||||||
|
import type { SessionRegistry } from "./sessions";
|
||||||
|
|
||||||
|
/** The wire shape of a `domain/changed` frame (see dsh-storage-domain). */
|
||||||
|
interface DomainChange {
|
||||||
|
domain?: string;
|
||||||
|
table?: string;
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The workspace registry's durable global singleton slice we read. */
|
||||||
|
interface WorkspaceGlobal {
|
||||||
|
archivedSessionIds?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structural host session store face: only the lineage read is needed. */
|
||||||
|
interface SessionStoreLike {
|
||||||
|
get(id: string): { header: { parentSession?: string } } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cap on lineage walks — defensive against a malformed parent chain. */
|
||||||
|
const MAX_LINEAGE_DEPTH = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DSH session ids that own a tool call's browser sessions: the calling
|
||||||
|
* agent's own session plus every ancestor along the seed lineage. Empty
|
||||||
|
* when the call carried no agent identity (those sessions outlive any
|
||||||
|
* archive cleanup by design — nothing can name their owner).
|
||||||
|
*/
|
||||||
|
export function ownerSessionIds(ctx: Context, agentId: string | undefined): string[] {
|
||||||
|
if (agentId === undefined) return [];
|
||||||
|
const store = ctx.get("sessions") as SessionStoreLike | undefined;
|
||||||
|
const ids: string[] = [];
|
||||||
|
let current: string | undefined = agentId;
|
||||||
|
for (let depth = 0; current !== undefined && depth < MAX_LINEAGE_DEPTH; depth += 1) {
|
||||||
|
if (ids.includes(current)) break; // a cycle in stored headers must not loop
|
||||||
|
ids.push(current);
|
||||||
|
current = store?.get(current)?.header.parentSession;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch conversation archival and stop the bsk sessions it opened. Returns
|
||||||
|
* the disposer (plugin unload). In compositions without the workspace
|
||||||
|
* domain (headless), the event simply never fires — and a context without
|
||||||
|
* the events mixin degrades to a no-op like the other optional seams.
|
||||||
|
*/
|
||||||
|
export function armArchiveCleanup(
|
||||||
|
ctx: Context,
|
||||||
|
registry: SessionRegistry,
|
||||||
|
observation: ObservationService,
|
||||||
|
): () => void {
|
||||||
|
// 'domain/changed' lives outside the vendored Events type map, so the
|
||||||
|
// listener goes through a structural view of the events mixin.
|
||||||
|
const on = (
|
||||||
|
ctx as { on?: (event: string, listener: (change: DomainChange) => void) => () => void }
|
||||||
|
).on;
|
||||||
|
if (typeof on !== "function") return () => {};
|
||||||
|
|
||||||
|
/** Archived ids already accounted for; lazily seeded from the registry. */
|
||||||
|
let seen: Set<string> | undefined;
|
||||||
|
const initialize = (): Set<string> => {
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,17 +30,51 @@
|
|||||||
color: var(--card-foreground);
|
color: var(--card-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar carrier: fill the better-sidebar tab area and let the body flex
|
||||||
|
(the tab content container owns the outer sizing). */
|
||||||
|
.sidebar-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab > * {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The BSK product mark in the sidebar tab strip (the source artwork already
|
||||||
|
carries rounded corners — just soften the square to match). */
|
||||||
|
.brand-icon {
|
||||||
|
display: block;
|
||||||
|
border-radius: 22%;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
cursor: move;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only the floating card drags by its header — the sidebar tab does not. */
|
||||||
|
.header[data-draggable] {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In the sidebar the status row is chrome, not a card edge: drop its bottom
|
||||||
|
border. A fixed-height bordered row can never track the shell header's
|
||||||
|
hairline across the panel boundary (its height varies per window), and
|
||||||
|
the BSK warm border tint clashes with the sidebar's neutral hairlines —
|
||||||
|
borderless, the stage's own background provides the separation. */
|
||||||
|
.sidebar-tab .header {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -131,6 +165,12 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-wrap {
|
.tool-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -165,8 +205,14 @@
|
|||||||
color: var(--destructive);
|
color: var(--destructive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover the wrap (not just :hover on the button) so the tooltip target and
|
/* Stop session: danger-tinted like interrupt. */
|
||||||
the left-corner resize overlay still light the icon up. */
|
.tool-stop {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover the wrap (not just :hover on the button) so the tooltip target
|
||||||
|
lights the icon up too. The corner resize handles sit above this bar, so
|
||||||
|
the outer sliver of the corner-most buttons starts a resize instead. */
|
||||||
.tool-wrap:hover .tool-button:not(:disabled) {
|
.tool-wrap:hover .tool-button:not(:disabled) {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -181,6 +227,15 @@
|
|||||||
background: var(--accent);
|
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 {
|
.hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 4px);
|
bottom: calc(100% + 4px);
|
||||||
@@ -203,13 +258,16 @@
|
|||||||
right: 0;
|
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 {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle[data-corner="nw"] {
|
.resize-handle[data-corner="nw"] {
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
// composer's way); collapsed = a status capsule; "pop out" upgrades the same
|
// composer's way); collapsed = a status capsule; "pop out" upgrades the same
|
||||||
// content to a native Document PiP window (user gesture required by the
|
// content to a native Document PiP window (user gesture required by the
|
||||||
// browser). Multi-session renders a meeting-style strip under the focus view.
|
// browser). Multi-session renders a meeting-style strip under the focus view.
|
||||||
// Visuals follow the BrowserSkill product family: @browser-skill/ui components
|
// When the dsh-better-sidebar plugin is installed the tracking view moves
|
||||||
// and oklch tokens on a .bsk-obs scope root, so the card reads as BSK's own
|
// into a sidebar tab instead (see observation-sidebar.tsx) and this floating
|
||||||
// surface without leaking styles into (or inheriting themes from) the shell.
|
// 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 { cn } from "@browser-skill/ui";
|
||||||
import {
|
import {
|
||||||
RiArrowDownSLine,
|
RiArrowDownSLine,
|
||||||
|
RiCheckLine,
|
||||||
|
RiCloseCircleLine,
|
||||||
RiErrorWarningLine,
|
RiErrorWarningLine,
|
||||||
RiPictureInPicture2Line,
|
RiPictureInPicture2Line,
|
||||||
RiPushpinFill,
|
RiPushpinFill,
|
||||||
@@ -25,6 +30,8 @@ const IconPip = asIcon(RiPictureInPicture2Line);
|
|||||||
const IconDown = asIcon(RiArrowDownSLine);
|
const IconDown = asIcon(RiArrowDownSLine);
|
||||||
const IconWarn = asIcon(RiErrorWarningLine);
|
const IconWarn = asIcon(RiErrorWarningLine);
|
||||||
const IconPin = asIcon(RiPushpinFill);
|
const IconPin = asIcon(RiPushpinFill);
|
||||||
|
const IconCloseSession = asIcon(RiCloseCircleLine);
|
||||||
|
const IconCheck = asIcon(RiCheckLine);
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -39,11 +46,12 @@ import { createPortal } from "react-dom";
|
|||||||
import type { SessionObservation } from "../observation";
|
import type { SessionObservation } from "../observation";
|
||||||
import css from "./ObservationOverlay.module.css";
|
import css from "./ObservationOverlay.module.css";
|
||||||
import type { ObservationClientStore } from "./observation-store";
|
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). */
|
// The pure view helpers live in observation-view (shared with the sidebar
|
||||||
interface DocumentPip {
|
// tab); re-export so existing imports of this module keep working.
|
||||||
requestWindow(options?: { width?: number; height?: number }): Promise<Window>;
|
export { focusOf, statusOf };
|
||||||
}
|
|
||||||
|
|
||||||
interface Point {
|
interface Point {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -61,11 +69,6 @@ const EDGE_MARGIN = 16;
|
|||||||
/** Default dock: top-right, just under the shell's top bar (no spacing tokens exist in dsh 0.1). */
|
/** Default dock: top-right, just under the shell's top bar (no spacing tokens exist in dsh 0.1). */
|
||||||
const TOP_OFFSET = 64;
|
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 {
|
function clampSize(size: Size, viewport: Size): Size {
|
||||||
const maxW = viewport.w * 0.8;
|
const maxW = viewport.w * 0.8;
|
||||||
const maxH = viewport.h * 0.8;
|
const maxH = viewport.h * 0.8;
|
||||||
@@ -114,23 +117,6 @@ function formatElapsed(sinceMs: number, nowMs: number): string {
|
|||||||
return `${mm}:${ss}`;
|
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. */
|
/** Compact toolbar icon: no label, hover bubble for the name / semantics. */
|
||||||
function IconAction(props: {
|
function IconAction(props: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -166,6 +152,83 @@ function IconAction(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long the stop button stays armed before the confirm click expires. */
|
||||||
|
const STOP_ARM_MS = 3000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop-session button with a lightweight two-click confirm: the first click
|
||||||
|
* arms the button (solid red, check icon, short expiry), the second executes
|
||||||
|
* the stop. No dialog, no layout shift — and a stray single click can never
|
||||||
|
* close an Agent Window.
|
||||||
|
*/
|
||||||
|
function StopSessionAction(props: {
|
||||||
|
sessionId: string | undefined;
|
||||||
|
onStop: (sessionId: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className={css["tool-wrap"]}
|
||||||
|
onPointerEnter={() => setHover(true)}
|
||||||
|
onPointerLeave={() => setHover(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(css["tool-button"], css["tool-stop"], armed && css["tool-stop-armed"])}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={armed}
|
||||||
|
data-armed={armed || undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (sessionId === undefined || stopping) return;
|
||||||
|
if (!armed) {
|
||||||
|
setArmed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setArmed(false);
|
||||||
|
setStopping(true);
|
||||||
|
const target = sessionId;
|
||||||
|
void onStop(target).finally(() => setStopping(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{armed ? <IconCheck size={16} /> : <IconCloseSession size={16} />}
|
||||||
|
</button>
|
||||||
|
{hover ? (
|
||||||
|
<span className={css.hint} role="tooltip">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */
|
/** Flat status dot, specced after the BSK popup's ConnectionStatusIndicator. */
|
||||||
function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) {
|
function StatusDot({ state }: { state: "active" | "idle" | "error" | "dead" }) {
|
||||||
const color =
|
const color =
|
||||||
@@ -246,8 +309,8 @@ function StripItem(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The floating card / PiP shared content. */
|
/** The floating card / PiP / sidebar-tab shared content. */
|
||||||
function OverlayBody(props: {
|
export function OverlayBody(props: {
|
||||||
store: ObservationClientStore;
|
store: ObservationClientStore;
|
||||||
focus: SessionObservation | undefined;
|
focus: SessionObservation | undefined;
|
||||||
sessions: readonly SessionObservation[];
|
sessions: readonly SessionObservation[];
|
||||||
@@ -310,6 +373,7 @@ function OverlayBody(props: {
|
|||||||
<div
|
<div
|
||||||
className={css.header}
|
className={css.header}
|
||||||
data-testid="obs-header"
|
data-testid="obs-header"
|
||||||
|
data-draggable={onHeaderPointerDown !== undefined || undefined}
|
||||||
onPointerDown={onHeaderPointerDown}
|
onPointerDown={onHeaderPointerDown}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
@@ -363,6 +427,7 @@ function OverlayBody(props: {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={css.actions}>
|
<div className={css.actions}>
|
||||||
|
<div className={css["actions-group"]}>
|
||||||
<IconAction
|
<IconAction
|
||||||
label={interrupting ? "Interrupting…" : "Interrupt the current browser action"}
|
label={interrupting ? "Interrupting…" : "Interrupt the current browser action"}
|
||||||
hint="Stop the current browser action."
|
hint="Stop the current browser action."
|
||||||
@@ -372,6 +437,11 @@ function OverlayBody(props: {
|
|||||||
>
|
>
|
||||||
<IconStop size={16} />
|
<IconStop size={16} />
|
||||||
</IconAction>
|
</IconAction>
|
||||||
|
<StopSessionAction
|
||||||
|
sessionId={focus?.sessionId}
|
||||||
|
onStop={(sessionId) => store.stopSession(sessionId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{onPopOut !== undefined ? (
|
{onPopOut !== undefined ? (
|
||||||
<IconAction
|
<IconAction
|
||||||
label="Pop out into a mini window"
|
label="Pop out into a mini window"
|
||||||
@@ -387,25 +457,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 }) {
|
export function ObservationOverlay({ store }: { store: ObservationClientStore }) {
|
||||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store);
|
||||||
useEffect(() => {
|
// While the better-sidebar plugin carries the tracking view, this floating
|
||||||
store.start();
|
// card (and its capsule) stays out of the way; an already-open PiP window
|
||||||
return () => store.stop();
|
// keeps its portal until the user closes it.
|
||||||
}, [store]);
|
const sidebarMode = useSyncExternalStore(subscribeSidebarMode, getSidebarMode);
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [pos, setPos] = useState<Point | null>(null);
|
const [pos, setPos] = useState<Point | null>(null);
|
||||||
const [size, setSize] = useState<Size>(DEFAULT_SIZE);
|
const [size, setSize] = useState<Size>(DEFAULT_SIZE);
|
||||||
const [pipWindow, setPipWindow] = useState<Window | null>(null);
|
const { pipWindow, pipSupported, popOut } = usePip();
|
||||||
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
kind: "move" | "resize";
|
kind: "move" | "resize";
|
||||||
corner?: ResizeCorner;
|
corner?: ResizeCorner;
|
||||||
@@ -414,23 +476,6 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
|||||||
base: Point & Size;
|
base: Point & Size;
|
||||||
} | null>(null);
|
} | 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 viewport = (): Size => ({ w: window.innerWidth, h: window.innerHeight });
|
||||||
|
|
||||||
const onPointerMove = useCallback((event: PointerEvent) => {
|
const onPointerMove = useCallback((event: PointerEvent) => {
|
||||||
@@ -511,34 +556,21 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
|||||||
document.addEventListener("pointerup", onPointerUp);
|
document.addEventListener("pointerup", onPointerUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
const popOut = async (): Promise<void> => {
|
|
||||||
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 = (
|
const body = (
|
||||||
<OverlayBody
|
<OverlayBody
|
||||||
store={store}
|
store={store}
|
||||||
focus={focus}
|
focus={focus}
|
||||||
sessions={snapshot.sessions}
|
sessions={snapshot.sessions}
|
||||||
available={snapshot.available}
|
available={snapshot.available}
|
||||||
pinnedId={pinned !== undefined ? pinnedId : null}
|
pinnedId={pinnedId}
|
||||||
onTogglePin={onTogglePin}
|
onTogglePin={onTogglePin}
|
||||||
now={now}
|
now={now}
|
||||||
inPip={pipWindow !== null}
|
inPip={pipWindow !== null}
|
||||||
onPopOut={pipWindow === null && pipApi() !== undefined ? () => void popOut() : undefined}
|
onPopOut={
|
||||||
|
pipWindow === null && pipSupported
|
||||||
|
? () => popOut({ width: size.w, height: size.h })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined}
|
onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined}
|
||||||
onHeaderPointerDown={pipWindow === null ? beginMove : undefined}
|
onHeaderPointerDown={pipWindow === null ? beginMove : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -548,6 +580,12 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
|
|||||||
return createPortal(body, pipWindow.document.body);
|
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) {
|
if (collapsed) {
|
||||||
const state = focus !== undefined ? statusOf(focus) : "idle";
|
const state = focus !== undefined ? statusOf(focus) : "idle";
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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=";
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* dsh-plugin-browserskill browser half: the `browser_screenshot` keyed
|
* dsh-plugin-browserskill browser half: the `browser_screenshot` keyed
|
||||||
* toolview plus the observation overlay (live thumbnails + interrupt) floating
|
* toolview plus the observation overlay (live thumbnails + interrupt). The
|
||||||
* over the shell via the `shell.overlay` seat.
|
* 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";
|
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
||||||
@@ -15,6 +17,7 @@ import { createElement } from "react";
|
|||||||
import "./bsk-tokens.nomodule.css";
|
import "./bsk-tokens.nomodule.css";
|
||||||
import "./bsk-ui.nomodule.css";
|
import "./bsk-ui.nomodule.css";
|
||||||
import { ObservationOverlay } from "./ObservationOverlay";
|
import { ObservationOverlay } from "./ObservationOverlay";
|
||||||
|
import { type BetterSidebarLike, registerObservationSidebar } from "./observation-sidebar";
|
||||||
import { type EventSourceLike, ObservationClientStore } from "./observation-store";
|
import { type EventSourceLike, ObservationClientStore } from "./observation-store";
|
||||||
import { ScreenshotToolView } from "./ScreenshotToolView";
|
import { ScreenshotToolView } from "./ScreenshotToolView";
|
||||||
|
|
||||||
@@ -81,4 +84,14 @@ export function apply(ctx: ClientContext): void {
|
|||||||
createElement(ObservationOverlay, { store }),
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// better-sidebar carrier for the observation view. When the
|
||||||
|
// dsh-better-sidebar plugin is installed its client publishes a
|
||||||
|
// `betterSidebar` cordis service; the client entry then runs the
|
||||||
|
// registration below and the tracking view moves from the floating card
|
||||||
|
// into a single-instance sidebar tab (Document PiP pop-out stays available
|
||||||
|
// from inside the tab). Detection is purely service-based — profiles
|
||||||
|
// without the sidebar plugin never start this fiber and keep the floating
|
||||||
|
// overlay.
|
||||||
|
|
||||||
|
import { createElement, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { BSK_LOGO_URL } from "./brand-icon";
|
||||||
|
import { OverlayBody } from "./ObservationOverlay";
|
||||||
|
import css from "./ObservationOverlay.module.css";
|
||||||
|
import type { ObservationClientStore } from "./observation-store";
|
||||||
|
import { useObservationView, usePip, visibleToScope } from "./observation-view";
|
||||||
|
import { setSidebarMode } from "./sidebar-mode";
|
||||||
|
|
||||||
|
/** The tab title — "Browser Skill", distinct from the sidebar's built-in "browser" tab. */
|
||||||
|
const TAB_TITLE = "Browser Skill";
|
||||||
|
|
||||||
|
/** Tab strip icon: the BrowserSkill product mark at the requested size. */
|
||||||
|
function TabIcon({ size }: { size: number }) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={BSK_LOGO_URL}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className={css["brand-icon"]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural mirrors of dsh-better-sidebar's client service surface (only
|
||||||
|
* the slices this integration touches — see the upstream
|
||||||
|
* lib/types/client/service.d.ts). Declared locally so the plugin carries no
|
||||||
|
* dependency on the sidebar package; the service contract has been stable
|
||||||
|
* since v0.4.0 and newer capabilities arrive behind its `features` list.
|
||||||
|
*/
|
||||||
|
export interface SidebarTabLike {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarLeafLike {
|
||||||
|
kind: "leaf";
|
||||||
|
id: string;
|
||||||
|
tabs: SidebarTabLike[];
|
||||||
|
active: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarSplitLike {
|
||||||
|
kind: "split";
|
||||||
|
id: string;
|
||||||
|
dir: "row" | "col";
|
||||||
|
sizes: number[];
|
||||||
|
children: SidebarNodeLike[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SidebarNodeLike = SidebarLeafLike | SidebarSplitLike;
|
||||||
|
|
||||||
|
export interface SidebarStateLike {
|
||||||
|
splits: SidebarNodeLike;
|
||||||
|
bottomSplits: SidebarNodeLike;
|
||||||
|
/** Whether the right panel is expanded (the merged drawer on narrow screens). */
|
||||||
|
panelOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarSnapshotLike {
|
||||||
|
sessionId?: string;
|
||||||
|
state?: SidebarStateLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props every tab component receives (the slices we read). */
|
||||||
|
export interface TabComponentPropsLike {
|
||||||
|
/** The conversation this sidebar instance belongs to. */
|
||||||
|
scope: { sessionId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabDescriptorLike {
|
||||||
|
id: string;
|
||||||
|
title: string | (() => string);
|
||||||
|
icon?: ReactNode | ((size: number) => ReactNode);
|
||||||
|
order?: number;
|
||||||
|
/** Single-instance: opening focuses the existing tab instead of duplicating. */
|
||||||
|
single?: boolean;
|
||||||
|
/** Small pill on the tab strip; null/undefined hides it. */
|
||||||
|
badge?: (
|
||||||
|
ctx: unknown,
|
||||||
|
scope: { sessionId: string },
|
||||||
|
state: unknown,
|
||||||
|
) => string | number | null | undefined;
|
||||||
|
component: (props: TabComponentPropsLike) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetterSidebarLike {
|
||||||
|
registerTab(descriptor: TabDescriptorLike): () => void;
|
||||||
|
openTab(seed: {
|
||||||
|
type: string;
|
||||||
|
title?: string;
|
||||||
|
/** Content seed (lands on tab.path); content opens expand the panel. */
|
||||||
|
path?: string;
|
||||||
|
}): void;
|
||||||
|
getSnapshot(): SidebarSnapshotLike;
|
||||||
|
/** Sidebar state feed (v0.12+): session switches, state and prefs changes. */
|
||||||
|
subscribeState?: (listener: () => void) => () => void;
|
||||||
|
isTabEnabled(id: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The registered tab type id (also the SidebarTab.type value). */
|
||||||
|
export const OBSERVATION_TAB_TYPE = "browserskill:observation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inert content seed carried on auto-opened tabs: its mere presence makes
|
||||||
|
* the sidebar treat the open as a content open (expanding the hosting panel
|
||||||
|
* so the tracking view lands in sight). Never read by our component.
|
||||||
|
*/
|
||||||
|
export const OBSERVATION_TAB_PATH = "browser-skill:observation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The observation tab body: the same OverlayBody the floating card renders,
|
||||||
|
* minus the card chrome (no drag header, no collapse — the sidebar tab bar
|
||||||
|
* owns those), plus the PiP pop-out upgrade. The view is scoped to the
|
||||||
|
* sidebar's conversation: only browser sessions started by it (or its
|
||||||
|
* descendants) show here — the floating card keeps the global view.
|
||||||
|
*/
|
||||||
|
export function ObservationSidebarTab({
|
||||||
|
store,
|
||||||
|
scopeId,
|
||||||
|
}: {
|
||||||
|
store: ObservationClientStore;
|
||||||
|
scopeId: string;
|
||||||
|
}) {
|
||||||
|
const { snapshot, focus, pinnedId, onTogglePin, now } = useObservationView(store, scopeId);
|
||||||
|
const { pipWindow, pipSupported, popOut } = usePip();
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<OverlayBody
|
||||||
|
store={store}
|
||||||
|
focus={focus}
|
||||||
|
sessions={snapshot.sessions}
|
||||||
|
available={snapshot.available}
|
||||||
|
pinnedId={pinnedId}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
now={now}
|
||||||
|
inPip={pipWindow !== null}
|
||||||
|
onPopOut={pipWindow === null && pipSupported ? () => popOut() : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pipWindow !== null) {
|
||||||
|
return createPortal(body, pipWindow.document.body);
|
||||||
|
}
|
||||||
|
return <div className={css["sidebar-tab"]}>{body}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function* leafNodes(node: SidebarNodeLike): Generator<SidebarLeafLike> {
|
||||||
|
if (node.kind === "leaf") {
|
||||||
|
yield node;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const child of node.children) yield* leafNodes(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a tab of our type is already open in either sidebar workbench. */
|
||||||
|
export function observationTabOpen(state: SidebarStateLike | undefined): boolean {
|
||||||
|
if (state === undefined) return false;
|
||||||
|
for (const root of [state.splits, state.bottomSplits]) {
|
||||||
|
for (const leaf of leafNodes(root)) {
|
||||||
|
if (leaf.tabs.some((tab) => tab.type === OBSERVATION_TAB_TYPE)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the observation sidebar tab and flip the carrier flag. Returns
|
||||||
|
* the disposer the cordis fiber invokes when the sidebar service goes away
|
||||||
|
* (plugin unload/HMR): the floating overlay then resumes as the carrier.
|
||||||
|
*/
|
||||||
|
export function registerObservationSidebar(
|
||||||
|
service: BetterSidebarLike,
|
||||||
|
store: ObservationClientStore,
|
||||||
|
): () => void {
|
||||||
|
setSidebarMode(true);
|
||||||
|
// Hold the feed for the whole sidebar lifetime so the auto-open watcher
|
||||||
|
// sees new sessions even while the tab itself is closed.
|
||||||
|
store.acquire();
|
||||||
|
const disposeTab = service.registerTab({
|
||||||
|
id: OBSERVATION_TAB_TYPE,
|
||||||
|
title: TAB_TITLE,
|
||||||
|
icon: (size: number) => createElement(TabIcon, { size }),
|
||||||
|
single: true,
|
||||||
|
// The badge counts only sessions visible to the tab strip's own
|
||||||
|
// conversation (global sessions from other conversations stay hidden,
|
||||||
|
// mirroring the tab body's scoped view).
|
||||||
|
badge: (_ctx, scope) => {
|
||||||
|
const count = store
|
||||||
|
.getSnapshot()
|
||||||
|
.sessions.filter((s) => visibleToScope(s, scope.sessionId)).length;
|
||||||
|
return count > 0 ? count : null;
|
||||||
|
},
|
||||||
|
component: (props) =>
|
||||||
|
createElement(ObservationSidebarTab, { store, scopeId: props.scope.sessionId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-open the tab when a browser session VISIBLE TO the active
|
||||||
|
// conversation appears (and right away when one is already live). The
|
||||||
|
// evaluation runs on observation publishes AND on sidebar state changes:
|
||||||
|
// the latter covers the sidebar store's async init (its state is
|
||||||
|
// undefined for the first beats after page load) and conversation
|
||||||
|
// switches. The open carries a content seed (`path`): only content opens
|
||||||
|
// land in sight — the sidebar expands the hosting panel for them. While
|
||||||
|
// the panel is OPEN an existing tab is never re-focused — the user may
|
||||||
|
// be reading another page on purpose; while it is collapsed a NEW
|
||||||
|
// session (0→N) nudges the tab back into sight, mirroring how the
|
||||||
|
// floating card used to reappear.
|
||||||
|
const activeVisibleCount = (): number => {
|
||||||
|
const activeId = service.getSnapshot().sessionId;
|
||||||
|
if (activeId === undefined) return 0;
|
||||||
|
return store.getSnapshot().sessions.filter((s) => visibleToScope(s, activeId)).length;
|
||||||
|
};
|
||||||
|
let previousVisible = activeVisibleCount();
|
||||||
|
const evaluate = (): void => {
|
||||||
|
const count = activeVisibleCount();
|
||||||
|
const { state, sessionId } = service.getSnapshot();
|
||||||
|
if (
|
||||||
|
state !== undefined &&
|
||||||
|
sessionId !== undefined &&
|
||||||
|
service.isTabEnabled(OBSERVATION_TAB_TYPE)
|
||||||
|
) {
|
||||||
|
const open = observationTabOpen(state);
|
||||||
|
if (count > 0 && !open) {
|
||||||
|
service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH });
|
||||||
|
} else if (previousVisible === 0 && count > 0 && open && state.panelOpen === false) {
|
||||||
|
service.openTab({ type: OBSERVATION_TAB_TYPE, path: OBSERVATION_TAB_PATH });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousVisible = count;
|
||||||
|
};
|
||||||
|
evaluate();
|
||||||
|
const unsubscribe = store.subscribe(evaluate);
|
||||||
|
const unsubscribeState = service.subscribeState?.(evaluate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribeState?.();
|
||||||
|
disposeTab();
|
||||||
|
store.release();
|
||||||
|
setSidebarMode(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ export interface OverlaySnapshot {
|
|||||||
const STATE_URL = "/bsk-observation/state";
|
const STATE_URL = "/bsk-observation/state";
|
||||||
const EVENTS_URL = "/bsk-observation/events";
|
const EVENTS_URL = "/bsk-observation/events";
|
||||||
const INTERRUPT_URL = "/bsk-observation/interrupt";
|
const INTERRUPT_URL = "/bsk-observation/interrupt";
|
||||||
|
const STOP_URL = "/bsk-observation/stop";
|
||||||
|
|
||||||
function revoke(url: string | undefined): void {
|
function revoke(url: string | undefined): void {
|
||||||
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
|
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
|
||||||
@@ -78,6 +79,8 @@ export class ObservationClientStore {
|
|||||||
};
|
};
|
||||||
private available = true;
|
private available = true;
|
||||||
private started = false;
|
private started = false;
|
||||||
|
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
|
||||||
|
private consumers = 0;
|
||||||
|
|
||||||
constructor(private readonly deps: ObservationClientDeps) {}
|
constructor(private readonly deps: ObservationClientDeps) {}
|
||||||
|
|
||||||
@@ -189,6 +192,23 @@ export class ObservationClientStore {
|
|||||||
this.publish();
|
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. */
|
/** Forget one session's tracked + held frames, revoking their blob URLs. */
|
||||||
private dropThumb(sessionId: string): void {
|
private dropThumb(sessionId: string): void {
|
||||||
const attachmentId = this.thumbBySession.get(sessionId);
|
const attachmentId = this.thumbBySession.get(sessionId);
|
||||||
@@ -292,4 +312,23 @@ export class ObservationClientStore {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop one session and close its Agent Window. The session's removal
|
||||||
|
* arrives through the SSE remove event — no local state is touched here.
|
||||||
|
*/
|
||||||
|
async stopSession(sessionId: string): Promise<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Shared view logic for the observation carriers (the floating overlay card
|
||||||
|
* and the better-sidebar tab): the store-backed view model (snapshot, focus
|
||||||
|
* pinning, elapsed ticker) and the Document PiP pop-out. Extracted from
|
||||||
|
* ObservationOverlay so both carriers run the same focus/interrupt behavior
|
||||||
|
* without duplicating hooks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
import type { SessionObservation } from "../observation";
|
||||||
|
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-follow focus: the most recently touched session, but never yank focus
|
||||||
|
* to a session whose latest action failed (errors flag the strip item, they
|
||||||
|
* do not steal the stage) or one already reported dead.
|
||||||
|
*/
|
||||||
|
export function focusOf(sessions: readonly SessionObservation[]): SessionObservation | undefined {
|
||||||
|
if (sessions.length === 0) return undefined;
|
||||||
|
const byRecency = [...sessions].sort((a, b) => b.since - a.since);
|
||||||
|
const healthy = byRecency.find((s) => s.lastError === undefined && s.dead !== true);
|
||||||
|
return healthy ?? byRecency[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusOf(obs: SessionObservation): "active" | "idle" | "error" {
|
||||||
|
if (obs.lastError !== undefined && obs.action === "idle") return "error";
|
||||||
|
return obs.action === "idle" ? "idle" : "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal Document PiP surface (TS lib.dom lacks it). */
|
||||||
|
export interface DocumentPip {
|
||||||
|
requestWindow(options?: { width?: number; height?: number }): Promise<Window>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipApi(): DocumentPip | undefined {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
return (window as unknown as { documentPictureInPicture?: DocumentPip }).documentPictureInPicture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone the host document's style/link nodes into a PiP window. */
|
||||||
|
export function cloneStylesInto(pipWindow: Window): void {
|
||||||
|
for (const node of document.querySelectorAll('link[rel="stylesheet"], style')) {
|
||||||
|
pipWindow.document.head.appendChild(node.cloneNode(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationView {
|
||||||
|
readonly snapshot: OverlaySnapshot;
|
||||||
|
/** Pinned session wins while it still exists; otherwise auto-follow. */
|
||||||
|
readonly focus: SessionObservation | undefined;
|
||||||
|
readonly pinnedId: string | null;
|
||||||
|
readonly onTogglePin: (sessionId: string) => void;
|
||||||
|
/** 1s ticker while anything is active (drives the elapsed readout). */
|
||||||
|
readonly now: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether one session is visible on a surface scoped to one DSH
|
||||||
|
* conversation: sessions started by that conversation or its descendants
|
||||||
|
* (the lineage ancestors ride along on the entry). Untracked sessions (no
|
||||||
|
* owner recorded) are hidden from scoped surfaces but stay in global views.
|
||||||
|
*/
|
||||||
|
export function visibleToScope(obs: SessionObservation, scopeId: string): boolean {
|
||||||
|
return obs.dshSessionIds?.includes(scopeId) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store-backed observation view model. Holds the feed for the component
|
||||||
|
* lifetime (refcounted — overlapping carriers never kill each other's
|
||||||
|
* stream). `scopeId` narrows the view to one DSH conversation's sessions
|
||||||
|
* (the better-sidebar tab); undefined keeps the global view (floating
|
||||||
|
* card, PiP).
|
||||||
|
*/
|
||||||
|
export function useObservationView(
|
||||||
|
store: ObservationClientStore,
|
||||||
|
scopeId?: string,
|
||||||
|
): ObservationView {
|
||||||
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
||||||
|
useEffect(() => {
|
||||||
|
store.acquire();
|
||||||
|
return () => store.release();
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
const scopedSnapshot: OverlaySnapshot =
|
||||||
|
scopeId === undefined
|
||||||
|
? snapshot
|
||||||
|
: {
|
||||||
|
...snapshot,
|
||||||
|
sessions: snapshot.sessions.filter((s) => visibleToScope(s, scopeId)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
||||||
|
const pinned =
|
||||||
|
pinnedId !== null ? scopedSnapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined;
|
||||||
|
const focus = pinned ?? focusOf(scopedSnapshot.sessions);
|
||||||
|
const onTogglePin = useCallback((sessionId: string) => {
|
||||||
|
setPinnedId((current) => (current === sessionId ? null : sessionId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const anyActive = scopedSnapshot.sessions.some((s) => s.action !== "idle");
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anyActive) return;
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [anyActive]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: scopedSnapshot,
|
||||||
|
focus,
|
||||||
|
pinnedId: pinned !== undefined ? pinnedId : null,
|
||||||
|
onTogglePin,
|
||||||
|
now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipHandle {
|
||||||
|
/** The open PiP window, or null while the content renders in its carrier. */
|
||||||
|
readonly pipWindow: Window | null;
|
||||||
|
/** Whether the browser exposes Document PiP (Chrome-only today). */
|
||||||
|
readonly pipSupported: boolean;
|
||||||
|
/**
|
||||||
|
* Upgrade the content into a native PiP window. Rejects silently without a
|
||||||
|
* user gesture (or when closed mid-request): the carrier keeps the content.
|
||||||
|
*/
|
||||||
|
readonly popOut: (size?: { width: number; height: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePip(): PipHandle {
|
||||||
|
const [pipWindow, setPipWindow] = useState<Window | null>(null);
|
||||||
|
const popOut = useCallback((size?: { width: number; height: number }): void => {
|
||||||
|
const pip = pipApi();
|
||||||
|
if (pip === undefined) return;
|
||||||
|
void pip
|
||||||
|
.requestWindow(size)
|
||||||
|
.then((win) => {
|
||||||
|
cloneStylesInto(win);
|
||||||
|
win.addEventListener("pagehide", () => setPipWindow(null));
|
||||||
|
setPipWindow(win);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
// Unmounting the carrier (tab close, conversation switch, plugin HMR) does
|
||||||
|
// not destroy the PiP browsing context: close it ourselves or it survives
|
||||||
|
// as a blank window. close() on an already-closed window is a no-op.
|
||||||
|
useEffect(() => () => pipWindow?.close(), [pipWindow]);
|
||||||
|
return { pipWindow, pipSupported: pipApi() !== undefined, popOut };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import type { Context } from "@deepseek-ai/cordis";
|
import type { Context } from "@deepseek-ai/cordis";
|
||||||
import Schema from "@deepseek-ai/schemastery";
|
import Schema from "@deepseek-ai/schemastery";
|
||||||
|
import { armArchiveCleanup } from "./archive-cleanup";
|
||||||
import { armLazyTools } from "./lazy-tools";
|
import { armLazyTools } from "./lazy-tools";
|
||||||
import { ObservationService } from "./observation";
|
import { ObservationService } from "./observation";
|
||||||
import { registerObservationRoutes } from "./observation-http";
|
import { registerObservationRoutes } from "./observation-http";
|
||||||
@@ -107,6 +108,10 @@ export function apply(
|
|||||||
removeRoutes = registerObservationRoutes(injected, observation);
|
removeRoutes = registerObservationRoutes(injected, observation);
|
||||||
return () => removeRoutes();
|
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
|
// 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
|
// failing the first tool call with a bare spawn error. Uses --version on
|
||||||
@@ -132,6 +137,7 @@ export function apply(
|
|||||||
removeSuite();
|
removeSuite();
|
||||||
unregisterSkill();
|
unregisterSkill();
|
||||||
removeRoutes();
|
removeRoutes();
|
||||||
|
disarmArchiveCleanup();
|
||||||
observation.dispose();
|
observation.dispose();
|
||||||
runner.killAll();
|
runner.killAll();
|
||||||
const stops = registry
|
const stops = registry
|
||||||
@@ -144,6 +150,7 @@ export function apply(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { armArchiveCleanup, ownerSessionIds } from "./archive-cleanup";
|
||||||
export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation";
|
export type { ObservationEvent, ObservationOptions, SessionObservation } from "./observation";
|
||||||
export { ObservationService } from "./observation";
|
export { ObservationService } from "./observation";
|
||||||
export { registerObservationRoutes } from "./observation-http";
|
export { registerObservationRoutes } from "./observation-http";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* GET /bsk-observation/state → { sessions, available }
|
* GET /bsk-observation/state → { sessions, available }
|
||||||
* GET /bsk-observation/events → SSE stream of ObservationEvent
|
* GET /bsk-observation/events → SSE stream of ObservationEvent
|
||||||
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
|
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
|
||||||
|
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
|
||||||
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
|
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
|
||||||
*
|
*
|
||||||
* Routes exist only when a webServer service is mounted (web composition);
|
* 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({
|
webServer.register({
|
||||||
kind: "prefix",
|
kind: "prefix",
|
||||||
path: `${ROUTE_BASE}/thumbnail`,
|
path: `${ROUTE_BASE}/thumbnail`,
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ export interface SessionObservation {
|
|||||||
* greys it out until it is stopped/removed. No more frames are requested.
|
* greys it out until it is stopped/removed. No more frames are requested.
|
||||||
*/
|
*/
|
||||||
dead?: boolean;
|
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). */
|
/** Incremental event carried to subscribers (SSE on the wire). */
|
||||||
@@ -153,11 +160,13 @@ export class ObservationService {
|
|||||||
/** Register a fresh owned session (called from browser_session_start). */
|
/** Register a fresh owned session (called from browser_session_start). */
|
||||||
addSession(sessionId: string, url?: string): void {
|
addSession(sessionId: string, url?: string): void {
|
||||||
if (!this.deps.options.enabled || this.disposed) return;
|
if (!this.deps.options.enabled || this.disposed) return;
|
||||||
|
const dshSessionIds = this.deps.registry.dshOwnersOf(sessionId);
|
||||||
this.put({
|
this.put({
|
||||||
sessionId,
|
sessionId,
|
||||||
...(url !== undefined ? { url } : {}),
|
...(url !== undefined ? { url } : {}),
|
||||||
action: "idle",
|
action: "idle",
|
||||||
since: this.scheduler.now(),
|
since: this.scheduler.now(),
|
||||||
|
...(dshSessionIds.length > 0 ? { dshSessionIds } : {}),
|
||||||
});
|
});
|
||||||
this.lastActivity.set(sessionId, this.scheduler.now());
|
this.lastActivity.set(sessionId, this.scheduler.now());
|
||||||
this.scheduleCapture(sessionId, 0);
|
this.scheduleCapture(sessionId, 0);
|
||||||
@@ -228,6 +237,36 @@ export class ObservationService {
|
|||||||
return this.deps.runner.killFor(target) > 0;
|
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<boolean> {
|
||||||
|
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
|
* Read one captured thumbnail from the in-process ring. Powers the plugin's
|
||||||
* own HTTP thumbnail route — frames are plugin-owned runtime data, never
|
* own HTTP thumbnail route — frames are plugin-owned runtime data, never
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export interface TrackedSession {
|
|||||||
export class SessionRegistry {
|
export class SessionRegistry {
|
||||||
private readonly sessions = new Map<string, TrackedSession>();
|
private readonly sessions = new Map<string, TrackedSession>();
|
||||||
private currentId: string | undefined;
|
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<string, ReadonlySet<string>>();
|
||||||
/**
|
/**
|
||||||
* Slots reserved by in-flight starts. reserveStart/completeStart/abandonStart
|
* Slots reserved by in-flight starts. reserveStart/completeStart/abandonStart
|
||||||
* run synchronously around the async spawn, so concurrent starts can never
|
* run synchronously around the async spawn, so concurrent starts can never
|
||||||
@@ -72,12 +79,37 @@ export class SessionRegistry {
|
|||||||
/** Forget a session; falls back to the most recent remaining one. */
|
/** Forget a session; falls back to the most recent remaining one. */
|
||||||
remove(sessionId: string): void {
|
remove(sessionId: string): void {
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
|
this.dshOwners.delete(sessionId);
|
||||||
if (this.currentId === sessionId) {
|
if (this.currentId === sessionId) {
|
||||||
const rest = [...this.sessions.values()];
|
const rest = [...this.sessions.values()];
|
||||||
this.currentId = rest.length > 0 ? rest[rest.length - 1].sessionId : undefined;
|
this.currentId = rest.length > 0 ? rest[rest.length - 1].sessionId : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record which DSH conversation(s) a freshly started bsk session belongs
|
||||||
|
* to (the starting agent's session plus its ancestors). No-op without ids
|
||||||
|
* — e.g. a start whose caller carried no agent identity.
|
||||||
|
*/
|
||||||
|
trackOwner(sessionId: string, dshSessionIds: readonly string[]): void {
|
||||||
|
if (dshSessionIds.length === 0 || !this.sessions.has(sessionId)) return;
|
||||||
|
this.dshOwners.set(sessionId, new Set(dshSessionIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bsk session ids owned by the given DSH conversation (or its descendants). */
|
||||||
|
ownedByDsh(dshSessionId: string): string[] {
|
||||||
|
const owned: string[] = [];
|
||||||
|
for (const [sessionId, owners] of this.dshOwners) {
|
||||||
|
if (owners.has(dshSessionId)) owned.push(sessionId);
|
||||||
|
}
|
||||||
|
return owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The DSH conversation ids owning one bsk session (empty when untracked). */
|
||||||
|
dshOwnersOf(sessionId: string): string[] {
|
||||||
|
return [...(this.dshOwners.get(sessionId) ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
/** Mark an owned session as most recently used (recency order refresh). */
|
/** Mark an owned session as most recently used (recency order refresh). */
|
||||||
private touch(sessionId: string): void {
|
private touch(sessionId: string): void {
|
||||||
const existing = this.sessions.get(sessionId);
|
const existing = this.sessions.get(sessionId);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type ToolResult,
|
type ToolResult,
|
||||||
type ToolRunContext,
|
type ToolRunContext,
|
||||||
} from "@deepseek-ai/dsh-tools";
|
} from "@deepseek-ai/dsh-tools";
|
||||||
|
import { ownerSessionIds } from "./archive-cleanup";
|
||||||
import { trySaveScreenshot } from "./image";
|
import { trySaveScreenshot } from "./image";
|
||||||
import { actionForLabel, type ObservationService } from "./observation";
|
import { actionForLabel, type ObservationService } from "./observation";
|
||||||
import type { KeyedExecutor } from "./queue";
|
import type { KeyedExecutor } from "./queue";
|
||||||
@@ -245,6 +246,9 @@ export function registerTools(deps: ToolDeps): () => void {
|
|||||||
browserInstanceId: reply.browser_instance_id,
|
browserInstanceId: reply.browser_instance_id,
|
||||||
startedAtMs: Date.now(),
|
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);
|
deps.observation.addSession(reply.session_id, args.url);
|
||||||
try {
|
try {
|
||||||
if (args.device !== undefined) {
|
if (args.device !== undefined) {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Archive-triggered cleanup: lineage resolution for browser-session
|
||||||
|
// ownership, the registry's owner index, and the domain/changed watcher
|
||||||
|
// that reaps a freshly archived conversation's bsk sessions.
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { armArchiveCleanup, ownerSessionIds } from "../src/archive-cleanup";
|
||||||
|
import type { ObservationService } from "../src/observation";
|
||||||
|
import { SessionRegistry } from "../src/sessions";
|
||||||
|
|
||||||
|
/** A ctx stub carrying a session store with the given lineage headers. */
|
||||||
|
function ctxWithSessions(headers: Record<string, { parentSession?: string }>) {
|
||||||
|
return {
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "sessions"
|
||||||
|
? {
|
||||||
|
get: (id: string) => (headers[id] === undefined ? undefined : { header: headers[id] }),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ownerSessionIds", () => {
|
||||||
|
it("walks the seed lineage to the root; empty without an agent identity", () => {
|
||||||
|
expect(ownerSessionIds(ctxWithSessions({}), undefined)).toEqual([]);
|
||||||
|
const ctx = ctxWithSessions({
|
||||||
|
child: { parentSession: "parent" },
|
||||||
|
parent: { parentSession: "root" },
|
||||||
|
root: {},
|
||||||
|
});
|
||||||
|
expect(ownerSessionIds(ctx, "child")).toEqual(["child", "parent", "root"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the walk at an unloaded ancestor", () => {
|
||||||
|
const ctx = ctxWithSessions({ child: { parentSession: "gone" } });
|
||||||
|
expect(ownerSessionIds(ctx, "child")).toEqual(["child", "gone"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never loops on a malformed parent cycle", () => {
|
||||||
|
const ctx = ctxWithSessions({
|
||||||
|
a: { parentSession: "b" },
|
||||||
|
b: { parentSession: "a" },
|
||||||
|
});
|
||||||
|
expect(ownerSessionIds(ctx, "a")).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SessionRegistry owner tracking", () => {
|
||||||
|
function start(registry: SessionRegistry, sessionId: string): void {
|
||||||
|
registry.reserveStart();
|
||||||
|
registry.completeStart({ sessionId, startedAtMs: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("indexes owners and forgets them on remove; ignores empty/unknown ownership", () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
start(registry, "bsk1");
|
||||||
|
start(registry, "bsk2");
|
||||||
|
registry.trackOwner("bsk1", ["conv-a", "root"]);
|
||||||
|
registry.trackOwner("bsk2", ["conv-b"]);
|
||||||
|
expect(registry.ownedByDsh("root")).toEqual(["bsk1"]);
|
||||||
|
expect(registry.ownedByDsh("conv-a")).toEqual(["bsk1"]);
|
||||||
|
expect(registry.ownedByDsh("conv-b")).toEqual(["bsk2"]);
|
||||||
|
expect(registry.ownedByDsh("nobody")).toEqual([]);
|
||||||
|
registry.remove("bsk1");
|
||||||
|
expect(registry.ownedByDsh("root")).toEqual([]);
|
||||||
|
// Empty owner lists and unknown session ids record nothing.
|
||||||
|
registry.trackOwner("bsk2", []);
|
||||||
|
registry.trackOwner("ghost", ["conv-c"]);
|
||||||
|
expect(registry.ownedByDsh("conv-c")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("armArchiveCleanup", () => {
|
||||||
|
function harness(opts: { archived?: string[] } = {}) {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
const observation = { stopSession: vi.fn(async () => true) };
|
||||||
|
const listeners = new Set<(change: unknown) => void>();
|
||||||
|
const ctx = {
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "workspaceRegistry" ? { archivedSessionIds: opts.archived ?? [] } : undefined,
|
||||||
|
on: (event: string, listener: (change: unknown) => void) => {
|
||||||
|
if (event === "domain/changed") listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const emit = (change: unknown) => {
|
||||||
|
for (const listener of [...listeners]) listener(change);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
observation: observation as unknown as ObservationService,
|
||||||
|
stopSession: observation.stopSession,
|
||||||
|
emit,
|
||||||
|
arm: () =>
|
||||||
|
armArchiveCleanup(ctx as never, registry, observation as unknown as ObservationService),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOwned(registry: SessionRegistry, bskId: string, owners: string[]): void {
|
||||||
|
registry.reserveStart();
|
||||||
|
registry.completeStart({ sessionId: bskId, startedAtMs: 1 });
|
||||||
|
registry.trackOwner(bskId, owners);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("stops every bsk session owned by a freshly archived conversation, until disarmed", () => {
|
||||||
|
const h = harness();
|
||||||
|
startOwned(h.registry, "bsk1", ["conv-a", "root"]);
|
||||||
|
startOwned(h.registry, "bsk2", ["conv-b"]);
|
||||||
|
const disarm = h.arm();
|
||||||
|
h.emit({
|
||||||
|
domain: "workspace",
|
||||||
|
table: "",
|
||||||
|
value: { archivedSessionIds: ["root"] },
|
||||||
|
});
|
||||||
|
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.stopSession).toHaveBeenCalledWith("bsk1");
|
||||||
|
// After the disposer runs the watcher is silent again.
|
||||||
|
disarm();
|
||||||
|
h.emit({
|
||||||
|
domain: "workspace",
|
||||||
|
table: "",
|
||||||
|
value: { archivedSessionIds: ["root", "conv-b"] },
|
||||||
|
});
|
||||||
|
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores pre-archived ids, foreign domains, and malformed frames", () => {
|
||||||
|
const h = harness({ archived: ["old-conv"] });
|
||||||
|
startOwned(h.registry, "bsk1", ["old-conv"]);
|
||||||
|
startOwned(h.registry, "bsk2", ["conv-a"]);
|
||||||
|
h.arm();
|
||||||
|
// Seeded from the registry: the pre-archived id must not retro-fire.
|
||||||
|
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["old-conv"] } });
|
||||||
|
h.emit({ domain: "settings", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||||
|
h.emit({ domain: "workspace", table: "rows", value: { archivedSessionIds: ["conv-a"] } });
|
||||||
|
h.emit({ domain: "workspace", table: "", value: {} });
|
||||||
|
expect(h.stopSession).not.toHaveBeenCalled();
|
||||||
|
// A genuinely new archive still fires.
|
||||||
|
h.emit({
|
||||||
|
domain: "workspace",
|
||||||
|
table: "",
|
||||||
|
value: { archivedSessionIds: ["old-conv", "conv-a"] },
|
||||||
|
});
|
||||||
|
expect(h.stopSession).toHaveBeenCalledWith("bsk2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a re-archived session as fresh again after unarchive", () => {
|
||||||
|
const h = harness();
|
||||||
|
startOwned(h.registry, "bsk1", ["conv-a"]);
|
||||||
|
h.arm();
|
||||||
|
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||||
|
expect(h.stopSession).toHaveBeenCalledTimes(1);
|
||||||
|
// Unarchive, then re-archive: the second archival cleans up again.
|
||||||
|
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: [] } });
|
||||||
|
h.emit({ domain: "workspace", table: "", value: { archivedSessionIds: ["conv-a"] } });
|
||||||
|
expect(h.stopSession).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// upgrade/fallback (mocked documentPictureInPicture). NOTE: use RTL's waitFor
|
// upgrade/fallback (mocked documentPictureInPicture). NOTE: use RTL's waitFor
|
||||||
// (act-flushing), not vi.waitFor, when asserting store-driven UI updates.
|
// (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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay";
|
import { applyResize, ObservationOverlay } from "../../src/client/ObservationOverlay";
|
||||||
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
||||||
@@ -157,6 +157,45 @@ describe("ObservationOverlay", () => {
|
|||||||
await screen.findByText(/s1 · clicking/);
|
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(<ObservationOverlay store={h.store} />);
|
||||||
|
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(<ObservationOverlay store={h.store} />);
|
||||||
|
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 () => {
|
it("resizes within min/max clamps", async () => {
|
||||||
const h = makeHarness([BUSY]);
|
const h = makeHarness([BUSY]);
|
||||||
render(<ObservationOverlay store={h.store} />);
|
render(<ObservationOverlay store={h.store} />);
|
||||||
@@ -222,6 +261,7 @@ describe("ObservationOverlay", () => {
|
|||||||
addEventListener: (name: string, fn: () => void) => {
|
addEventListener: (name: string, fn: () => void) => {
|
||||||
listeners.set(name, [...(listeners.get(name) ?? []), fn]);
|
listeners.set(name, [...(listeners.get(name) ?? []), fn]);
|
||||||
},
|
},
|
||||||
|
close: vi.fn(),
|
||||||
} as unknown as Window;
|
} as unknown as Window;
|
||||||
});
|
});
|
||||||
(window as unknown as Record<string, unknown>).documentPictureInPicture = { requestWindow };
|
(window as unknown as Record<string, unknown>).documentPictureInPicture = { requestWindow };
|
||||||
@@ -236,6 +276,28 @@ describe("ObservationOverlay", () => {
|
|||||||
await screen.findByTestId("obs-card");
|
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<string, unknown>).documentPictureInPicture = { requestWindow };
|
||||||
|
const { unmount } = render(<ObservationOverlay store={h.store} />);
|
||||||
|
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 () => {
|
it("renders the strip for two sessions and pins focus on click", async () => {
|
||||||
const older: SessionObservation = {
|
const older: SessionObservation = {
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// better-sidebar carrier: tab registration + auto-open, the sidebar-mode
|
||||||
|
// flag hiding the floating overlay, PiP pop-out from the tab, and the
|
||||||
|
// store's refcounted acquire/release across overlapping carriers.
|
||||||
|
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ObservationOverlay } from "../../src/client/ObservationOverlay";
|
||||||
|
import {
|
||||||
|
type BetterSidebarLike,
|
||||||
|
OBSERVATION_TAB_PATH,
|
||||||
|
OBSERVATION_TAB_TYPE,
|
||||||
|
ObservationSidebarTab,
|
||||||
|
observationTabOpen,
|
||||||
|
registerObservationSidebar,
|
||||||
|
type SidebarNodeLike,
|
||||||
|
type SidebarStateLike,
|
||||||
|
type TabDescriptorLike,
|
||||||
|
} from "../../src/client/observation-sidebar";
|
||||||
|
import { type EventSourceLike, ObservationClientStore } from "../../src/client/observation-store";
|
||||||
|
import { getSidebarMode, setSidebarMode } from "../../src/client/sidebar-mode";
|
||||||
|
import type { SessionObservation } from "../../src/observation";
|
||||||
|
|
||||||
|
// Owned by the sidebar mock's active conversation ("conv-1") so the scoped
|
||||||
|
// visibility logic lets it through; FOREIGN belongs to another conversation.
|
||||||
|
const BUSY: SessionObservation = {
|
||||||
|
sessionId: "s1",
|
||||||
|
action: "clicking",
|
||||||
|
since: Date.now() - 7000,
|
||||||
|
dshSessionIds: ["conv-1"],
|
||||||
|
};
|
||||||
|
const FOREIGN: SessionObservation = {
|
||||||
|
sessionId: "s9",
|
||||||
|
action: "clicking",
|
||||||
|
since: Date.now() - 5000,
|
||||||
|
dshSessionIds: ["other-conv"],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
store: ObservationClientStore;
|
||||||
|
es: () => EventSourceLike | undefined;
|
||||||
|
push: (sessions: SessionObservation[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHarness(initial: SessionObservation[]): Harness {
|
||||||
|
let es: EventSourceLike | undefined;
|
||||||
|
let current = initial;
|
||||||
|
const store = new ObservationClientStore({
|
||||||
|
fetchFn: async (url: string) => {
|
||||||
|
if (url === "/bsk-observation/state") {
|
||||||
|
return { ok: true, json: async () => ({ sessions: current }) };
|
||||||
|
}
|
||||||
|
return { ok: true, json: async () => ({ interrupted: true }) };
|
||||||
|
},
|
||||||
|
eventSourceFactory: () => {
|
||||||
|
es = { onmessage: null, close: vi.fn() };
|
||||||
|
return es;
|
||||||
|
},
|
||||||
|
loadImage: async (id: string) => `blob:${id}`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
es: () => es,
|
||||||
|
push: (sessions) => {
|
||||||
|
current = sessions;
|
||||||
|
for (const s of sessions) {
|
||||||
|
es?.onmessage?.({ data: JSON.stringify({ type: "upsert", session: s }) });
|
||||||
|
}
|
||||||
|
if (sessions.length === 0) es?.onmessage?.({ data: JSON.stringify({ type: "reset" }) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function leafWith(type: string, panelOpen = true): SidebarStateLike {
|
||||||
|
return {
|
||||||
|
panelOpen,
|
||||||
|
splits: {
|
||||||
|
kind: "split",
|
||||||
|
id: "root",
|
||||||
|
dir: "row",
|
||||||
|
sizes: [1],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
kind: "leaf",
|
||||||
|
id: "pane-1",
|
||||||
|
active: type,
|
||||||
|
tabs: [{ id: type, type, title: type }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bottomSplits: { kind: "leaf", id: "pane-2", active: null, tabs: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarMock {
|
||||||
|
service: BetterSidebarLike;
|
||||||
|
descriptor: () => TabDescriptorLike;
|
||||||
|
openTab: ReturnType<typeof vi.fn>;
|
||||||
|
disposeTab: ReturnType<typeof vi.fn>;
|
||||||
|
setState: (state: SidebarStateLike | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a tab into the first leaf of the main tree (mirrors a real open). */
|
||||||
|
function withTabOpened(state: SidebarStateLike, type: string): SidebarStateLike {
|
||||||
|
let done = false;
|
||||||
|
const walk = (node: SidebarNodeLike): SidebarNodeLike => {
|
||||||
|
if (done) return node;
|
||||||
|
if (node.kind === "leaf") {
|
||||||
|
done = true;
|
||||||
|
return { ...node, active: type, tabs: [...node.tabs, { id: type, type, title: type }] };
|
||||||
|
}
|
||||||
|
return { ...node, children: node.children.map(walk) };
|
||||||
|
};
|
||||||
|
return { ...state, splits: walk(state.splits) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSidebar(
|
||||||
|
initialState: SidebarStateLike | undefined = leafWith("explorer"),
|
||||||
|
): SidebarMock {
|
||||||
|
let descriptor: TabDescriptorLike | undefined;
|
||||||
|
let state: SidebarStateLike | undefined = initialState;
|
||||||
|
const openTab = vi.fn((seed: { type: string }) => {
|
||||||
|
// Mirror the real feedback loop: a created tab lands in the state, so
|
||||||
|
// later evaluations see it open (single-instance dedupe re-focuses).
|
||||||
|
if (state !== undefined && !observationTabOpen(state)) {
|
||||||
|
state = withTabOpened(state, seed.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const disposeTab = vi.fn();
|
||||||
|
const service: BetterSidebarLike = {
|
||||||
|
registerTab: (next) => {
|
||||||
|
descriptor = next;
|
||||||
|
return disposeTab;
|
||||||
|
},
|
||||||
|
openTab,
|
||||||
|
getSnapshot: () => ({ sessionId: "conv-1", state }),
|
||||||
|
isTabEnabled: () => true,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
descriptor: () => {
|
||||||
|
if (descriptor === undefined) throw new Error("no tab registered");
|
||||||
|
return descriptor;
|
||||||
|
},
|
||||||
|
openTab,
|
||||||
|
disposeTab,
|
||||||
|
setState: (next) => {
|
||||||
|
state = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
setSidebarMode(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registerObservationSidebar", () => {
|
||||||
|
it("registers a single-instance tab and holds the feed for its lifetime", () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
expect(getSidebarMode()).toBe(false);
|
||||||
|
const dispose = registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
expect(getSidebarMode()).toBe(true);
|
||||||
|
// The fiber holds the observation feed itself, so the auto-open watcher
|
||||||
|
// sees new sessions even while the tab is closed.
|
||||||
|
expect(h.es()).toBeDefined();
|
||||||
|
const descriptor = sidebar.descriptor();
|
||||||
|
expect(descriptor.id).toBe(OBSERVATION_TAB_TYPE);
|
||||||
|
expect(descriptor.single).toBe(true);
|
||||||
|
expect(descriptor.title).toBe("Browser Skill");
|
||||||
|
dispose();
|
||||||
|
expect(getSidebarMode()).toBe(false);
|
||||||
|
expect(sidebar.disposeTab).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.es()?.close).toHaveBeenCalled?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the tab right away when a session is already live at activation", async () => {
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
// The initial state fetch lands asynchronously; the 0→N watcher then fires.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sidebar.openTab).toHaveBeenCalledWith({
|
||||||
|
type: OBSERVATION_TAB_TYPE,
|
||||||
|
path: OBSERVATION_TAB_PATH,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-opens on the first session but never re-focuses an open tab", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||||
|
// First session arrives: the tab lands in the sidebar.
|
||||||
|
h.push([BUSY]);
|
||||||
|
await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1));
|
||||||
|
// The sidebar now reports the tab open (persisted/restored): further
|
||||||
|
// session arrivals must not steal focus from whatever the user reads.
|
||||||
|
sidebar.setState(leafWith(OBSERVATION_TAB_TYPE));
|
||||||
|
h.push([]);
|
||||||
|
h.push([{ ...BUSY, sessionId: "s2" }]);
|
||||||
|
h.push([]);
|
||||||
|
h.push([{ ...BUSY, sessionId: "s3" }]);
|
||||||
|
await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1));
|
||||||
|
expect(sidebar.openTab).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open while the sidebar has no active session state", () => {
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
const sidebar = makeSidebar(undefined);
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nudges an existing tab back into sight when the panel is collapsed", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
// Tab already open (persisted), but the panel is collapsed: a new
|
||||||
|
// session should surface the tracking view again (focus + expand via a
|
||||||
|
// content open), like the floating card reappearing.
|
||||||
|
const sidebar = makeSidebar(leafWith(OBSERVATION_TAB_TYPE, false));
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
h.push([BUSY]);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(sidebar.openTab).toHaveBeenCalledWith({
|
||||||
|
type: OBSERVATION_TAB_TYPE,
|
||||||
|
path: OBSERVATION_TAB_PATH,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the visible session count as the tab badge", () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
const badge = sidebar.descriptor().badge;
|
||||||
|
const scope = { sessionId: "conv-1" };
|
||||||
|
expect(badge?.(undefined, scope, undefined)).toBeNull();
|
||||||
|
h.store.acquire();
|
||||||
|
h.push([BUSY, FOREIGN]);
|
||||||
|
expect(badge?.(undefined, scope, undefined)).toBe(1);
|
||||||
|
expect(badge?.(undefined, { sessionId: "other-conv" }, undefined)).toBe(1);
|
||||||
|
expect(badge?.(undefined, { sessionId: "nobody" }, undefined)).toBeNull();
|
||||||
|
h.store.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-open for sessions owned by other conversations", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const sidebar = makeSidebar();
|
||||||
|
registerObservationSidebar(sidebar.service, h.store);
|
||||||
|
h.push([FOREIGN]);
|
||||||
|
await waitFor(() => expect(h.store.getSnapshot().sessions.length).toBe(1));
|
||||||
|
expect(sidebar.openTab).not.toHaveBeenCalled();
|
||||||
|
// A session visible to the active conversation does open the tab.
|
||||||
|
h.push([BUSY]);
|
||||||
|
await waitFor(() => expect(sidebar.openTab).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("observationTabOpen", () => {
|
||||||
|
it("finds the tab in either workbench tree", () => {
|
||||||
|
expect(observationTabOpen(undefined)).toBe(false);
|
||||||
|
expect(observationTabOpen(leafWith("explorer"))).toBe(false);
|
||||||
|
expect(observationTabOpen(leafWith(OBSERVATION_TAB_TYPE))).toBe(true);
|
||||||
|
const inBottom: SidebarStateLike = {
|
||||||
|
splits: { kind: "leaf", id: "p1", active: null, tabs: [] },
|
||||||
|
bottomSplits: {
|
||||||
|
kind: "leaf",
|
||||||
|
id: "p2",
|
||||||
|
active: OBSERVATION_TAB_TYPE,
|
||||||
|
tabs: [{ id: OBSERVATION_TAB_TYPE, type: OBSERVATION_TAB_TYPE, title: "Browser" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(observationTabOpen(inBottom)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ObservationSidebarTab", () => {
|
||||||
|
it("renders the tracking view without card chrome (no collapse, no drag header)", async () => {
|
||||||
|
const h = makeHarness([BUSY]);
|
||||||
|
render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||||
|
await screen.findByText(/s1 · clicking/);
|
||||||
|
expect(screen.queryByRole("button", { name: "Collapse" })).toBeNull();
|
||||||
|
expect(screen.getByTestId("obs-header").dataset.draggable).toBeUndefined();
|
||||||
|
// No resize handles — the sidebar owns the geometry.
|
||||||
|
expect(screen.queryByTestId("obs-resize-se")).toBeNull();
|
||||||
|
// Session controls ride along: interrupt + stop-with-confirm.
|
||||||
|
expect(screen.getByRole("button", { name: "Stop session s1" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes the view to the sidebar's conversation", async () => {
|
||||||
|
const h = makeHarness([BUSY, FOREIGN]);
|
||||||
|
render(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||||
|
// 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(<ObservationSidebarTab store={h.store} scopeId="other-conv" />);
|
||||||
|
await screen.findByText(/s9 · clicking/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("carrier switching", () => {
|
||||||
|
it("hides the floating overlay while sidebar mode is active", async () => {
|
||||||
|
const h = makeHarness([]);
|
||||||
|
const { container } = render(<ObservationOverlay store={h.store} />);
|
||||||
|
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(<ObservationOverlay store={h.store} />);
|
||||||
|
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(<ObservationSidebarTab store={h.store} scopeId="conv-1" />);
|
||||||
|
overlay.unmount();
|
||||||
|
h.push([BUSY]);
|
||||||
|
await screen.findByText(/s1 · clicking/);
|
||||||
|
expect(h.es()?.close ?? vi.fn()).not.toHaveBeenCalled();
|
||||||
|
tab.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -137,6 +137,9 @@ describe("client plugin registration", () => {
|
|||||||
const sessions = { binding: () => undefined };
|
const sessions = { binding: () => undefined };
|
||||||
const ctx = {
|
const ctx = {
|
||||||
get: (key: string) => (key === "sessions" ? sessions : undefined),
|
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: {
|
slots: {
|
||||||
inject: (_name: string, fn: () => unknown) => fn(),
|
inject: (_name: string, fn: () => unknown) => fn(),
|
||||||
register: (slot: { name: string; key?: string; id?: string }, view: unknown) => {
|
register: (slot: { name: string; key?: string; id?: string }, view: unknown) => {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ function fakeRunner(
|
|||||||
opts: {
|
opts: {
|
||||||
screenshotFails?: boolean;
|
screenshotFails?: boolean;
|
||||||
screenshotNotFound?: boolean;
|
screenshotNotFound?: boolean;
|
||||||
|
stopNotFound?: boolean;
|
||||||
|
stopFails?: boolean;
|
||||||
killCount?: number;
|
killCount?: number;
|
||||||
screenshotBytes?: () => Uint8Array;
|
screenshotBytes?: () => Uint8Array;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -72,6 +74,21 @@ function fakeRunner(
|
|||||||
killed,
|
killed,
|
||||||
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
|
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
|
||||||
calls.push({ args, options });
|
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 (args[0] === "screenshot") {
|
||||||
if (opts.screenshotNotFound) {
|
if (opts.screenshotNotFound) {
|
||||||
return {
|
return {
|
||||||
@@ -180,6 +197,23 @@ describe("state machine", () => {
|
|||||||
expect(events[events.length - 1].type).toBe("reset");
|
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)", () => {
|
it("ignores instrumentation for unknown sessions (owned-only by construction)", () => {
|
||||||
const { service } = setup({});
|
const { service } = setup({});
|
||||||
service.beginAction("foreign", "clicking");
|
service.beginAction("foreign", "clicking");
|
||||||
@@ -307,6 +341,57 @@ describe("interrupt routing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("stopSession", () => {
|
||||||
|
it("stops an owned session: kills in-flight tools, runs session stop, removes the entry", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const runner = fakeRunner();
|
||||||
|
const { service, events } = setup({ registry, runner });
|
||||||
|
service.addSession("s1");
|
||||||
|
expect(registry.isOwned("s1")).toBe(true);
|
||||||
|
|
||||||
|
await expect(service.stopSession("s1")).resolves.toBe(true);
|
||||||
|
expect(runner.killed).toEqual(["s1"]);
|
||||||
|
const stop = runner.calls.find((c) => c.args[0] === "session" && c.args[1] === "stop");
|
||||||
|
expect(stop?.args).toEqual(["session", "stop", "s1"]);
|
||||||
|
expect(registry.isOwned("s1")).toBe(false);
|
||||||
|
expect(service.getState()).toEqual([]);
|
||||||
|
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses foreign sessions without touching the runner", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const runner = fakeRunner();
|
||||||
|
const { service } = setup({ registry, runner });
|
||||||
|
await expect(service.stopSession("foreign")).resolves.toBe(false);
|
||||||
|
expect(runner.killed).toEqual([]);
|
||||||
|
expect(runner.calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops idempotently when the daemon already forgot the session (dead entries)", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const runner = fakeRunner({ stopNotFound: true });
|
||||||
|
const { service, events } = setup({ registry, runner });
|
||||||
|
service.addSession("s1");
|
||||||
|
await expect(service.stopSession("s1")).resolves.toBe(true);
|
||||||
|
expect(registry.isOwned("s1")).toBe(false);
|
||||||
|
expect(events[events.length - 1]).toMatchObject({ type: "remove" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and keeps the entry when the stop itself fails", async () => {
|
||||||
|
const registry = new SessionRegistry(5);
|
||||||
|
own(registry, "s1");
|
||||||
|
const runner = fakeRunner({ stopFails: true });
|
||||||
|
const { service } = setup({ registry, runner });
|
||||||
|
service.addSession("s1");
|
||||||
|
await expect(service.stopSession("s1")).resolves.toBe(false);
|
||||||
|
expect(registry.isOwned("s1")).toBe(true);
|
||||||
|
expect(service.getState().map((s) => s.sessionId)).toEqual(["s1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HTTP/SSE interface", () => {
|
describe("HTTP/SSE interface", () => {
|
||||||
interface RecordedRoute {
|
interface RecordedRoute {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -405,11 +490,45 @@ describe("HTTP/SSE interface", () => {
|
|||||||
await interruptRoute?.handler(postReq, interruptRes.res as never);
|
await interruptRoute?.handler(postReq, interruptRes.res as never);
|
||||||
expect(JSON.parse(interruptRes.res.body())).toEqual({ interrupted: true });
|
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();
|
dispose();
|
||||||
expect(routes.size).toBe(0);
|
expect(routes.size).toBe(0);
|
||||||
service.dispose();
|
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 () => {
|
it("fences off non-loopback, cross-site, and simple-request traffic", async () => {
|
||||||
const registry = new SessionRegistry(5);
|
const registry = new SessionRegistry(5);
|
||||||
own(registry, "s1");
|
own(registry, "s1");
|
||||||
|
|||||||
Reference in New Issue
Block a user