1import { useAtomValue } from "@effect/atom-react";
2import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
3import { PREVIEW_STREAM_BASE_PATH } from "@t3tools/client-runtime/preview/server-browser-stream";
4import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview";
5import { resolveDeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
6import type {
7 EnvironmentId,
8 PreviewEvent,
9 PreviewListResult,
10 PreviewSessionSnapshot,
11 ThreadId,
12} from "@t3tools/contracts";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15import { AsyncResult, Atom } from "effect/reactivity";
16import { useMemo } from "react";
17
18import { connectionAtomRuntime } from "../connection/runtime";
19import { useEnvironmentQuery } from "./query";
20import { environmentSession, usePreparedConnection } from "./session";
21
22export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime);
23
24interface ThreadPreviewTabs {
25 readonly serverEpoch: string | null;
26 readonly revision: number;
27 readonly sessions: ReadonlyArray<PreviewSessionSnapshot>;
28 /** A list result has arrived, so `sessions` covers tabs opened before this view. */
29 readonly listed: boolean;
30}
31
32const EMPTY_TABS: ThreadPreviewTabs = {
33 serverEpoch: null,
34 revision: 0,
35 sessions: [],
36 listed: false,
37};
38const emptyTabsAtom = Atom.make(EMPTY_TABS).pipe(Atom.withLabel("mobile-preview-tabs:empty"));
39
40const MAX_REPLAY_EVENTS = 200;
41
42function applyEvent(current: ThreadPreviewTabs, event: PreviewEvent): ThreadPreviewTabs {
43 if (event.revision <= current.revision) return current;
44 const index = current.sessions.findIndex((session) => session.tabId === event.tabId);
45 const sessions = [...current.sessions];
46 if (event.type === "closed") {
47 if (index !== -1) sessions.splice(index, 1);
48 } else if (event.type === "failed") {
49 const existing = sessions[index];
50 if (existing) {
51 sessions[index] = {
52 ...existing,
53 navStatus: {
54 _tag: "LoadFailed",
55 url: event.url,
56 title: event.title,
57 code: event.code,
58 description: event.description,
59 },
60 updatedAt: event.createdAt,
61 };
62 }
63 } else {
64 // Keep a tab's place so the picker order is stable.
65 sessions[index === -1 ? sessions.length : index] = event.snapshot;
66 }
67 return { ...current, serverEpoch: event.serverEpoch, revision: event.revision, sessions };
68}
69
70const threadPreviewTabsAtom = Atom.family((threadKey: string) => {
71 const ref = parseScopedThreadKey(threadKey);
72 if (ref === null) return emptyTabsAtom;
73 const listAtom = previewEnvironment.list({
74 environmentId: ref.environmentId,
75 input: { threadId: ref.threadId },
76 });
77 const eventsAtom = previewEnvironment.events({ environmentId: ref.environmentId, input: {} });
78 return Atom.make((get) => {
79 let disposed = false;
80 let state = EMPTY_TABS;
81 // Lists can land after events they predate; replay newer events on each list.
82 let list: PreviewListResult | null = null;
83 let events: ReadonlyArray<PreviewEvent> = [];
84 const publish = (next: ThreadPreviewTabs) => {
85 if (next === state) return;
86 state = next;
87 get.setSelf(next);
88 };
89 const applyList = (result: PreviewListResult) => {
90 if (list?.serverEpoch === result.serverEpoch && result.revision < list.revision) return;
91 list = result;
92 events = events.filter(
93 (event) => event.serverEpoch === result.serverEpoch && event.revision > result.revision,
94 );
95 return events.reduce<ThreadPreviewTabs>(applyEvent, { ...result, listed: true });
96 };
97 get.addFinalizer(() => {
98 disposed = true;
99 });
100 get.subscribe(listAtom, (result) => {
101 if (!AsyncResult.isSuccess(result)) return;
102 const next = applyList(result.value);
103 if (next) publish(next);
104 });
105 get.subscribe(eventsAtom, (result) => {
106 if (!AsyncResult.isSuccess(result) || result.value.threadId !== ref.threadId) return;
107 const event = result.value;
108 if (list?.serverEpoch === event.serverEpoch && event.revision <= list.revision) return;
109 events = [...events.slice(1 - MAX_REPLAY_EVENTS), event];
110 // A restarted server resets revisions; only a fresh list is authoritative.
111 if (state.serverEpoch !== null && event.serverEpoch !== state.serverEpoch) {
112 get.refresh(listAtom);
113 return;
114 }
115 publish(applyEvent(state, event));
116 });
117 get.mount(listAtom);
118 get.mount(eventsAtom);
119 const cached = get.once(listAtom);
120 if (AsyncResult.isSuccess(cached)) state = applyList(cached.value) ?? state;
121 // The cached list can predate an agent-opened tab.
122 queueMicrotask(() => {
123 if (!disposed) get.refresh(listAtom);
124 });
125 return state;
126 }).pipe(Atom.setIdleTTL(1_000), Atom.withLabel(`mobile-preview-tabs:${threadKey}`));
127});
128
129export function useThreadServerBrowserTabs(input: {
130 readonly environmentId: EnvironmentId;
131 readonly threadId: ThreadId;
132 readonly enabled: boolean;
133}) {
134 const tabs = useAtomValue(
135 input.enabled
136 ? threadPreviewTabsAtom(
137 scopedThreadKey({ environmentId: input.environmentId, threadId: input.threadId }),
138 )
139 : emptyTabsAtom,
140 );
141 const sessions = useMemo(
142 () => tabs.sessions.filter((session) => session.runtime === "server"),
143 [tabs.sessions],
144 );
145 return { tabs: sessions, loaded: tabs.listed };
146}
147
148const previewStreamAccessAtom = Atom.family((environmentId: EnvironmentId) =>
149 connectionAtomRuntime
150 .atom((get) => {
151 const prepared = Option.getOrNull(
152 get(environmentSession.preparedConnectionValueAtom(environmentId)),
153 );
154 return prepared === null
155 ? Effect.never
156 : resolveDeviceHubAccess({ prepared, hubBasePath: PREVIEW_STREAM_BASE_PATH });
157 })
158 .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`mobile-preview-stream-access:${environmentId}`)),
159);
160
161export function usePreviewStreamAccess(environmentId: EnvironmentId) {
162 const prepared = usePreparedConnection(environmentId);
163 const query = useEnvironmentQuery(previewStreamAccessAtom(environmentId));
164 const access = query.data && query.error === null && Option.isSome(prepared) ? query.data : null;
165 return { access, error: query.error, refresh: query.refresh };
166}