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 ( +
setHover(true)} + onPointerLeave={() => setHover(false)} + > + + {hover && !pinned && obs.dead !== true ? ( + + ) : null} +
+ ); +} + /** 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) => 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 ? (
+ {sessions.length >= 2 ? ( +
+ {sessions.map((obs) => ( + + ))} +
+ ) : null}