mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
192 lines
7.1 KiB
TypeScript
192 lines
7.1 KiB
TypeScript
/**
|
|
* Shared view logic for the observation carriers (the floating overlay card
|
|
* and the better-sidebar tab): the store-backed view model (snapshot, focus
|
|
* pinning, elapsed ticker) and the Document PiP pop-out. Extracted from
|
|
* ObservationOverlay so both carriers run the same focus/interrupt behavior
|
|
* without duplicating hooks.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
import type { SessionObservation } from "../observation";
|
|
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
|
|
|
|
/**
|
|
* 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;
|
|
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" {
|
|
if (obs.lastError !== undefined && obs.action === "idle") return "error";
|
|
return obs.action === "idle" ? "idle" : "active";
|
|
}
|
|
|
|
/** Minimal Document PiP surface (TS lib.dom lacks it). */
|
|
export interface DocumentPip {
|
|
requestWindow(options?: { width?: number; height?: number }): Promise<Window>;
|
|
}
|
|
|
|
export function pipApi(): DocumentPip | undefined {
|
|
if (typeof window === "undefined") return undefined;
|
|
return (window as unknown as { documentPictureInPicture?: DocumentPip }).documentPictureInPicture;
|
|
}
|
|
|
|
/** Clone the host document's style/link nodes into a PiP window. */
|
|
export function cloneStylesInto(pipWindow: Window): void {
|
|
for (const node of document.querySelectorAll('link[rel="stylesheet"], style')) {
|
|
pipWindow.document.head.appendChild(node.cloneNode(true));
|
|
}
|
|
}
|
|
|
|
export interface ObservationView {
|
|
readonly snapshot: OverlaySnapshot;
|
|
/** Pinned session wins while it still exists; otherwise auto-follow. */
|
|
readonly focus: SessionObservation | undefined;
|
|
readonly pinnedId: string | null;
|
|
readonly onTogglePin: (sessionId: string) => void;
|
|
/** 1s ticker while anything is active (drives the elapsed readout). */
|
|
readonly now: number;
|
|
}
|
|
|
|
/**
|
|
* Whether one session is visible on a surface scoped to one DSH
|
|
* conversation: sessions started by that conversation or its descendants
|
|
* (the lineage ancestors ride along on the entry). Untracked sessions (no
|
|
* owner recorded) are hidden from scoped surfaces but stay in global views.
|
|
*/
|
|
export function visibleToScope(obs: SessionObservation, scopeId: string): boolean {
|
|
return obs.dshSessionIds?.includes(scopeId) === true;
|
|
}
|
|
|
|
/**
|
|
* The store-backed observation view model. Holds the feed for the component
|
|
* lifetime (refcounted — overlapping carriers never kill each other's
|
|
* stream). `scopeId` narrows the view to one DSH conversation's sessions
|
|
* (the better-sidebar tab); undefined keeps the global view (floating
|
|
* card, PiP).
|
|
*/
|
|
export function useObservationView(
|
|
store: ObservationClientStore,
|
|
scopeId?: string,
|
|
): ObservationView {
|
|
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
useEffect(() => {
|
|
store.acquire();
|
|
return () => store.release();
|
|
}, [store]);
|
|
|
|
const scopedSnapshot: OverlaySnapshot =
|
|
scopeId === undefined
|
|
? snapshot
|
|
: {
|
|
...snapshot,
|
|
sessions: snapshot.sessions.filter((s) => visibleToScope(s, scopeId)),
|
|
};
|
|
|
|
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
|
const pinned =
|
|
pinnedId !== null ? scopedSnapshot.sessions.find((s) => s.sessionId === pinnedId) : undefined;
|
|
const focus = pinned ?? focusOf(scopedSnapshot.sessions);
|
|
const onTogglePin = useCallback((sessionId: string) => {
|
|
setPinnedId((current) => (current === sessionId ? null : sessionId));
|
|
}, []);
|
|
|
|
const anyActive = scopedSnapshot.sessions.some((s) => s.action !== "idle");
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
if (!anyActive) return;
|
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(timer);
|
|
}, [anyActive]);
|
|
|
|
return {
|
|
snapshot: scopedSnapshot,
|
|
focus,
|
|
pinnedId: pinned !== undefined ? pinnedId : null,
|
|
onTogglePin,
|
|
now,
|
|
};
|
|
}
|
|
|
|
/** A mounted metadata watcher is not necessarily a visible screenshot viewer. */
|
|
export function useThumbnailObservation(store: ObservationClientStore, enabled: boolean) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
const element = ref.current;
|
|
if (!enabled || element === null) return;
|
|
// A portal uses its PiP document's visibility, independently of the main tab.
|
|
const doc = element.ownerDocument;
|
|
const Observer = doc.defaultView?.IntersectionObserver;
|
|
let active = true;
|
|
let intersecting = Observer === undefined;
|
|
let release: (() => void) | undefined;
|
|
const update = () => {
|
|
if (!active) return;
|
|
const visible = intersecting && doc.visibilityState !== "hidden";
|
|
if (visible && release === undefined) release = store.watchThumbnails();
|
|
else if (!visible && release !== undefined) {
|
|
release();
|
|
release = undefined;
|
|
}
|
|
};
|
|
const observer =
|
|
Observer === undefined
|
|
? undefined
|
|
: new Observer((entries) => {
|
|
intersecting = entries.some(
|
|
(entry) => entry.target === element && entry.isIntersecting,
|
|
);
|
|
update();
|
|
});
|
|
observer?.observe(element);
|
|
doc.addEventListener("visibilitychange", update);
|
|
update();
|
|
return () => {
|
|
active = false;
|
|
observer?.disconnect();
|
|
doc.removeEventListener("visibilitychange", update);
|
|
release?.();
|
|
};
|
|
}, [store, enabled]);
|
|
return ref;
|
|
}
|
|
|
|
export interface PipHandle {
|
|
/** The open PiP window, or null while the content renders in its carrier. */
|
|
readonly pipWindow: Window | null;
|
|
/** Whether the browser exposes Document PiP (Chrome-only today). */
|
|
readonly pipSupported: boolean;
|
|
/**
|
|
* Upgrade the content into a native PiP window. Rejects silently without a
|
|
* user gesture (or when closed mid-request): the carrier keeps the content.
|
|
*/
|
|
readonly popOut: (size?: { width: number; height: number }) => void;
|
|
}
|
|
|
|
export function usePip(): PipHandle {
|
|
const [pipWindow, setPipWindow] = useState<Window | null>(null);
|
|
const popOut = useCallback((size?: { width: number; height: number }): void => {
|
|
const pip = pipApi();
|
|
if (pip === undefined) return;
|
|
void pip
|
|
.requestWindow(size)
|
|
.then((win) => {
|
|
cloneStylesInto(win);
|
|
win.addEventListener("pagehide", () => setPipWindow(null));
|
|
setPipWindow(win);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
// Unmounting the carrier (tab close, conversation switch, plugin HMR) does
|
|
// not destroy the PiP browsing context: close it ourselves or it survives
|
|
// as a blank window. close() on an already-closed window is a no-op.
|
|
useEffect(() => () => pipWindow?.close(), [pipWindow]);
|
|
return { pipWindow, pipSupported: pipApi() !== undefined, popOut };
|
|
}
|