mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user