From ce89e9521179c7c37f3c3965a7713e6f59badce9 Mon Sep 17 00:00:00 2001
From: BB-fat <1056871944@qq.com>
Date: Fri, 14 Aug 2026 05:58:53 +0000
Subject: [PATCH] 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.
---
.../src/client/ObservationOverlay.module.css | 98 +++++++++++
.../src/client/ObservationOverlay.tsx | 155 ++++++++++++++++--
.../src/client/observation-store.ts | 19 ++-
.../src/observation-http.ts | 5 +-
.../src/observation.ts | 77 +++++++--
.../tests/client/observation-overlay.test.tsx | 106 ++++++++++++
.../tests/observation.test.ts | 68 +++++++-
7 files changed, 489 insertions(+), 39 deletions(-)
diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css
index 2fe2215..697749c 100644
--- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css
+++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.module.css
@@ -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;
+}
diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx
index 3f54b40..243b177 100644
--- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx
+++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx
@@ -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 (
+
) => 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 (
@@ -119,11 +214,7 @@ function OverlayBody(props: {
role="presentation"
>
-
- {focus === undefined
- ? "browser sessions"
- : `${focus.sessionId} · ${actionText} · ${elapsed}`}
-
+
{statusText}
{onCollapse !== undefined ? (
) : (
- {thumb?.status === "error" ? "frame unavailable" : "waiting for page"}
+ {!available
+ ? "last frame kept"
+ : thumb?.status === "error"
+ ? "frame unavailable"
+ : "waiting for page"}
)}
{thumb?.status === "error" ? (
@@ -154,6 +249,20 @@ function OverlayBody(props: {
) : null}
+ {sessions.length >= 2 ? (
+
+ {sessions.map((obs) => (
+
+ ))}
+
+ ) : null}