feat(dsh-plugin-browserskill): observation overlay — floating card, interrupt, and PiP (Phase 1-B)

Client half of the PiP observation window, wired to the host
ObservationService over the /bsk-observation HTTP+SSE seam:

- ObservationClientStore: initial state fetch + SSE increments +
  on-demand thumbnail blob loading (session-authorized readAttachment,
  same path as the toolview) + interrupt POST. All I/O injectable.
- ObservationOverlay registered into the shell.overlay list seat (the
  sanctioned frame-wide surface; root is off-limits). Hidden with no
  owned sessions; appears on first start; vanishes when all stop.
- Focus view: status row (green/grey/red dot + session + action + mm:ss
  ticking), breathing thumbnail (new frames fade in, failures keep the
  last frame with a warning badge, pre-navigate placeholder), and the
  action area.
- Interrupt: single click, no confirm; greys into 'Interrupting…' and
  back; disabled when nothing is in flight; one-time tooltip explains
  the semantics (stops the current action only; the run continues).
- Card: drag-move via the header, corner-handle resize (min 240x180,
  max 80% viewport), both clamped and kept for the page lifetime;
  collapsible status capsule.
- PiP: Pop out (user gesture) opens Document PiP with the card's
  current size, portals the same content in, and clones the document's
  style nodes; pagehide falls back to the card with state intact;
  unsupported browsers hide the button; nothing auto-pops.

Tests (+17, 89 total): store (fetch/SSE apply/malformed frames/
thumbnail lifecycle/interrupt wire/stop cleanup) and overlay (hidden->
visible lifecycle, status row, thumbnail via loader, interrupt
disabled/active/hint-once, capsule, resize clamps, move clamps, PiP
unsupported, PiP pop/fallback). Styling follows web-styling (dsw-alias
tokens, CSS Modules, focus-visible, reduced-motion).
This commit is contained in:
BB-fat
2026-08-14 05:33:34 +00:00
parent a7bc8bacf6
commit 24faa10659
9 changed files with 1169 additions and 20 deletions
@@ -0,0 +1,156 @@
/**
* Client-side data layer for the observation overlay: initial state fetch,
* SSE increment stream, on-demand thumbnail blob loading through the
* session-authorized readAttachment path, and the interrupt call. All I/O is
* injected so tests never touch a network.
*/
import type { ObservationEvent, SessionObservation } from "../observation";
export interface EventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
close(): void;
}
export interface ObservationClientDeps {
fetchFn: (
url: string,
init?: { method?: string; body?: string },
) => Promise<{
ok: boolean;
json(): Promise<unknown>;
}>;
eventSourceFactory: (url: string) => EventSourceLike;
/** Resolve one attachment id to a displayable blob URL. */
loadImage: (attachmentId: string) => Promise<string>;
}
export interface ThumbnailState {
status: "loading" | "ready" | "error";
url?: string;
}
export interface OverlaySnapshot {
readonly sessions: readonly SessionObservation[];
readonly connected: boolean;
readonly thumbnails: Readonly<Record<string, ThumbnailState>>;
}
const STATE_URL = "/bsk-observation/state";
const EVENTS_URL = "/bsk-observation/events";
const INTERRUPT_URL = "/bsk-observation/interrupt";
export class ObservationClientStore {
private sessions = new Map<string, SessionObservation>();
private thumbs = new Map<string, ThumbnailState>();
private listeners = new Set<() => void>();
private events: EventSourceLike | undefined;
private snapshot: OverlaySnapshot = { sessions: [], connected: false, thumbnails: {} };
private started = false;
constructor(private readonly deps: ObservationClientDeps) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): OverlaySnapshot => this.snapshot;
private publish(): void {
this.snapshot = {
sessions: [...this.sessions.values()],
connected: this.events !== undefined,
thumbnails: Object.fromEntries(this.thumbs),
};
for (const listener of [...this.listeners]) listener();
}
/** Initial fetch + SSE subscription. Idempotent. */
start(): void {
if (this.started) return;
this.started = true;
void this.deps
.fetchFn(STATE_URL)
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as { sessions?: SessionObservation[] };
this.sessions = new Map((body.sessions ?? []).map((s) => [s.sessionId, s]));
this.publish();
})
.catch(() => {});
const events = this.deps.eventSourceFactory(EVENTS_URL);
events.onmessage = (message) => {
let event: ObservationEvent;
try {
event = JSON.parse(message.data) as ObservationEvent;
} catch {
return;
}
this.apply(event);
};
this.events = events;
this.publish();
}
stop(): void {
this.events?.close();
this.events = undefined;
this.started = false;
for (const thumb of this.thumbs.values()) {
if (thumb.url !== undefined && typeof URL.revokeObjectURL === "function") {
URL.revokeObjectURL(thumb.url);
}
}
this.thumbs.clear();
this.sessions.clear();
this.publish();
}
private apply(event: ObservationEvent): void {
if (event.type === "reset") {
this.sessions.clear();
} else if (event.type === "remove" && event.session !== undefined) {
this.sessions.delete(event.session.sessionId);
} else if (event.type === "upsert" && event.session !== undefined) {
this.sessions.set(event.session.sessionId, event.session);
}
this.publish();
}
/**
* Ensure a thumbnail load is in flight for one attachment reference. New
* frames replace the old URL; failures keep the last good frame (the caller
* renders `status: 'error'` as a small badge over the old image).
*/
ensureThumbnail(attachmentId: string | undefined): void {
if (attachmentId === undefined || this.thumbs.has(attachmentId)) return;
this.thumbs.set(attachmentId, { status: "loading" });
this.publish();
this.deps.loadImage(attachmentId).then(
(url) => {
this.thumbs.set(attachmentId, { status: "ready", url });
this.publish();
},
() => {
this.thumbs.set(attachmentId, { status: "error" });
this.publish();
},
);
}
/** Interrupt the current or named session's in-flight call. */
async interrupt(sessionId?: string): Promise<boolean> {
try {
const res = await this.deps.fetchFn(INTERRUPT_URL, {
method: "POST",
body: JSON.stringify(sessionId === undefined ? {} : { sessionId }),
});
if (!res.ok) return false;
const body = (await res.json()) as { interrupted?: boolean };
return body.interrupted === true;
} catch {
return false;
}
}
}