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
@@ -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. */