1"use client";
2
3import { useAtomValue } from "@effect/atom-react";
4import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
5import type { ScopedThreadRef } from "@t3tools/contracts";
6import * as Schema from "effect/Schema";
7import { AsyncResult, Atom } from "effect/reactivity";
8
9import {
10 applyPreviewServerEvent,
11 readThreadPreviewState,
12 reconcilePreviewServerSessions,
13} from "~/previewStateStore";
14import { previewEnvironment } from "~/state/preview";
15
16class PreviewSessionThreadKeyParseError extends Schema.TaggedError<PreviewSessionThreadKeyParseError>()(
17 "PreviewSessionThreadKeyParseError",
18 { threadKey: Schema.String },
19) {
20 override get message(): string {
21 return `Invalid scoped preview thread key: ${this.threadKey}`;
22 }
23}
24
25const previewSessionSyncAtom = Atom.family((threadKey: string) => {
26 const threadRef = parseScopedThreadKey(threadKey);
27 if (threadRef === null) {
28 throw new PreviewSessionThreadKeyParseError({ threadKey });
29 }
30
31 const sessionsAtom = previewEnvironment.list({
32 environmentId: threadRef.environmentId,
33 input: { threadId: threadRef.threadId },
34 });
35 const eventsAtom = previewEnvironment.events({
36 environmentId: threadRef.environmentId,
37 input: {},
38 });
39
40 return Atom.make((get) => {
41 let disposed = false;
42 let eventsVersion = 0;
43
44 const reconcileSessions = (result: Atom.Type<typeof sessionsAtom>) => {
45 if (!AsyncResult.isSuccess(result)) return;
46 reconcilePreviewServerSessions(threadRef, result.value);
47 if (!result.waiting && !readThreadPreviewState(threadRef).listLoaded) {
48 // An event overtook the first list. Retry the authoritative baseline;
49 // do not declare a partial event-only index ready for surface cleanup.
50 queueMicrotask(() => {
51 if (!disposed) get.refresh(sessionsAtom);
52 });
53 }
54 };
55
56 const applyLatestEvent = (result: Atom.Type<typeof eventsAtom>) => {
57 if (!AsyncResult.isSuccess(result) || result.value.threadId !== threadRef.threadId) return;
58 const currentEpoch = readThreadPreviewState(threadRef).serverEpoch;
59 if (currentEpoch !== null && currentEpoch !== result.value.serverEpoch) {
60 get.refresh(sessionsAtom);
61 return;
62 }
63 applyPreviewServerEvent(threadRef, result.value);
64 };
65
66 get.addFinalizer(() => {
67 disposed = true;
68 });
69 const initialEvent = get.once(eventsAtom);
70 get.subscribe(sessionsAtom, (result) => {
71 reconcileSessions(result);
72 });
73 get.subscribe(eventsAtom, (result) => {
74 eventsVersion += 1;
75 applyLatestEvent(result);
76 });
77 get.mount(sessionsAtom);
78 get.mount(eventsAtom);
79 queueMicrotask(() => {
80 if (disposed) return;
81 // The cached list can predate an automation-created tab. Keep the local
82 // snapshot visible until an authoritative refresh arrives instead of
83 // reconciling against a stale empty result when the panel first mounts.
84 get.refresh(sessionsAtom);
85 if (eventsVersion === 0) applyLatestEvent(initialEvent);
86 });
87 }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`preview:session-sync:${threadKey}`));
88});
89
90export function usePreviewSession(threadRef: ScopedThreadRef): void {
91 useAtomValue(previewSessionSyncAtom(scopedThreadKey(threadRef)));
92}