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