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();
}