1import { useAtomValue } from "@effect/atom-react";
2import { scopedThreadKey } from "@t3tools/client-runtime/environment";
3import {
4 type DesktopPreviewColorScheme,
5 type DesktopPreviewFavicon,
6 type PreviewEvent,
7 type PreviewListResult,
8 type PreviewSessionSnapshot,
9 type ScopedThreadRef,
10} from "@t3tools/contracts";
11import { Atom } from "effect/reactivity";
12
13import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants";
14import { appAtomRegistry } from "./rpc/atomRegistry";
15
16export interface DesktopPreviewOverlay {
17 hasWebContents: boolean;
18 canGoBack: boolean;
19 canGoForward: boolean;
20 loading: boolean;
21 zoomFactor: number;
22 pictureInPicture: boolean;
23 colorScheme: DesktopPreviewColorScheme;
24 audioMuted: boolean;
25 audible: boolean;
26 controller: "human" | "agent" | "none";
27 favicon: DesktopPreviewFavicon | null;
28}
29
30export interface ThreadPreviewState {
31 snapshot: PreviewSessionSnapshot | null;
32 sessions: Record<string, PreviewSessionSnapshot>;
33 /** Tabs intentionally closed by this client. Stale list snapshots must not resurrect them. */
34 suppressedTabIds: ReadonlySet<string>;
35 activeTabId: string | null;
36 desktopOverlay: DesktopPreviewOverlay | null;
37 desktopByTabId: Record<string, DesktopPreviewOverlay>;
38 recentlySeenUrls: string[];
39 listLoaded: boolean;
40 serverEpoch: string | null;
41 serverRevision: number;
42}
43
44const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({
45 snapshot: null,
46 sessions: {},
47 suppressedTabIds: new Set<string>(),
48 activeTabId: null,
49 desktopOverlay: null,
50 desktopByTabId: {},
51 recentlySeenUrls: [] as string[],
52 listLoaded: false,
53 serverEpoch: null,
54 serverRevision: 0,
55});
56
57const emptyPreviewStateAtom = Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(
58 Atom.withLabel("preview:empty-thread"),
59);
60
61export const previewStateAtom = Atom.family((threadKey: string) =>
62 Atom.make<ThreadPreviewState>(EMPTY_THREAD_PREVIEW_STATE).pipe(
63 Atom.keepAlive,
64 Atom.withLabel(`preview:thread:${threadKey}`),
65 ),
66);
67
68// Only Electron needs this cross-thread index; separate it to avoid unrelated preview subscriptions.
69interface ActivePreviewThreadIndex {
70 readonly keys: ReadonlySet<string>;
71}
72
73const activePreviewThreadKeysAtom = Atom.make<ActivePreviewThreadIndex>({
74 keys: new Set<string>(),
75}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-thread-keys"));
76
77const activePreviewSessionsAtom = Atom.make((get) => {
78 const byThreadKey: Record<string, ThreadPreviewState> = {};
79 for (const threadKey of get(activePreviewThreadKeysAtom).keys) {
80 const state = get(previewStateAtom(threadKey));
81 if (Object.keys(state.sessions).length > 0) {
82 byThreadKey[threadKey] = state;
83 }
84 }
85 return byThreadKey;
86}).pipe(Atom.withLabel("preview:active-sessions"));
87
88/**
89 * Every thread whose preview state has been written. Panel tabs and the mini
90 * player follow those threads' states from a store this one cannot import
91 * (rightPanelStore -> closedViewStore -> lib/utils -> composerDraftStore ->
92 * previewStateStore is a cycle), so they subscribe through this index.
93 */
94export const previewThreadRefsAtom = Atom.make<ReadonlyMap<string, ScopedThreadRef>>(
95 new Map(),
96).pipe(Atom.keepAlive, Atom.withLabel("preview:thread-refs"));
97
98function syncActivePreviewThread(threadKey: string, state: ThreadPreviewState): void {
99 const active = Object.keys(state.sessions).length > 0;
100 appAtomRegistry.update(activePreviewThreadKeysAtom, (current) => {
101 if (current.keys.has(threadKey) === active) return current;
102 const next = new Set(current.keys);
103 if (active) next.add(threadKey);
104 else next.delete(threadKey);
105 return { keys: next };
106 });
107}
108
109function updateThreadPreviewState(
110 ref: ScopedThreadRef,
111 update: (current: ThreadPreviewState) => ThreadPreviewState,
112): void {
113 const threadKey = scopedThreadKey(ref);
114 // Indexed before the write so a new thread's subscribers see this change.
115 appAtomRegistry.update(previewThreadRefsAtom, (refs) =>
116 refs.has(threadKey) ? refs : new Map(refs).set(threadKey, ref),
117 );
118 const atom = previewStateAtom(threadKey);
119 let nextState = appAtomRegistry.get(atom);
120 const changed = appAtomRegistry.modify(atom, (current) => {
121 nextState = update(current);
122 return [nextState !== current, nextState];
123 });
124 if (changed) syncActivePreviewThread(threadKey, nextState);
125}
126
127const dedupeRecentUrls = (existing: string[], url: string): string[] => {
128 const next = [url, ...existing.filter((entry) => entry !== url)];
129 return next.slice(0, PREVIEW_RECENT_URL_LIMIT);
130};
131
132const rememberSnapshotUrl = (
133 recentlySeenUrls: string[],
134 snapshot: PreviewSessionSnapshot,
135): string[] =>
136 snapshot.navStatus._tag === "Idle"
137 ? recentlySeenUrls
138 : dedupeRecentUrls(recentlySeenUrls, snapshot.navStatus.url);
139
140const latestSnapshot = (
141 sessions: Record<string, PreviewSessionSnapshot>,
142): PreviewSessionSnapshot | null =>
143 Object.values(sessions)
144 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt))
145 .at(-1) ?? null;
146
147const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => {
148 if (!current.sessions[tabId]) return current;
149 const { [tabId]: _closed, ...sessions } = current.sessions;
150 const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId;
151 const nextSnapshot = latestSnapshot(sessions);
152 const activeTabId =
153 current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId;
154 const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot;
155 return {
156 ...current,
157 sessions,
158 desktopByTabId,
159 activeTabId: snapshot?.tabId ?? null,
160 snapshot,
161 desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null,
162 };
163};
164
165export function useThreadPreviewState(ref: ScopedThreadRef | null | undefined): ThreadPreviewState {
166 const atom = ref ? previewStateAtom(scopedThreadKey(ref)) : emptyPreviewStateAtom;
167 return useAtomValue(atom);
168}
169
170export function useActivePreviewSessions(): Record<string, ThreadPreviewState> {
171 return useAtomValue(activePreviewSessionsAtom);
172}
173
174export function readActivePreviewSessions(): Record<string, ThreadPreviewState> {
175 return appAtomRegistry.get(activePreviewSessionsAtom);
176}
177
178export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState {
179 return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref)));
180}
181
182export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void {
183 updateThreadPreviewState(ref, (current) => {
184 if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current;
185 if (event.revision < current.serverRevision) return current;
186 const next = (() => {
187 switch (event.type) {
188 case "opened":
189 case "navigated":
190 case "resized": {
191 const snapshot = event.snapshot;
192 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
193 const recentlySeenUrls =
194 snapshot.navStatus._tag === "Idle"
195 ? current.recentlySeenUrls
196 : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url);
197 const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };
198 const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId;
199 const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot;
200 return {
201 ...current,
202 sessions,
203 activeTabId: activeTabId ?? snapshot.tabId,
204 snapshot: activeSnapshot,
205 desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null,
206 recentlySeenUrls,
207 };
208 }
209 case "failed": {
210 const existing = current.sessions[event.tabId];
211 if (!existing) return current;
212 const failedSnapshot = {
213 ...existing,
214 navStatus: {
215 _tag: "LoadFailed" as const,
216 url: event.url,
217 title: event.title,
218 code: event.code,
219 description: event.description,
220 ...(event.download === undefined ? {} : { download: event.download }),
221 },
222 updatedAt: event.createdAt,
223 };
224 const sessions = { ...current.sessions, [event.tabId]: failedSnapshot };
225 return {
226 ...current,
227 sessions,
228 snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot,
229 };
230 }
231 case "closed": {
232 const closed = removeSession(current, event.tabId);
233 if (!closed.suppressedTabIds.has(event.tabId)) return closed;
234 const suppressedTabIds = new Set(closed.suppressedTabIds);
235 suppressedTabIds.delete(event.tabId);
236 return { ...closed, suppressedTabIds };
237 }
238 }
239 })();
240 return next.serverRevision === event.revision && next.serverEpoch === event.serverEpoch
241 ? next
242 : {
243 ...next,
244 serverEpoch: event.serverEpoch,
245 serverRevision: event.revision,
246 };
247 });
248}
249
250export function applyPreviewServerSnapshot(
251 ref: ScopedThreadRef,
252 snapshot: PreviewSessionSnapshot | null,
253): void {
254 updateThreadPreviewState(ref, (current) => {
255 if (!snapshot && current.snapshot === null) return current;
256 if (!snapshot) {
257 return {
258 ...current,
259 snapshot: null,
260 sessions: {},
261 activeTabId: null,
262 desktopOverlay: null,
263 desktopByTabId: {},
264 };
265 }
266 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
267 const existing = current.sessions[snapshot.tabId];
268 if (existing && existing.updatedAt > snapshot.updatedAt) return current;
269 const recentlySeenUrls = rememberSnapshotUrl(current.recentlySeenUrls, snapshot);
270 return {
271 ...current,
272 snapshot,
273 sessions: { ...current.sessions, [snapshot.tabId]: snapshot },
274 activeTabId: snapshot.tabId,
275 desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null,
276 recentlySeenUrls,
277 };
278 });
279}
280
281/** Updates background tabs without focusing them. */
282export function updatePreviewServerSnapshot(
283 ref: ScopedThreadRef,
284 snapshot: PreviewSessionSnapshot,
285): void {
286 updateThreadPreviewState(ref, (current) => {
287 if (current.suppressedTabIds.has(snapshot.tabId)) return current;
288 const existing = current.sessions[snapshot.tabId];
289 if (existing && existing.updatedAt > snapshot.updatedAt) return current;
290 const sessions = { ...current.sessions, [snapshot.tabId]: snapshot };
291 const activeTabId =
292 current.activeTabId && sessions[current.activeTabId] ? current.activeTabId : snapshot.tabId;
293 const activeSnapshot = sessions[activeTabId] ?? snapshot;
294 return {
295 ...current,
296 sessions,
297 activeTabId,
298 snapshot: activeSnapshot,
299 desktopOverlay: current.desktopByTabId[activeTabId] ?? null,
300 recentlySeenUrls: rememberSnapshotUrl(current.recentlySeenUrls, snapshot),
301 };
302 });
303}
304
305export function reconcilePreviewServerSessions(
306 ref: ScopedThreadRef,
307 result: PreviewListResult,
308): void {
309 updateThreadPreviewState(ref, (current) => {
310 const sameServer = current.serverEpoch === result.serverEpoch;
311 if (sameServer && result.revision < current.serverRevision) {
312 return current;
313 }
314 const snapshots = result.sessions;
315 const sessions: Record<string, PreviewSessionSnapshot> = {};
316 const currentSuppressedTabIds = sameServer ? current.suppressedTabIds : new Set<string>();
317 let recentlySeenUrls = current.recentlySeenUrls;
318 for (const snapshot of snapshots) {
319 if (currentSuppressedTabIds.has(snapshot.tabId)) continue;
320 const existing = sameServer ? current.sessions[snapshot.tabId] : undefined;
321 const next = existing && existing.updatedAt > snapshot.updatedAt ? existing : snapshot;
322 sessions[next.tabId] = next;
323 recentlySeenUrls = rememberSnapshotUrl(recentlySeenUrls, next);
324 }
325
326 const fallback = latestSnapshot(sessions);
327 const activeTabId =
328 current.activeTabId && sessions[current.activeTabId]
329 ? current.activeTabId
330 : (fallback?.tabId ?? null);
331 const snapshot = activeTabId ? (sessions[activeTabId] ?? null) : null;
332 const desktopByTabId = sameServer
333 ? Object.fromEntries(
334 Object.entries(current.desktopByTabId).filter(([tabId]) => sessions[tabId] !== undefined),
335 )
336 : {};
337 const suppressedTabIds = new Set(
338 [...currentSuppressedTabIds].filter((tabId) =>
339 snapshots.some((snapshot) => snapshot.tabId === tabId),
340 ),
341 );
342 const desktopOverlay = activeTabId ? (desktopByTabId[activeTabId] ?? null) : null;
343 if (
344 sameServer &&
345 current.listLoaded &&
346 current.serverRevision === result.revision &&
347 current.activeTabId === activeTabId &&
348 current.snapshot === snapshot &&
349 current.desktopOverlay === desktopOverlay &&
350 current.recentlySeenUrls === recentlySeenUrls &&
351 haveSameValues(current.sessions, sessions) &&
352 haveSameValues(current.desktopByTabId, desktopByTabId) &&
353 current.suppressedTabIds.size === suppressedTabIds.size
354 ) {
355 return current;
356 }
357 return {
358 ...current,
359 sessions,
360 suppressedTabIds,
361 activeTabId,
362 snapshot,
363 desktopByTabId,
364 desktopOverlay,
365 recentlySeenUrls,
366 listLoaded: true,
367 serverEpoch: result.serverEpoch,
368 serverRevision: result.revision,
369 };
370 });
371}
372
373function haveSameValues<T>(left: Record<string, T>, right: Record<string, T>): boolean {
374 const keys = Object.keys(left);
375 return keys.length === Object.keys(right).length && keys.every((key) => left[key] === right[key]);
376}
377
378function isPreviewStateEqual(
379 previous: DesktopPreviewOverlay | null,
380 next: DesktopPreviewOverlay | null,
381) {
382 return (
383 previous === next ||
384 (previous !== null &&
385 next !== null &&
386 previous.hasWebContents === next.hasWebContents &&
387 previous.canGoBack === next.canGoBack &&
388 previous.canGoForward === next.canGoForward &&
389 previous.loading === next.loading &&
390 previous.zoomFactor === next.zoomFactor &&
391 previous.pictureInPicture === next.pictureInPicture &&
392 previous.colorScheme === next.colorScheme &&
393 previous.audioMuted === next.audioMuted &&
394 previous.audible === next.audible &&
395 previous.controller === next.controller &&
396 previous.favicon?.dataUrl === next.favicon?.dataUrl &&
397 previous.favicon?.pageUrl === next.favicon?.pageUrl &&
398 previous.favicon?.capturedAt === next.favicon?.capturedAt)
399 );
400}
401
402export function applyPreviewDesktopState(
403 ref: ScopedThreadRef,
404 tabId: string,
405 overlay: DesktopPreviewOverlay | null,
406): void {
407 updateThreadPreviewState(ref, (current) => {
408 if (isPreviewStateEqual(current.desktopByTabId[tabId] ?? null, overlay)) {
409 return current;
410 }
411 const desktopByTabId = { ...current.desktopByTabId };
412 if (overlay) desktopByTabId[tabId] = overlay;
413 else delete desktopByTabId[tabId];
414 return {
415 ...current,
416 desktopByTabId,
417 desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay,
418 };
419 });
420}
421
422export function beginPreviewSessionClose(ref: ScopedThreadRef, tabId: string): void {
423 updateThreadPreviewState(ref, (current) => {
424 const suppressedTabIds = new Set(current.suppressedTabIds);
425 suppressedTabIds.add(tabId);
426 return {
427 ...removeSession(current, tabId),
428 suppressedTabIds,
429 };
430 });
431}
432
433export function cancelPreviewSessionClose(
434 ref: ScopedThreadRef,
435 snapshot: PreviewSessionSnapshot | null,
436 tabId: string,
437): void {
438 updateThreadPreviewState(ref, (current) => {
439 if (!current.suppressedTabIds.has(tabId)) return current;
440 const suppressedTabIds = new Set(current.suppressedTabIds);
441 suppressedTabIds.delete(tabId);
442 if (!snapshot) {
443 return { ...current, suppressedTabIds };
444 }
445 const recentlySeenUrls =
446 snapshot.navStatus._tag !== "Idle"
447 ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url)
448 : current.recentlySeenUrls;
449 return {
450 ...current,
451 snapshot,
452 sessions: { ...current.sessions, [snapshot.tabId]: snapshot },
453 suppressedTabIds,
454 activeTabId: snapshot.tabId,
455 desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null,
456 recentlySeenUrls,
457 };
458 });
459}
460
461export function setActivePreviewTab(ref: ScopedThreadRef, tabId: string): void {
462 updateThreadPreviewState(ref, (current) => {
463 const snapshot = current.sessions[tabId];
464 if (!snapshot || current.activeTabId === tabId) return current;
465 return {
466 ...current,
467 activeTabId: tabId,
468 snapshot,
469 desktopOverlay: current.desktopByTabId[tabId] ?? null,
470 };
471 });
472}
473
474/** Runs once the tab is known; popup opened events can arrive after their announcing stream. */
475export function whenPreviewTabKnown(
476 ref: ScopedThreadRef,
477 tabId: string,
478 action: () => void,
479 timeoutMs = 5_000,
480): () => void {
481 const atom = previewStateAtom(scopedThreadKey(ref));
482 if (appAtomRegistry.get(atom).sessions[tabId]) {
483 action();
484 return () => {};
485 }
486 const stop = () => {
487 clearTimeout(timer);
488 unsubscribe();
489 };
490 const unsubscribe = appAtomRegistry.subscribe(atom, (state) => {
491 if (!state.sessions[tabId]) return;
492 stop();
493 action();
494 });
495 const timer = setTimeout(stop, timeoutMs);
496 return stop;
497}
498
499export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void {
500 if (url.trim().length === 0) return;
501 updateThreadPreviewState(ref, (current) => ({
502 ...current,
503 recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url),
504 }));
505}
506
507export function isPreviewSupportedInRuntime(): boolean {
508 if (typeof window === "undefined") return false;
509 return Boolean(window.desktopBridge?.preview);
510}
511
512/** Releases desktop-host pages as well as local session state. */
513export function clearThreadPreviewState(ref: ScopedThreadRef): void {
514 updateThreadPreviewState(ref, (current) =>
515 Object.keys(current.sessions).length === 0 ? current : EMPTY_THREAD_PREVIEW_STATE,
516 );
517}
518
519export function resetPreviewStateForTests(): void {
520 for (const threadKey of appAtomRegistry.get(previewThreadRefsAtom).keys()) {
521 appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE);
522 }
523 appAtomRegistry.set(previewThreadRefsAtom, new Map());
524 appAtomRegistry.set(activePreviewThreadKeysAtom, { keys: new Set<string>() });
525}
526
527export const __testing = {
528 EMPTY_THREAD_PREVIEW_STATE,
529 RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT,
530};