feat(dsh-plugin-browserskill): multi-session strip, focus pin, and edge states (Phase 1-C)

- Strip (meeting-style multi-session layout): one item per session
  (mini frame + id + status dot), horizontal row with scroll overflow;
  hover reveals a per-item interrupt button that acts without
  refocusing; click pins the focus view (pin badge, click again to
  release); auto-follow picks the most recently active session but
  never steals focus for errored or dead sessions (red edge / greyed
  item instead).
- Edge states: the host flips an availability flag after repeated
  global capture failures (client shows 'browser unavailable', keeps
  the last frame, greys interrupt, and recovers on the next success);
  a session_not_found envelope marks the session dead (no more frame
  requests, grey strip item, removable as usual); lastError now clears
  when the next action starts or succeeds.
- State/SSE wire gains { available } and an 'availability' event;
  SessionObservation gains dead?.

Tests (+8, 97 total): host availability flip, dead marking +
instrumentation drop + clean removal, lastError lifecycle; client
strip render/pin/unpin/auto-follow, error no-steal, unavailable strip,
dead grey, strip-item interrupt without refocus.
This commit is contained in:
BB-fat
2026-08-14 05:58:53 +00:00
parent 24faa10659
commit ce89e95211
7 changed files with 489 additions and 39 deletions
@@ -256,3 +256,101 @@
animation: none;
}
}
.strip {
display: flex;
gap: 6px;
padding: 4px 8px;
overflow-x: auto;
border-top: 1px solid var(--dsw-alias-border-secondary);
}
.strip-item {
position: relative;
flex: none;
border-radius: 6px;
border: 1px solid var(--dsw-alias-border-secondary);
}
.strip-item[data-focused] {
border-color: var(--dsw-alias-border-focus);
}
.strip-item[data-state="error"] {
border-color: var(--dsw-alias-label-error);
}
.strip-item[data-state="dead"] {
opacity: 0.45;
}
.strip-main {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
border: none;
background: none;
font: inherit;
font-size: 11px;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.strip-thumb {
width: 28px;
height: 20px;
overflow: hidden;
border-radius: 3px;
background: var(--dsw-alias-bg-base);
display: inline-flex;
}
.strip-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.strip-thumb-empty {
width: 100%;
height: 100%;
}
.strip-id {
max-width: 64px;
overflow: hidden;
text-overflow: ellipsis;
}
.pin-badge {
font-size: 9px;
line-height: 1;
}
.strip-interrupt {
position: absolute;
top: -7px;
right: -7px;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--dsw-alias-label-error);
background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base));
color: var(--dsw-alias-label-error);
font-size: 8px;
line-height: 1;
padding: 0;
cursor: pointer;
}
.strip-interrupt:disabled {
opacity: 0.4;
cursor: default;
}
.strip-main:focus-visible,
.strip-interrupt:focus-visible {
outline: 2px solid var(--dsw-alias-border-focus);
outline-offset: 1px;
}
@@ -1,8 +1,9 @@
// Observation overlay: the default in-app carrier for live session watching.
// Expanded = draggable/resizable floating card (bottom-right); collapsed = a
// status capsule; "pop out" upgrades the same content to a native Document
// PiP window (user gesture required by the browser). Pure client state —
// position/size/collapse live for the page lifetime only.
// PiP window (user gesture required by the browser). Multi-session renders a
// meeting-style strip under the focus view. Pure client state — position,
// size, collapse, and the pin live for the page lifetime only.
import {
type PointerEvent as ReactPointerEvent,
@@ -64,10 +65,16 @@ function formatElapsed(sinceMs: number, nowMs: number): string {
return `${mm}:${ss}`;
}
/** Focus = most recently active session (action or latest state change). */
/**
* 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;
return sessions.reduce((a, b) => (a.since >= b.since ? a : b));
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" {
@@ -75,17 +82,98 @@ export function statusOf(obs: SessionObservation): "active" | "idle" | "error" {
return obs.action === "idle" ? "idle" : "active";
}
/** One strip item: mini frame, id, status dot, hover interrupt, pin toggle. */
function StripItem(props: {
store: ObservationClientStore;
obs: SessionObservation;
pinned: boolean;
focused: boolean;
onTogglePin: (sessionId: string) => void;
}) {
const { store, obs, pinned, focused, onTogglePin } = props;
const [hover, setHover] = useState(false);
const thumbId = obs.thumbnailAttachmentId;
useEffect(() => {
store.ensureThumbnail(thumbId);
}, [store, thumbId]);
const thumb = thumbId !== undefined ? store.getSnapshot().thumbnails[thumbId] : undefined;
const state = obs.dead === true ? "dead" : statusOf(obs);
return (
<div
className={css["strip-item"]}
data-state={state}
data-focused={focused || undefined}
data-pinned={pinned || undefined}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
>
<button
type="button"
className={css["strip-main"]}
aria-label={`${pinned ? "Unpin" : "Pin"} session ${obs.sessionId}`}
aria-pressed={pinned}
onClick={() => onTogglePin(obs.sessionId)}
>
<span className={css["strip-thumb"]}>
{thumb?.status === "ready" && thumb.url !== undefined ? (
<img src={thumb.url} alt="" />
) : (
<span className={css["strip-thumb-empty"]} />
)}
</span>
<span className={css["strip-id"]}>{obs.sessionId}</span>
<span className={css.dot} data-state={state === "dead" ? "idle" : state} aria-hidden />
{pinned ? (
<span className={css["pin-badge"]} aria-label="pinned">
📌
</span>
) : null}
</button>
{hover && !pinned && obs.dead !== true ? (
<button
type="button"
className={css["strip-interrupt"]}
aria-label={`Interrupt session ${obs.sessionId}`}
disabled={obs.action === "idle"}
onClick={(event) => {
event.stopPropagation();
void store.interrupt(obs.sessionId);
}}
>
■
</button>
) : null}
</div>
);
}
/** The floating card / PiP shared content. */
function OverlayBody(props: {
store: ObservationClientStore;
focus: SessionObservation | undefined;
sessions: readonly SessionObservation[];
available: boolean;
pinnedId: string | null;
onTogglePin: (sessionId: string) => void;
now: number;
onPopOut?: (() => void) | undefined;
onCollapse?: (() => void) | undefined;
inPip: boolean;
onHeaderPointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void;
}) {
const { store, focus, now, onPopOut, onCollapse, inPip, onHeaderPointerDown } = props;
const {
store,
focus,
sessions,
available,
pinnedId,
onTogglePin,
now,
onPopOut,
onCollapse,
inPip,
onHeaderPointerDown,
} = props;
const [interrupting, setInterrupting] = useState(false);
const [hintOpen, setHintOpen] = useState(false);
const [hintSeen, setHintSeen] = useState(false);
@@ -96,7 +184,12 @@ function OverlayBody(props: {
}, [store, thumbId]);
const thumb = thumbId !== undefined ? store.getSnapshot().thumbnails[thumbId] : undefined;
const canInterrupt = !interrupting && focus !== undefined && focus.action !== "idle";
const canInterrupt =
available &&
!interrupting &&
focus !== undefined &&
focus.action !== "idle" &&
focus.dead !== true;
const onInterrupt = (): void => {
if (!canInterrupt || focus === undefined) return;
setHintSeen(true);
@@ -105,10 +198,12 @@ function OverlayBody(props: {
void store.interrupt(focus.sessionId).finally(() => setInterrupting(false));
};
const actionText =
focus === undefined ? "no session" : focus.action === "idle" ? "idle" : focus.action;
const elapsed = focus !== undefined ? formatElapsed(focus.since, now) : "00:00";
const state = focus !== undefined ? statusOf(focus) : "idle";
const statusText = !available
? "browser unavailable"
: focus === undefined
? "no session"
: `${focus.sessionId} · ${focus.action === "idle" ? "idle" : focus.action} · ${formatElapsed(focus.since, now)}`;
const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle";
return (
<div className={css.body} data-state={state} data-in-pip={inPip || undefined}>
@@ -119,11 +214,7 @@ function OverlayBody(props: {
role="presentation"
>
<span className={css.dot} data-state={state} aria-hidden />
<span className={css["status-text"]}>
{focus === undefined
? "browser sessions"
: `${focus.sessionId} · ${actionText} · ${elapsed}`}
</span>
<span className={css["status-text"]}>{statusText}</span>
{onCollapse !== undefined ? (
<button
type="button"
@@ -145,7 +236,11 @@ function OverlayBody(props: {
/>
) : (
<div className={css.placeholder}>
{thumb?.status === "error" ? "frame unavailable" : "waiting for page"}
{!available
? "last frame kept"
: thumb?.status === "error"
? "frame unavailable"
: "waiting for page"}
</div>
)}
{thumb?.status === "error" ? (
@@ -154,6 +249,20 @@ function OverlayBody(props: {
</span>
) : null}
</div>
{sessions.length >= 2 ? (
<div className={css.strip} data-testid="obs-strip" role="list">
{sessions.map((obs) => (
<StripItem
key={obs.sessionId}
store={store}
obs={obs}
pinned={pinnedId === obs.sessionId}
focused={focus?.sessionId === obs.sessionId}
onTogglePin={onTogglePin}
/>
))}
</div>
) : null}
<div className={css.actions}>
<span className={css["interrupt-wrap"]}>
<button
@@ -209,6 +318,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
const [pos, setPos] = useState<Point | null>(null);
const [size, setSize] = useState<Size>(DEFAULT_SIZE);
const [pipWindow, setPipWindow] = useState<Window | null>(null);
const [pinnedId, setPinnedId] = useState<string | null>(null);
const dragRef = useRef<{
kind: "move" | "resize";
startX: number;
@@ -217,7 +327,6 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
} | null>(null);
// Elapsed-time ticker: 1s while anything is active.
const focus = focusOf(snapshot.sessions);
const anyActive = snapshot.sessions.some((s) => s.action !== "idle");
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
@@ -226,6 +335,14 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
return () => clearInterval(timer);
}, [anyActive]);
// Focus: the pinned session wins while it still exists; otherwise auto-follow.
const pinned =
pinnedId !== null ? snapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined;
const focus = pinned ?? focusOf(snapshot.sessions);
const onTogglePin = useCallback((sessionId: string) => {
setPinnedId((current) => (current === sessionId ? null : sessionId));
}, []);
const viewport = (): Size => ({ w: window.innerWidth, h: window.innerHeight });
const onPointerMove = useCallback((event: PointerEvent) => {
@@ -284,6 +401,10 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
<OverlayBody
store={store}
focus={focus}
sessions={snapshot.sessions}
available={snapshot.available}
pinnedId={pinned !== undefined ? pinnedId : null}
onTogglePin={onTogglePin}
now={now}
inPip={pipWindow !== null}
onPopOut={pipWindow === null && pipApi() !== undefined ? () => void popOut() : undefined}
@@ -34,6 +34,8 @@ export interface OverlaySnapshot {
readonly sessions: readonly SessionObservation[];
readonly connected: boolean;
readonly thumbnails: Readonly<Record<string, ThumbnailState>>;
/** False when the host reports the browser/daemon as unreachable. */
readonly available: boolean;
}
const STATE_URL = "/bsk-observation/state";
@@ -45,7 +47,13 @@ export class ObservationClientStore {
private thumbs = new Map<string, ThumbnailState>();
private listeners = new Set<() => void>();
private events: EventSourceLike | undefined;
private snapshot: OverlaySnapshot = { sessions: [], connected: false, thumbnails: {} };
private snapshot: OverlaySnapshot = {
sessions: [],
connected: false,
thumbnails: {},
available: true,
};
private available = true;
private started = false;
constructor(private readonly deps: ObservationClientDeps) {}
@@ -62,6 +70,7 @@ export class ObservationClientStore {
sessions: [...this.sessions.values()],
connected: this.events !== undefined,
thumbnails: Object.fromEntries(this.thumbs),
available: this.available,
};
for (const listener of [...this.listeners]) listener();
}
@@ -74,8 +83,12 @@ export class ObservationClientStore {
.fetchFn(STATE_URL)
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as { sessions?: SessionObservation[] };
const body = (await res.json()) as {
sessions?: SessionObservation[];
available?: boolean;
};
this.sessions = new Map((body.sessions ?? []).map((s) => [s.sessionId, s]));
if (typeof body.available === "boolean") this.available = body.available;
this.publish();
})
.catch(() => {});
@@ -114,6 +127,8 @@ export class ObservationClientStore {
this.sessions.delete(event.session.sessionId);
} else if (event.type === "upsert" && event.session !== undefined) {
this.sessions.set(event.session.sessionId, event.session);
} else if (event.type === "availability") {
this.available = event.available;
}
this.publish();
}
@@ -56,7 +56,10 @@ export function registerObservationRoutes(
sendJson(res, 405, { error: "method not allowed" });
return;
}
sendJson(res, 200, { sessions: observation.getState() });
sendJson(res, 200, {
sessions: observation.getState(),
available: observation.isAvailable(),
});
},
}),
webServer.register({
@@ -31,13 +31,17 @@ export interface SessionObservation {
thumbnailAttachmentId?: string;
/** Summary of the most recent failed action (drives the red status dot). */
lastError?: string;
/**
* The daemon reports this session as gone (e.g. daemon restart): the strip
* greys it out until it is stopped/removed. No more frames are requested.
*/
dead?: boolean;
}
/** Incremental event carried to subscribers (SSE on the wire). */
export interface ObservationEvent {
type: "upsert" | "remove" | "reset";
session?: SessionObservation;
}
export type ObservationEvent =
| { type: "upsert" | "remove" | "reset"; session?: SessionObservation }
| { type: "availability"; available: boolean };
export interface ObservationOptions {
enabled: boolean;
@@ -84,6 +88,9 @@ export class ObservationService {
private readonly captureInFlight = new Set<string>();
private readonly captureFailures = new Map<string, number>();
private readonly lastActivity = new Map<string, number>();
/** Consecutive capture failures across ALL sessions (daemon-level signal). */
private globalFailures = 0;
private available = true;
private disposed = false;
constructor(
@@ -105,6 +112,17 @@ export class ObservationService {
return [...this.observations.values()].map((entry) => ({ ...entry }));
}
/** Whether the browser side looks reachable (drives the "browser unavailable" state). */
isAvailable(): boolean {
return this.available;
}
private setAvailable(available: boolean): void {
if (this.available === available) return;
this.available = available;
this.emit({ type: "availability", available });
}
/** Subscribe to incremental changes; returns an unsubscribe function. */
subscribe(listener: (event: ObservationEvent) => void): () => void {
this.listeners.add(listener);
@@ -156,10 +174,13 @@ export class ObservationService {
beginAction(sessionId: string, action: string): void {
if (!this.deps.options.enabled || this.disposed) return;
const entry = this.observations.get(sessionId);
if (entry === undefined) return;
if (entry === undefined || entry.dead === true) return;
const now = this.scheduler.now();
this.lastActivity.set(sessionId, now);
this.put({ ...entry, action, since: now });
// A fresh action clears the previous error marker (red dot means "last action failed").
const next: SessionObservation = { ...entry, action, since: now };
delete next.lastError;
this.put(next);
}
/**
@@ -169,15 +190,13 @@ export class ObservationService {
endAction(sessionId: string, error?: string): void {
if (!this.deps.options.enabled || this.disposed) return;
const entry = this.observations.get(sessionId);
if (entry === undefined) return;
if (entry === undefined || entry.dead === true) return;
const now = this.scheduler.now();
this.lastActivity.set(sessionId, now);
this.put({
...entry,
action: "idle",
since: now,
...(error !== undefined ? { lastError: error } : {}),
});
const next: SessionObservation = { ...entry, action: "idle", since: now };
if (error !== undefined) next.lastError = error;
else delete next.lastError;
this.put(next);
this.scheduleCapture(sessionId, 0);
}
@@ -230,7 +249,8 @@ export class ObservationService {
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
private scheduleCapture(sessionId: string, delayMs?: number): void {
if (!this.deps.options.enabled || this.disposed) return;
if (!this.observations.has(sessionId)) return;
const entry = this.observations.get(sessionId);
if (entry === undefined || entry.dead === true) return;
this.cancelCapture(sessionId);
const now = this.scheduler.now();
const lastSeen = this.lastActivity.get(sessionId) ?? 0;
@@ -255,7 +275,9 @@ export class ObservationService {
* Failures keep the previous frame and back off silently.
*/
private async capture(sessionId: string): Promise<void> {
if (this.disposed || !this.observations.has(sessionId)) return;
if (this.disposed) return;
const current = this.observations.get(sessionId);
if (current === undefined || current.dead === true) return;
if (this.captureInFlight.has(sessionId)) return;
const attachments = this.deps.ctx.get("attachments") as AttachmentLike | undefined;
if (attachments === undefined) {
@@ -269,7 +291,19 @@ export class ObservationService {
["screenshot", "--session", sessionId, "--out", outPath],
{ timeoutMs: 15_000, tag: `observation:${sessionId}` },
);
if (result.code !== 0) throw new Error(`screenshot exited ${result.code}`);
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") {
this.markDead(sessionId);
return;
}
throw new Error(`screenshot exited ${result.code}`);
}
const reply = JSON.parse(result.stdout) as { path?: string };
const data = await readFile(reply.path ?? outPath);
const ref = await attachments.saveImage({
@@ -280,15 +314,26 @@ export class ObservationService {
const entry = this.observations.get(sessionId);
if (entry === undefined) return;
this.captureFailures.delete(sessionId);
this.globalFailures = 0;
this.setAvailable(true);
this.put({ ...entry, thumbnailAttachmentId: String(ref.attachmentId) });
} catch {
// Silent by design: keep the previous frame, count toward backoff.
this.captureFailures.set(sessionId, (this.captureFailures.get(sessionId) ?? 0) + 1);
this.globalFailures += 1;
if (this.globalFailures >= FAILURE_BACKOFF_THRESHOLD) this.setAvailable(false);
} finally {
this.captureInFlight.delete(sessionId);
if (!this.disposed && this.observations.has(sessionId)) this.scheduleCapture(sessionId);
}
}
/** Mark a session dead: grey it out, stop asking for frames, keep the entry for the strip. */
private markDead(sessionId: string): void {
const entry = this.observations.get(sessionId);
if (entry === undefined || entry.dead === true) return;
this.cancelCapture(sessionId);
this.put({ ...entry, dead: true, action: "idle" });
}
}
/** Map a bsk command label onto its observation action verb. */
@@ -16,6 +16,7 @@ interface Harness {
store: ObservationClientStore;
es: () => EventSourceLike;
push: (sessions: SessionObservation[]) => void;
emitRaw: (event: unknown) => void;
fetches: { url: string; init?: { body?: string } }[];
loadImage: ReturnType<typeof vi.fn>;
}
@@ -52,6 +53,9 @@ function makeHarness(initial: SessionObservation[]): Harness {
}
if (sessions.length === 0) es?.onmessage?.({ data: JSON.stringify({ type: "reset" }) });
},
emitRaw: (event) => {
es?.onmessage?.({ data: JSON.stringify(event) });
},
fetches,
loadImage,
};
@@ -183,4 +187,106 @@ describe("ObservationOverlay", () => {
for (const fn of listeners.get("pagehide") ?? []) fn();
await screen.findByTestId("obs-card");
});
it("renders the strip for two sessions and pins focus on click", async () => {
const older: SessionObservation = {
sessionId: "s1",
action: "idle",
since: Date.now() - 60000,
};
const newer: SessionObservation = { sessionId: "s2", action: "clicking", since: Date.now() };
const h = makeHarness([older, newer]);
render(<ObservationOverlay store={h.store} />);
// Auto-follows the most recently active session.
await screen.findByText(/s2 · clicking/);
const strip = screen.getByTestId("obs-strip");
expect(strip.textContent).toContain("s1");
expect(strip.textContent).toContain("s2");
// Pin s1: focus moves and stays even when s2 gets newer activity.
fireEvent.click(screen.getByRole("button", { name: "Pin session s1" }));
await screen.findByText(/s1 · idle/);
h.push([{ ...newer, since: Date.now() + 5000, action: "filling" }]);
await waitFor(() => expect(screen.queryByText(/s2 · filling/)).toBeNull());
expect(screen.getByText(/s1 · idle/)).toBeTruthy();
// Unpin: auto-follow resumes.
fireEvent.click(screen.getByRole("button", { name: "Unpin session s1" }));
await screen.findByText(/s2 · filling/);
});
it("flags an errored strip item without stealing focus", async () => {
const s1: SessionObservation = { sessionId: "s1", action: "idle", since: Date.now() - 30000 };
const s2: SessionObservation = { sessionId: "s2", action: "idle", since: Date.now() - 10000 };
const h = makeHarness([s1, s2]);
render(<ObservationOverlay store={h.store} />);
await screen.findByText(/s2 · idle/);
// s2's latest action failed: red edge on its strip item, focus stays on s2 already…
// now push an even-more-recent ERROR for s1 — focus must NOT jump to it.
h.push([
{ ...s2, since: Date.now() - 5000 },
{ ...s1, since: Date.now(), lastError: "click failed" },
]);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Pin session s1" }).closest("[data-state]"),
).toHaveProperty("dataset", expect.objectContaining({ state: "error" })),
);
expect(screen.getByText(/s2 · idle/)).toBeTruthy();
});
it("shows browser-unavailable with a kept frame and a greyed interrupt", async () => {
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]);
render(<ObservationOverlay store={h.store} />);
await waitFor(() => expect(h.loadImage).toHaveBeenCalledWith("att-1"));
await screen.findByRole("img", { name: "session s1 view" });
h.emitRaw({ type: "availability", available: false });
await screen.findByText("browser unavailable");
// Last frame stays on stage; interrupt is greyed.
expect(screen.getByRole("img", { name: "session s1 view" })).toBeTruthy();
expect(screen.getByRole("button", { name: /Interrupt the current/ })).toHaveProperty(
"disabled",
true,
);
});
it("greys out a dead session in the strip and skips it for focus", async () => {
const s1: SessionObservation = { sessionId: "s1", action: "idle", since: Date.now() - 20000 };
const s2: SessionObservation = { sessionId: "s2", action: "idle", since: Date.now() - 10000 };
const h = makeHarness([s1, s2]);
render(<ObservationOverlay store={h.store} />);
await screen.findByText(/s2 · idle/);
h.push([{ ...s1, dead: true, since: Date.now() }, s2]);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Pin session s1" }).closest("[data-state]"),
).toHaveProperty("dataset", expect.objectContaining({ state: "dead" })),
);
// Dead sessions never take the stage.
expect(screen.getByText(/s2 · idle/)).toBeTruthy();
});
it("interrupts a strip session directly on hover without focusing it", async () => {
const s1: SessionObservation = {
sessionId: "s1",
action: "clicking",
since: Date.now() - 30000,
};
const s2: SessionObservation = { sessionId: "s2", action: "idle", since: Date.now() };
const h = makeHarness([s1, s2]);
render(<ObservationOverlay store={h.store} />);
await screen.findByText(/s2 · idle/);
const item = screen.getByRole("button", { name: "Pin session s1" }).closest("div");
if (item === null) throw new Error("no strip item");
fireEvent.pointerEnter(item);
const mini = await screen.findByRole("button", { name: "Interrupt session s1" });
fireEvent.click(mini);
await waitFor(() =>
expect(
h.fetches.some(
(f) => f.url === "/bsk-observation/interrupt" && f.init?.body === '{"sessionId":"s1"}',
),
).toBe(true),
);
// Focus did not move to s1.
expect(screen.getByText(/s2 · idle/)).toBeTruthy();
});
});
@@ -56,7 +56,9 @@ interface FakeRunner extends BskRunner {
}
/** Runner that answers screenshot with a real temp PNG and records kills. */
function fakeRunner(opts: { screenshotFails?: boolean; killCount?: number } = {}): FakeRunner {
function fakeRunner(
opts: { screenshotFails?: boolean; screenshotNotFound?: boolean; killCount?: number } = {},
): FakeRunner {
const calls: FakeRunner["calls"] = [];
const killed: string[] = [];
return {
@@ -65,6 +67,15 @@ function fakeRunner(opts: { screenshotFails?: boolean; killCount?: number } = {}
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
calls.push({ args, options });
if (args[0] === "screenshot") {
if (opts.screenshotNotFound) {
return {
code: 4,
stdout: JSON.stringify({ code: "session_not_found", message: "no such session" }),
stderr: "",
timedOut: false,
aborted: false,
};
}
if (opts.screenshotFails) {
return { code: 1, stdout: "", stderr: "boom", timedOut: false, aborted: false };
}
@@ -257,8 +268,10 @@ describe("observation traffic isolation", () => {
// The capture upsert carries the thumbnail but the action stays idle…
const captureEvents = events.filter((e) => e.type === "upsert");
expect(captureEvents).toHaveLength(1);
expect(captureEvents[0].session?.action).toBe("idle");
expect(captureEvents[0].session?.thumbnailAttachmentId).toBe("att-1");
const captureEvent = captureEvents[0];
if (captureEvent.type !== "upsert") throw new Error("unreachable");
expect(captureEvent.session?.action).toBe("idle");
expect(captureEvent.session?.thumbnailAttachmentId).toBe("att-1");
// …and the registry's current pointer never moved through observation.
expect(registry.current()).toBe("s1");
expect(registry.list()[0].startedAtMs).toBe(1);
@@ -398,6 +411,55 @@ describe("HTTP/SSE interface", () => {
});
});
describe("availability and dead sessions", () => {
it("flips availability off after repeated global failures and back on success", async () => {
const scheduler = fakeScheduler();
const runner = fakeRunner({ screenshotFails: true });
const { service, events } = setup({ runner, scheduler });
service.addSession("s1");
for (let i = 1; i <= 3; i++) {
scheduler.runNext();
await waitFor(() => runner.calls.filter((c) => c.args[0] === "screenshot").length === i);
}
expect(service.isAvailable()).toBe(false);
expect(events.some((e) => e.type === "availability" && e.available === false)).toBe(true);
service.dispose();
});
it("marks a session dead on session_not_found and stops asking for frames", async () => {
const scheduler = fakeScheduler();
const runner = fakeRunner({ screenshotNotFound: true });
const { service, events } = setup({ runner, scheduler });
service.addSession("s1");
scheduler.runNext();
await waitFor(() => events.some((e) => e.type === "upsert" && e.session?.dead === true));
expect(service.getState()[0].dead).toBe(true);
expect(service.getState()[0].action).toBe("idle");
// No further captures are scheduled for a dead session.
expect(scheduler.pending()).toEqual([]);
// Instrumentation is dropped for dead sessions.
service.beginAction("s1", "clicking");
expect(service.getState()[0].action).toBe("idle");
// A dead session leaves cleanly.
service.removeSession("s1");
expect(service.getState()).toEqual([]);
});
it("clears lastError on the next action and keeps it on failure", () => {
const { service } = setup({});
service.addSession("s1");
service.endAction("s1", "click failed");
expect(service.getState()[0].lastError).toBe("click failed");
service.beginAction("s1", "clicking");
expect(service.getState()[0].lastError).toBeUndefined();
service.endAction("s1");
expect(service.getState()[0].lastError).toBeUndefined();
service.endAction("s1", "fill failed");
expect(service.getState()[0].lastError).toBe("fill failed");
service.dispose();
});
});
describe("actionForLabel", () => {
it("maps tool labels onto observation verbs", () => {
expect(actionForLabel("navigate")).toBe("navigating");