471 lines · 16.2 KB · TypeScript
RawDownload
/** * Per-thread preview UI state. * * Each thread owns an independent atom. Most consumers read exactly one * thread; the desktop browser host uses the aggregate session atom because it * is the one place that must enumerate every live preview tab. */import { useAtomValue } from "@effect/atom-react";import { scopedThreadKey } from "@t3tools/client-runtime/environment";import {  type DesktopPreviewColorScheme,  type DesktopPreviewFavicon,  type PreviewEvent,  type PreviewListResult,  type PreviewSessionSnapshot,  type ScopedThreadRef,} from "@t3tools/contracts";import { Atom } from "effect/unstable/reactivity"; import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants";import { appAtomRegistry } from "./rpc/atomRegistry"; export interface DesktopPreviewOverlay {  hasWebContents: boolean;  canGoBack: boolean;  canGoForward: boolean;  loading: boolean;  zoomFactor: number;  pictureInPicture: boolean;  colorScheme: DesktopPreviewColorScheme;  audioMuted: boolean;  audible: boolean;  controller: "human" | "agent" | "none";  favicon: DesktopPreviewFavicon | null;} export interface ThreadPreviewState {  snapshot: PreviewSessionSnapshot | null;  sessions: Record<string, PreviewSessionSnapshot>;  /** Tabs intentionally closed by this client. Stale list snapshots must not resurrect them. */  suppressedTabIds: ReadonlySet<string>;  activeTabId: string | null;  desktopOverlay: DesktopPreviewOverlay | null;  desktopByTabId: Record<string, DesktopPreviewOverlay>;  recentlySeenUrls: string[];  /** Server process currently authoritative for revision ordering. */  serverEpoch: string | null;  /** Latest ordered server revision applied from a list response or event. */  serverRevision: number;} const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({  snapshot: null,  sessions: {},  suppressedTabIds: new Set<string>(),  activeTabId: null,  desktopOverlay: null,  desktopByTabId: {},  recentlySeenUrls: [] as string[],  serverEpoch: null,  serverRevision: 0,}); const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(  Atom.withLabel("preview:empty-thread"),); export const previewStateAtom = Atom.family((threadKey: string) =>  Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(    Atom.keepAlive,    Atom.withLabel(`preview:thread:${threadKey}`),  ),); // Only the Electron browser host needs a cross-thread view. Keep that index// separate so thread-local readers never subscribe to unrelated previews.interface ActivePreviewThreadIndex {  readonly keys: ReadonlySet<string>;} const activePreviewThreadKeysAtom = Atom.make<ActivePreviewThreadIndex>({  keys: new Set<string>(),}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-thread-keys")); const activePreviewSessionsAtom = Atom.make((get) => {  const byThreadKey: Record<string, ThreadPreviewState> = {};  for (const threadKey of get(activePreviewThreadKeysAtom).keys) {    const state = get(previewStateAtom(threadKey));    if (Object.keys(state.sessions).length > 0) {      byThreadKey[threadKey] = state;    }  }  return byThreadKey;}).pipe(Atom.withLabel("preview:active-sessions")); const changedPreviewThreadKeys = new Set<string>(); function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): void {  const active = Object.keys(state.sessions).length > 0;  appAtomRegistry.update(activePreviewThreadKeysAtom, (current) => {    if (current.keys.has(threadKey) === active) return current;    const next = new Set(current.keys);    if (active) next.add(threadKey);    else next.delete(threadKey);    return { keys: next };  });} function updateThreadPreviewState(  ref: ScopedThreadRef,  update: (current: ThreadPreviewState) => ThreadPreviewState,): void {  const threadKey = scopedThreadKey(ref);  const atom = previewStateAtom(threadKey);  let nextState = appAtomRegistry.get(atom);  const changed = appAtomRegistry.modify(atom, (current) => {    nextState = update(current);    return [nextState !== current, nextState];  });  if (!changed) return;  changedPreviewThreadKeys.add(threadKey);  syncActivePreviewThread(threadKey, nextState);} const dedupeRecentUrls = (existing: string[], url: string): string[] => {  const next = [url, ...existing.filter((entry) => entry !== url)];  return next.slice(0, PREVIEW_RECENT_URL_LIMIT);}; const rememberSnapshotUrl = (  recentlySeenUrls: string[],  snapshot: PreviewSessionSnapshot,): string[] =>  snapshot.navStatus._tag === "Idle"    ? recentlySeenUrls    : dedupeRecentUrls(recentlySeenUrls, snapshot.navStatus.url); const latestSnapshot = (  sessions: Record<string, PreviewSessionSnapshot>,): PreviewSessionSnapshot | null =>  Object.values(sessions)    .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))    .at(-1) ?? null; const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {  if (!current.sessions[tabId]) return current;  const { [tabId]: _closed, ...sessions } = current.sessions;  const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;  const nextSnapshot = latestSnapshot(sessions);  const activeTabId =    current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;  const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;  return {    ...current,    sessions,    desktopByTabId,    activeTabId: snapshot?.tabId ?? null,    snapshot,    desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null,  };}; export function useThreadPreviewState(ref: ScopedThreadRef | null | undefined): ThreadPreviewState {  const atom = ref ? previewStateAtom(scopedThreadKey(ref)) : emptyPreviewStateAtom;  return useAtomValue(atom);} export function useActivePreviewSessions(): Record<string, ThreadPreviewState> {  return useAtomValue(activePreviewSessionsAtom);} export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState {  return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref)));} export function subscribeThreadPreviewState(  ref: ScopedThreadRef,  listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void,): () => void {  const atom = previewStateAtom(scopedThreadKey(ref));  let previous = appAtomRegistry.get(atom);  return appAtomRegistry.subscribe(atom, (state) => {    const prior = previous;    previous = state;    listener(state, prior);  });} export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void {  updateThreadPreviewState(ref, (current) => {    if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current;    if (event.revision < current.serverRevision) return current;    const next = (() => {      switch (event.type) {        case "opened":        case "navigated":        case "resized": {          const snapshot = event.snapshot;          if (current.suppressedTabIds.has(snapshot.tabId)) return current;          const recentlySeenUrls =            snapshot.navStatus._tag === "Idle"              ? current.recentlySeenUrls              : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url);          const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };          const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId;          const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot;          return {            ...current,            sessions,            activeTabId: activeTabId ?? snapshot.tabId,            snapshot: activeSnapshot,            desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null,            recentlySeenUrls,          };        }        case "failed": {          const existing = current.sessions[event.tabId];          if (!existing) return current;          const failedSnapshot = {            ...existing,            navStatus: {              _tag: "LoadFailed" as const,              url: event.url,              title: event.title,              code: event.code,              description: event.description,            },            updatedAt: event.createdAt,          };          const sessions = { ...current.sessions, [event.tabId]: failedSnapshot };          return {            ...current,            sessions,            snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot,          };        }        case "closed": {          const closed = removeSession(current, event.tabId);          if (!closed.suppressedTabIds.has(event.tabId)) return closed;          const suppressedTabIds = new Set(closed.suppressedTabIds);          suppressedTabIds.delete(event.tabId);          return { ...closed, suppressedTabIds };        }      }    })();    return next.serverRevision === event.revision && next.serverEpoch === event.serverEpoch      ? next      : {          ...next,          serverEpoch: event.serverEpoch,          serverRevision: event.revision,        };  });} export function applyPreviewServerSnapshot(  ref: ScopedThreadRef,  snapshot: PreviewSessionSnapshot | null,): void {  updateThreadPreviewState(ref, (current) => {    if (!snapshot && current.snapshot === null) return current;    if (!snapshot) {      return {        ...current,        snapshot: null,        sessions: {},        activeTabId: null,        desktopOverlay: null,        desktopByTabId: {},      };    }    if (current.suppressedTabIds.has(snapshot.tabId)) return current;    const existing = current.sessions[snapshot.tabId];    if (existing && existing.updatedAt > snapshot.updatedAt) return current;    const recentlySeenUrls = rememberSnapshotUrl(current.recentlySeenUrls, snapshot);    return {      ...current,      snapshot,      sessions: { ...current.sessions, [snapshot.tabId]: snapshot },      activeTabId: snapshot.tabId,      desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null,      recentlySeenUrls,    };  });} /** * Merge a server mutation without changing which tab the user is viewing. * * Commands such as resize can target background tabs. Their response is * authoritative for that tab, but it is not a request to focus the tab. */export function updatePreviewServerSnapshot(  ref: ScopedThreadRef,  snapshot: PreviewSessionSnapshot,): void {  updateThreadPreviewState(ref, (current) => {    if (current.suppressedTabIds.has(snapshot.tabId)) return current;    const existing = current.sessions[snapshot.tabId];    if (existing && existing.updatedAt > snapshot.updatedAt) return current;    const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };    const activeTabId =      current.activeTabId && sessions[current.activeTabId] ? current.activeTabId : snapshot.tabId;    const activeSnapshot = sessions[activeTabId] ?? snapshot;    return {      ...current,      sessions,      activeTabId,      snapshot: activeSnapshot,      desktopOverlay: current.desktopByTabId[activeTabId] ?? null,      recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, snapshot),    };  });} /** * Replace the local session index from an authoritative preview.list result. * Missing tabs are removed while the current active tab is preserved whenever * it still exists in the server result. */export function reconcilePreviewServerSessions(  ref: ScopedThreadRef,  result: PreviewListResult,): void {  updateThreadPreviewState(ref, (current) => {    const sameServer = current.serverEpoch === result.serverEpoch;    if (sameServer && result.revision < current.serverRevision) return current;    const snapshots = result.sessions;    const sessions: Record<string, PreviewSessionSnapshot> = {};    const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set<string>();    let recentlySeenUrls = current.recentlySeenUrls;    for (const snapshot of snapshots) {      if (currentSuppressedTabIds.has(snapshot.tabId)) continue;      const existing = sameServer ? current.sessions[snapshot.tabId] : undefined;      const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot;      sessions[next.tabId] = next;      recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next);    }     const fallback = latestSnapshot(sessions);    const activeTabId =      current.activeTabId && sessions[current.activeTabId]        ? current.activeTabId        : (fallback?.tabId ?? null);    const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null;    const desktopByTabId = sameServer      ? Object.fromEntries(          Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),        )      : {};    const suppressedTabIds = new Set(      [...currentSuppressedTabIds].filter((tabId) =>        snapshots.some((snapshot) => snapshot.tabId === tabId),      ),    );    return {      ...current,      sessions,      suppressedTabIds,      activeTabId,      snapshot,      desktopByTabId,      desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null,      recentlySeenUrls,      serverEpoch: result.serverEpoch,      serverRevision: result.revision,    };  });} export function applyPreviewDesktopState(  ref: ScopedThreadRef,  tabId: string,  overlay: DesktopPreviewOverlay | null,): void {  updateThreadPreviewState(ref, (current) => {    const desktopByTabId = { ...current.desktopByTabId };    if (overlay) desktopByTabId[tabId] = overlay;    else delete desktopByTabId[tabId];    return {      ...current,      desktopByTabId,      desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay,    };  });} export function beginPreviewSessionClose(ref: ScopedThreadRef, tabId: string): void {  updateThreadPreviewState(ref, (current) => {    const suppressedTabIds = new Set(current.suppressedTabIds);    suppressedTabIds.add(tabId);    return {      ...removeSession(current, tabId),      suppressedTabIds,    };  });} export function cancelPreviewSessionClose(  ref: ScopedThreadRef,  snapshot: PreviewSessionSnapshot | null,  tabId: string,): void {  updateThreadPreviewState(ref, (current) => {    if (!current.suppressedTabIds.has(tabId)) return current;    const suppressedTabIds = new Set(current.suppressedTabIds);    suppressedTabIds.delete(tabId);    if (!snapshot) {      return { ...current, suppressedTabIds };    }    const recentlySeenUrls =      snapshot.navStatus._tag !== "Idle"        ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)        : current.recentlySeenUrls;    return {      ...current,      snapshot,      sessions: { ...current.sessions, [snapshot.tabId]: snapshot },      suppressedTabIds,      activeTabId: snapshot.tabId,      desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null,      recentlySeenUrls,    };  });} export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {  updateThreadPreviewState(ref, (current) => {    const snapshot = current.sessions[tabId];    if (!snapshot || current.activeTabId === tabId) return current;    return {      ...current,      activeTabId: tabId,      snapshot,      desktopOverlay: current.desktopByTabId[tabId] ?? null,    };  });} export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void {  if (url.trim().length === 0) return;  updateThreadPreviewState(ref, (current) => ({    ...current,    recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url),  }));} export function removePreviewThread(ref: ScopedThreadRef): void {  const threadKey = scopedThreadKey(ref);  appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE);  syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE);  changedPreviewThreadKeys.delete(threadKey);} export function isPreviewSupportedInRuntime(): boolean {  if (typeof window === "undefined") return false;  return Boolean(window.desktopBridge?.preview);} export function resetPreviewStateForTests(): void {  for (const threadKey of changedPreviewThreadKeys) {    appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE);  }  changedPreviewThreadKeys.clear();  appAtomRegistry.set(activePreviewThreadKeysAtom, { keys: new Set<string>() });} export const __testing = {  EMPTY_THREAD_PREVIEW_STATE,  RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT,};