1import { useAtomValue } from "@effect/atom-react";
2import type {
3 EnvironmentProject,
4 EnvironmentThread,
5 EnvironmentThreadShell,
6} from "@t3tools/client-runtime/state/shell";
7import {
8 EMPTY_THREAD_HISTORY_META,
9 type EnvironmentThreadStatus,
10 type ThreadHistoryMeta,
11} from "@t3tools/client-runtime/state/threads";
12import type { ScopedProjectRef, ScopedThreadRef, ServerConfig } from "@t3tools/contracts";
13import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts";
14import { Atom } from "effect/reactivity";
15import { appAtomRegistry } from "../rpc/atomRegistry";
16import { environmentProjects } from "./projects";
17import { environmentCapabilitiesAtom, environmentServerConfigsAtom } from "./server";
18import {
19 allEnvironmentProjectSnapshotsReadyAtom,
20 allEnvironmentShellsBootstrappedAtom,
21} from "./shell";
22import { environmentThreadDetails, environmentThreadShells } from "./threads";
23import { waitForAtomValue } from "./waitForAtomValue";
24
25const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
26const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]);
27
28const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
29 Atom.withLabel("web-project:empty"),
30);
31const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe(
32 Atom.withLabel("web-thread-refs:empty"),
33);
34const EMPTY_THREAD_SHELLS_ATOM = Atom.make<ReadonlyArray<EnvironmentThreadShell>>(
35 Object.freeze([]),
36).pipe(Atom.withLabel("web-thread-shells:empty"));
37const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).pipe(
38 Atom.withLabel("web-thread-shell:empty"),
39);
40const EMPTY_THREAD_PROJECTION_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(
41 Atom.withLabel("web-thread-projection:empty"),
42);
43const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pipe(
44 Atom.withLabel("web-thread-status:empty"),
45);
46const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe(
47 Atom.withLabel("web-thread-visible-turn-items:empty"),
48);
49const EMPTY_THREAD_HISTORY_ATOM = Atom.make<ThreadHistoryMeta>(EMPTY_THREAD_HISTORY_META).pipe(
50 Atom.withLabel("web-thread-history:empty"),
51);
52
53const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe(
54 Atom.keepAlive,
55 Atom.withLabel("web-active-environment-id"),
56);
57
58export function useActiveEnvironmentId(): EnvironmentId | null {
59 return useAtomValue(activeEnvironmentIdAtom);
60}
61
62export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void {
63 appAtomRegistry.set(activeEnvironmentIdAtom, environmentId);
64}
65
66export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> {
67 return useAtomValue(environmentThreadShells.threadRefsAtom);
68}
69
70export function useEnvironmentThreadRefs(
71 environmentId: EnvironmentId | null,
72): ReadonlyArray<ScopedThreadRef> {
73 return useAtomValue(
74 environmentId === null
75 ? EMPTY_THREAD_REFS_ATOM
76 : environmentThreadShells.environmentThreadRefsAtom(environmentId),
77 );
78}
79
80export function useProjects(): ReadonlyArray<EnvironmentProject> {
81 return useAtomValue(environmentProjects.projectsAtom);
82}
83
84export function useServerConfigs(): ReadonlyMap<EnvironmentId, ServerConfig> {
85 return useAtomValue(environmentServerConfigsAtom);
86}
87
88/** Every thread shell. Pass `enabled: false` to read a stable empty list and
89 skip re-rendering on each shell update while the caller does not need them. */
90export function useThreadShells(enabled = true): ReadonlyArray<EnvironmentThreadShell> {
91 return useAtomValue(
92 enabled ? environmentThreadShells.threadShellsAtom : EMPTY_THREAD_SHELLS_ATOM,
93 );
94}
95
96export function useAllEnvironmentShellsBootstrapped(): boolean {
97 return useAtomValue(allEnvironmentShellsBootstrappedAtom);
98}
99
100export function useAllEnvironmentProjectSnapshotsReady(): boolean {
101 return useAtomValue(allEnvironmentProjectSnapshotsReadyAtom);
102}
103
104export function useThreadShellsForProjectRefs(
105 refs: ReadonlyArray<ScopedProjectRef>,
106): ReadonlyArray<EnvironmentThreadShell> {
107 return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs));
108}
109
110export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null {
111 return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref));
112}
113
114export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null {
115 return useAtomValue(
116 ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref),
117 );
118}
119
120export function useChildThreadInputs(ref: ScopedThreadRef | null) {
121 return useAtomValue(
122 ref === null ? EMPTY_THREAD_SHELLS_ATOM : environmentThreadShells.childThreadInputsAtom(ref),
123 );
124}
125
126export function useThreadProjection(ref: ScopedThreadRef | null): EnvironmentThread | null {
127 return useAtomValue(
128 ref === null ? EMPTY_THREAD_PROJECTION_ATOM : environmentThreadDetails.threadAtom(ref),
129 );
130}
131
132export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadStatus {
133 return useAtomValue(
134 ref === null ? EMPTY_THREAD_STATUS_ATOM : environmentThreadDetails.statusAtom(ref),
135 );
136}
137
138export function useThreadHistory(ref: ScopedThreadRef | null): ThreadHistoryMeta {
139 return useAtomValue(
140 ref === null ? EMPTY_THREAD_HISTORY_ATOM : environmentThreadDetails.historyAtom(ref),
141 );
142}
143
144export function resolveThreadDetailRef(
145 ref: ScopedThreadRef | null,
146 options: {
147 shellExists: boolean;
148 waitForShell: boolean;
149 },
150): ScopedThreadRef | null {
151 return ref !== null && (!options.waitForShell || options.shellExists) ? ref : null;
152}
153
154export function useThreadVisibleTurnItems(
155 ref: ScopedThreadRef | null,
156): ReadonlyArray<OrchestrationV2ProjectedTurnItem> {
157 return useAtomValue(
158 ref === null
159 ? EMPTY_VISIBLE_TURN_ITEMS_ATOM
160 : environmentThreadDetails.visibleTurnItemsAtom(ref),
161 );
162}
163
164export function readProject(ref: ScopedProjectRef): EnvironmentProject | null {
165 return appAtomRegistry.get(environmentProjects.projectAtom(ref));
166}
167
168export function readProjects(): ReadonlyArray<EnvironmentProject> {
169 return appAtomRegistry.get(environmentProjects.projectsAtom);
170}
171
172/** Resolves when the project event reaches the live client store. */
173export function waitForProject(
174 ref: ScopedProjectRef,
175 timeoutMs = 10_000,
176): Promise<EnvironmentProject> {
177 const current = readProject(ref);
178 if (current !== null) return Promise.resolve(current);
179
180 return new Promise((resolve, reject) => {
181 let unsubscribe: (() => void) | null = null;
182 const timeout = setTimeout(() => {
183 unsubscribe?.();
184 reject(new Error("The project did not appear in the desktop app."));
185 }, timeoutMs);
186 const finish = (project: EnvironmentProject | null) => {
187 if (project === null) return;
188 clearTimeout(timeout);
189 unsubscribe?.();
190 resolve(project);
191 };
192 unsubscribe = appAtomRegistry.subscribe(environmentProjects.projectAtom(ref), finish);
193 finish(readProject(ref));
194 });
195}
196
197/**
198 * Resolves once a deleted project leaves the live client store. The delete
199 * command returns before the shell stream does, so anything that picks a
200 * project in between (the index route's draft landing) still sees it.
201 */
202export function waitForProjectRemoval(ref: ScopedProjectRef, timeoutMs = 5_000): Promise<boolean> {
203 return waitForAtomValue({
204 registry: appAtomRegistry,
205 atom: environmentProjects.projectAtom(ref),
206 predicate: (project) => project === null,
207 timeoutMs,
208 });
209}
210
211export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {
212 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
213}
214
215export function waitForThreadShell(ref: ScopedThreadRef, timeoutMs = 5_000): Promise<boolean> {
216 return waitForAtomValue({
217 registry: appAtomRegistry,
218 atom: environmentThreadShells.threadShellAtom(ref),
219 predicate: (thread) => thread !== null,
220 timeoutMs,
221 });
222}
223
224/** Whether the environment hosts preview tabs in its own browser (`runtime: "server"`),
225 so clients without Electron can still use the Browser panel. */
226export function useEnvironmentSupportsServerBrowser(environmentId: EnvironmentId | null): boolean {
227 const configs = useServerConfigs();
228 return (
229 environmentId !== null &&
230 configs.get(environmentId)?.environment.capabilities.serverBrowser === true
231 );
232}
233
234export function readEnvironmentSupportsServerBrowser(environmentId: EnvironmentId): boolean {
235 return (
236 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
237 .serverBrowser === true
238 );
239}
240
241export function readEnvironmentSupportsTitleRegeneration(environmentId: EnvironmentId): boolean {
242 return (
243 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadTitleRegeneration ===
244 true
245 );
246}
247
248/** Whether the environment's server understands thread.pin/unpin.
249 Same version-skew contract as settlement. */
250export function readEnvironmentSupportsPinning(environmentId: EnvironmentId): boolean {
251 return (
252 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinning === true
253 );
254}
255
256/** Whether the environment's server understands thread.pin.reorder (and
257 orderKey on thread.pin). Same version-skew contract as settlement. */
258export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): boolean {
259 return (
260 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadPinReorder === true
261 );
262}
263
264/** Whether the environment's server understands thread.auto-settle.set.
265 Same version-skew contract as settlement. */
266export function readEnvironmentSupportsAutoSettleOptOut(environmentId: EnvironmentId): boolean {
267 return (
268 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadAutoSettleOptOut ===
269 true
270 );
271}
272
273export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean {
274 return (
275 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadActiveReorder ===
276 true
277 );
278}
279
280/** Whether the environment's server understands thread.settle/unsettle.
281 False for pre-settlement servers (capability defaults false on decode),
282 so clients under version skew fall back instead of erroring. */
283export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean {
284 return (
285 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSettlement === true
286 );
287}
288
289/** Whether the environment's server understands thread.snooze/unsnooze.
290 Same version-skew contract as settlement. */
291export function readEnvironmentSupportsSnooze(environmentId: EnvironmentId): boolean {
292 return appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadSnooze === true;
293}
294
295/** Whether the environment's server understands thread.visit/mark-unread and
296 projects lastVisitedAt on thread shells. Same version-skew contract as
297 settlement: against older servers, clients keep the browser-local visited
298 state instead. */
299export function readEnvironmentSupportsVisitedTracking(environmentId: EnvironmentId): boolean {
300 return (
301 appAtomRegistry.get(environmentCapabilitiesAtom).get(environmentId)?.threadVisitedTracking ===
302 true
303 );
304}
305
306export function readEnvironmentThreadRefs(
307 environmentId: EnvironmentId,
308): ReadonlyArray<ScopedThreadRef> {
309 return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId));
310}
311
312export function readThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
313 return appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
314}