/** * 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 PreviewEvent, 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 { canGoBack: boolean; canGoForward: boolean; loading: boolean; zoomFactor: number; colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; } export interface ThreadPreviewState { snapshot: PreviewSessionSnapshot | null; sessions: Record; /** Tabs intentionally closed by this client. Stale list snapshots must not resurrect them. */ suppressedTabIds: ReadonlySet; activeTabId: string | null; desktopOverlay: DesktopPreviewOverlay | null; desktopByTabId: Record; recentlySeenUrls: string[]; } const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ snapshot: null, sessions: {}, suppressedTabIds: new Set(), activeTabId: null, desktopOverlay: null, desktopByTabId: {}, recentlySeenUrls: [] as string[], }); const emptyPreviewStateAtom = Atom.make(EMPTY_THREAD_PREVIEW_STATE).pipe( Atom.withLabel("preview:empty-thread"), ); export const previewStateAtom = Atom.family((threadKey: string) => Atom.make(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; } const activePreviewThreadKeysAtom = Atom.make({ keys: new Set(), }).pipe(Atom.keepAlive, Atom.withLabel("preview:active-thread-keys")); const activePreviewSessionsAtom = Atom.make((get) => { const byThreadKey: Record = {}; 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(); 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, ): 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 { 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) => { 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": return removeSession(current, event.tabId); } }); } 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, snapshots: ReadonlyArray, ): void { updateThreadPreviewState(ref, (current) => { const sessions: Record = {}; let recentlySeenUrls = current.recentlySeenUrls; for (const snapshot of snapshots) { if (current.suppressedTabIds.has(snapshot.tabId)) continue; const existing = current.sessions[snapshot.tabId]; 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 = Object.fromEntries( Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined), ); return { ...current, sessions, activeTabId, snapshot, desktopByTabId, desktopOverlay: activeTabId ? (desktopByTabId[activeTabId] ?? null) : null, recentlySeenUrls, }; }); } 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() }); } export const __testing = { EMPTY_THREAD_PREVIEW_STATE, RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT, };