1import { useAtomValue } from "@effect/atom-react";
2import { useRoute, type RouteProp } from "@react-navigation/native";
3import { useMemo, useRef, useState } from "react";
4import {
5 EnvironmentId,
6 ThreadId,
7 type OrchestrationV2ThreadProjection,
8 type OrchestrationV2ThreadShell,
9 type ScopedProjectRef,
10 type ScopedThreadRef,
11} from "@t3tools/contracts";
12import {
13 presentThreadShell,
14 type EnvironmentThreadShell,
15} from "@t3tools/client-runtime/state/shell";
16import {
17 deriveLatestThreadRun,
18 deriveThreadRuntime,
19} from "@t3tools/client-runtime/state/thread-execution";
20import * as Option from "effect/Option";
21
22import { scopedThreadKey } from "../lib/scopedEntities";
23import { useProject, useThreadShell } from "../state/entities";
24import { useEnvironmentThread } from "../state/threads";
25import {
26 resolvePendingThreadCreation,
27 pendingThreadCreationOutcomesAtom,
28 pendingThreadCreationShell,
29 type PendingThreadCreation,
30} from "./pending-thread-creation";
31import {
32 useRemoteEnvironmentRuntime,
33 useSavedRemoteConnection,
34} from "./use-remote-environment-registry";
35import { useThreadOutboxMessages } from "./use-thread-outbox";
36type ThreadSelectionRouteParams = {
37 readonly environmentId?: string | string[];
38 readonly threadId?: string | string[];
39};
40
41// A blank param (a hand-typed deep link) is treated as missing, since branded
42// IDs reject whitespace-only values.
43function firstRouteParam(value: string | string[] | undefined): string | null {
44 const first = Array.isArray(value) ? value[0] : value;
45 return first === undefined || first.trim().length === 0 ? null : first;
46}
47
48function latestUserMessageAt(
49 projection: OrchestrationV2ThreadProjection,
50): OrchestrationV2ThreadShell["latestUserMessageAt"] {
51 for (let index = projection.messages.length - 1; index >= 0; index -= 1) {
52 const message = projection.messages[index];
53 if (message?.role === "user") {
54 return message.createdAt;
55 }
56 }
57
58 return null;
59}
60
61/**
62 * Builds an optimistic thread shell from the detail projection for the window
63 * where the shell list has not materialized the thread yet (e.g. a thread that
64 * was just created from this device).
65 */
66function threadDetailToShell(
67 environmentId: EnvironmentId,
68 projection: OrchestrationV2ThreadProjection,
69): EnvironmentThreadShell {
70 const thread = projection.thread;
71 const latestRun = deriveLatestThreadRun(projection);
72 const runtime = deriveThreadRuntime(projection);
73 const pendingRequest =
74 projection.runtimeRequests.find((request) => request.status === "pending") ?? null;
75 return presentThreadShell(environmentId, {
76 id: thread.id,
77 projectId: thread.projectId,
78 title: thread.title,
79 providerInstanceId: thread.providerInstanceId,
80 modelSelection: thread.modelSelection,
81 runtimeMode: thread.runtimeMode,
82 interactionMode: thread.interactionMode,
83 branch: thread.branch,
84 worktreePath: thread.worktreePath,
85 linkedPullRequest: thread.linkedPullRequest ?? null,
86 pullRequests: thread.pullRequests,
87 branchPullRequest: thread.branchPullRequest ?? null,
88 activeProviderThreadId: thread.activeProviderThreadId,
89 lineage: thread.lineage,
90 forkedFrom: thread.forkedFrom,
91 createdBy: thread.createdBy,
92 creationSource: thread.creationSource,
93 latestRunId: latestRun?.runId ?? null,
94 activeRunId: runtime?.activeRunId ?? null,
95 status: runtime?.status ?? "idle",
96 pendingRuntimeRequest:
97 pendingRequest === null
98 ? null
99 : { id: pendingRequest.id, kind: pendingRequest.kind, createdAt: pendingRequest.createdAt },
100 latestVisibleMessage: null,
101 latestUserMessageAt: latestUserMessageAt(projection),
102 hasActionableProposedPlan: false,
103 itemCount: projection.turnItems.length,
104 visibleItemCount: projection.visibleTurnItems.length,
105 createdAt: thread.createdAt,
106 updatedAt: thread.updatedAt,
107 archivedAt: thread.archivedAt,
108 settledOverride: thread.settledOverride,
109 settledAt: thread.settledAt,
110 unsettledAt: thread.unsettledAt,
111 activeOrderKey: thread.activeOrderKey,
112 autoSettleDisabledAt: thread.autoSettleDisabledAt,
113 pinnedAt: thread.pinnedAt,
114 pinOrderKey: thread.pinOrderKey,
115 snoozedUntil: thread.snoozedUntil ?? null,
116 snoozedAt: thread.snoozedAt ?? null,
117 deletedAt: thread.deletedAt,
118 });
119}
120
121function useResolvedThreadSelection(params: ThreadSelectionRouteParams | undefined) {
122 const routeParams = params ?? {};
123 const routeThreadRef = useMemo<ScopedThreadRef | null>(() => {
124 const environmentId = firstRouteParam(routeParams.environmentId);
125 const threadId = firstRouteParam(routeParams.threadId);
126 if (!environmentId || !threadId) {
127 return null;
128 }
129
130 return {
131 environmentId: EnvironmentId.make(environmentId),
132 threadId: ThreadId.make(threadId),
133 };
134 }, [routeParams.environmentId, routeParams.threadId]);
135 const lastRouteThreadRef = useRef<ScopedThreadRef | null>(null);
136 if (routeThreadRef !== null) {
137 lastRouteThreadRef.current = routeThreadRef;
138 }
139 const selectedThreadRef = routeThreadRef ?? lastRouteThreadRef.current;
140 const selectedThreadShell = useThreadShell(selectedThreadRef);
141 const selectedThreadKey =
142 selectedThreadRef === null
143 ? null
144 : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId);
145 const queuedMessagesByThreadKey = useThreadOutboxMessages();
146 const creationOutcome = useAtomValue(pendingThreadCreationOutcomesAtom);
147 // A creation the outbox still holds or just delivered: the thread screen
148 // opened before the server made the thread, so present a stand-in shell.
149 const pendingCreation = useMemo<PendingThreadCreation | null>(() => {
150 if (selectedThreadKey === null) {
151 return null;
152 }
153 const queued = queuedMessagesByThreadKey[selectedThreadKey]?.find(
154 (message) => message.creation !== undefined,
155 );
156 const outcome = creationOutcome[selectedThreadKey] ?? null;
157 const message = queued ?? outcome?.message ?? null;
158 return message === null ? null : { message, outcome };
159 }, [creationOutcome, queuedMessagesByThreadKey, selectedThreadKey]);
160 // Until the creation is delivered the server has no thread to subscribe
161 // to; subscribing anyway would retry "not found" for the whole setup.
162 const selectedThreadDetailRef =
163 selectedThreadShell !== null ||
164 pendingCreation === null ||
165 pendingCreation.outcome?.kind === "delivered"
166 ? selectedThreadRef
167 : null;
168 const [previousCreation, setPreviousCreation] = useState<PendingThreadCreation | null>(null);
169 // Normal selection is shell-only. Detail readers subscribe separately; only
170 // optimistic creation needs the projection here until its prompt arrives.
171 const needsDetail =
172 selectedThreadShell === null || pendingCreation !== null || previousCreation !== null;
173 const selectedThreadDetailState = useEnvironmentThread(
174 needsDetail ? (selectedThreadDetailRef?.environmentId ?? null) : null,
175 needsDetail ? (selectedThreadDetailRef?.threadId ?? null) : null,
176 );
177 const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data);
178 const selectedThread = useMemo(
179 () =>
180 selectedThreadShell ??
181 (selectedThreadRef !== null && selectedThreadDetail !== null
182 ? threadDetailToShell(selectedThreadRef.environmentId, selectedThreadDetail)
183 : pendingCreation !== null
184 ? pendingThreadCreationShell(pendingCreation.message)
185 : null),
186 [pendingCreation, selectedThreadDetail, selectedThreadRef, selectedThreadShell],
187 );
188 const selectedThreadCreation = resolvePendingThreadCreation({
189 threadKey: selectedThreadKey,
190 pending: pendingCreation,
191 previous: previousCreation,
192 detail: selectedThreadDetail,
193 });
194 if (previousCreation !== selectedThreadCreation) {
195 setPreviousCreation(selectedThreadCreation);
196 }
197 const selectedProjectRef = useMemo<ScopedProjectRef | null>(
198 () =>
199 selectedThread === null
200 ? null
201 : {
202 environmentId: selectedThread.environmentId,
203 projectId: selectedThread.projectId,
204 },
205 [selectedThread],
206 );
207 const selectedThreadProject = useProject(selectedProjectRef);
208 const selectedEnvironmentId = selectedThread?.environmentId ?? null;
209 const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId);
210 const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId);
211
212 return useMemo(
213 () => ({
214 selectedThreadRef,
215 selectedThread,
216 selectedThreadCreation,
217 selectedThreadDetailRef,
218 selectedThreadProject,
219 selectedEnvironmentConnection,
220 selectedEnvironmentRuntime,
221 }),
222 [
223 selectedEnvironmentConnection,
224 selectedEnvironmentRuntime,
225 selectedThread,
226 selectedThreadCreation,
227 selectedThreadDetailRef,
228 selectedThreadProject,
229 selectedThreadRef,
230 ],
231 );
232}
233
234type ThreadSelectionState = ReturnType<typeof useResolvedThreadSelection>;
235
236export function useThreadSelection(): ThreadSelectionState {
237 const route = useRoute<RouteProp<Record<string, ThreadSelectionRouteParams | undefined>>>();
238 return useResolvedThreadSelection(route.params);
239}