1import { useServerConfigs } from "./entities";
2import { Alert } from "react-native";
3import {
4 questionAttachmentDraftKey,
5 questionAttachmentDraftPrefix,
6 questionAttachmentPreparationAtom,
7} from "./question-attachments";
8import { composerDraftsAtom, clearComposerDraft } from "./use-composer-drafts";
9import {
10 composerAttachmentUploadsAtom,
11 composerAttachmentUploadBlockReason,
12 composerAttachmentsStillUploading,
13} from "./composer-attachment-uploads";
14import { useAtomValue } from "@effect/atom-react";
15import { useCallback, useEffect, useRef, useState } from "react";
16
17import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/contracts";
18import {
19 type PendingThreadRequests,
20 type ThreadUserInputQuestion,
21 seedUserInputDraftAnswers,
22} from "@t3tools/client-runtime/state/thread-requests";
23import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
24import { Atom } from "effect/reactivity";
25
26import { threadEnvironment } from "../state/threads";
27import { scopedRequestKey } from "../lib/scopedEntities";
28import {
29 buildPendingUserInputAnswers,
30 setPendingUserInputCustomAnswer,
31 togglePendingUserInputOptionSelection,
32 type PendingUserInputDraftAnswer,
33} from "../lib/threadActivity";
34import { appAtomRegistry } from "./atom-registry";
35import { useSelectedThreadPendingRequests } from "./use-thread-detail";
36import { useThreadSelection } from "./use-thread-selection";
37import { useAtomCommand } from "./use-atom-command";
38import { readEnvironmentScope } from "./session";
39
40const EMPTY_PENDING_REQUESTS: PendingThreadRequests = { approvals: [], userInputs: [] };
41
42const userInputDraftsByRequestKeyAtom = Atom.make<
43 Record<string, Record<string, PendingUserInputDraftAnswer>>
44>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts"));
45
46function setUserInputDraftOption(
47 requestKey: string,
48 question: ThreadUserInputQuestion,
49 value: string,
50): void {
51 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
52 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
53 ...current,
54 [requestKey]: {
55 ...current[requestKey],
56 [question.id]: togglePendingUserInputOptionSelection(
57 question,
58 current[requestKey]?.[question.id],
59 value,
60 ),
61 },
62 });
63}
64
65function setUserInputDraftCustomAnswer(
66 requestKey: string,
67 question: ThreadUserInputQuestion,
68 customAnswer: string,
69): void {
70 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
71 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
72 ...current,
73 [requestKey]: {
74 ...current[requestKey],
75 [question.id]: setPendingUserInputCustomAnswer(
76 question,
77 current[requestKey]?.[question.id],
78 customAnswer,
79 ),
80 },
81 });
82}
83
84export function useSelectedThreadRequests() {
85 const respondToApproval = useAtomCommand(
86 threadEnvironment.respondToApproval,
87 "thread approval response",
88 );
89 const respondToUserInput = useAtomCommand(
90 threadEnvironment.respondToUserInput,
91 "thread user input response",
92 );
93 const dismissUserInput = useAtomCommand(
94 threadEnvironment.dismissUserInput,
95 "thread user input dismissal",
96 );
97 const { selectedThread: selectedThreadShell } = useThreadSelection();
98 const pendingRequests = useSelectedThreadPendingRequests();
99 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
100 const userInputResponsesInFlight = useRef(new Set<string>());
101 const [respondingApprovalId, setRespondingApprovalId] = useState<RuntimeRequestId | null>(null);
102 const [respondingUserInputId, setRespondingUserInputId] = useState<RuntimeRequestId | null>(null);
103
104 const activePendingApprovals = pendingRequests?.approvals ?? EMPTY_PENDING_REQUESTS.approvals;
105 const activePendingApproval = activePendingApprovals[0] ?? null;
106 const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs;
107 const activePendingUserInput = activePendingUserInputs[0] ?? null;
108 useEffect(() => {
109 if (!activePendingUserInput || !selectedThreadShell) return;
110 const requestKey = scopedRequestKey(
111 selectedThreadShell.environmentId,
112 activePendingUserInput.requestId,
113 );
114 const existing = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
115 const drafts = existing[requestKey] ?? {};
116 const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts);
117 if (seeded !== drafts) {
118 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...existing, [requestKey]: seeded });
119 }
120 }, [activePendingUserInput, selectedThreadShell]);
121 const questionServerConfigs = useServerConfigs();
122 const attachmentDrafts = useAtomValue(composerDraftsAtom);
123 const preparationCounts = useAtomValue(questionAttachmentPreparationAtom);
124 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
125 useEffect(() => {
126 if (!selectedThreadShell || !pendingRequests) return;
127 const prefix = questionAttachmentDraftPrefix(
128 selectedThreadShell.environmentId,
129 selectedThreadShell.id,
130 );
131 const retained = new Set(
132 activePendingUserInputs.flatMap((request) =>
133 request.questions.map((question) =>
134 questionAttachmentDraftKey(
135 selectedThreadShell.environmentId,
136 selectedThreadShell.id,
137 request.requestId,
138 question.id,
139 ),
140 ),
141 ),
142 );
143 const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) };
144 let changed = false;
145 for (const key of new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)])) {
146 if (!key.startsWith(prefix) || retained.has(key)) continue;
147 if (attachmentDrafts[key]) clearComposerDraft(key);
148 if (key in counts) {
149 delete counts[key];
150 changed = true;
151 }
152 }
153 if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts);
154 }, [activePendingUserInputs, attachmentDrafts, pendingRequests, selectedThreadShell]);
155 const activePendingUserInputDrafts =
156 activePendingUserInput && selectedThreadShell
157 ? Object.fromEntries(
158 activePendingUserInput.questions.map((question) => {
159 const key = questionAttachmentDraftKey(
160 selectedThreadShell.environmentId,
161 selectedThreadShell.id,
162 activePendingUserInput.requestId,
163 question.id,
164 );
165 const attachments = attachmentDrafts[key]?.attachments ?? [];
166 const uploadInput = {
167 environmentId: selectedThreadShell.environmentId,
168 attachments,
169 serverConfig: questionServerConfigs.get(selectedThreadShell.environmentId) ?? null,
170 states: uploadStates,
171 };
172 return [
173 question.id,
174 {
175 ...userInputDraftsByRequestKey[
176 scopedRequestKey(
177 selectedThreadShell.environmentId,
178 activePendingUserInput.requestId,
179 )
180 ]?.[question.id],
181 attachmentCount: attachments.length,
182 attachmentsBlocked:
183 (attachments.length > 0 &&
184 questionServerConfigs.get(selectedThreadShell.environmentId)?.environment
185 .capabilities.questionAttachments !== true) ||
186 (preparationCounts[key] ?? 0) > 0 ||
187 composerAttachmentsStillUploading(uploadInput) ||
188 composerAttachmentUploadBlockReason({
189 ...uploadInput,
190 connected: true,
191 }) !== null,
192 },
193 ];
194 }),
195 )
196 : {};
197 const activePendingUserInputAnswers = activePendingUserInput
198 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts)
199 : null;
200
201 const onSelectUserInputOption = useCallback(
202 (requestId: RuntimeRequestId, question: ThreadUserInputQuestion, value: string) => {
203 if (!selectedThreadShell) {
204 return;
205 }
206
207 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
208 setUserInputDraftOption(requestKey, question, value);
209 },
210 [selectedThreadShell],
211 );
212
213 const onChangeUserInputCustomAnswer = useCallback(
214 (requestId: RuntimeRequestId, questionId: string, customAnswer: string) => {
215 const question = activePendingUserInputs
216 .find((request) => request.requestId === requestId)
217 ?.questions.find((entry) => entry.id === questionId);
218 if (!selectedThreadShell || !question) {
219 return;
220 }
221
222 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
223 setUserInputDraftCustomAnswer(requestKey, question, customAnswer);
224 },
225 [activePendingUserInputs, selectedThreadShell],
226 );
227
228 const onRespondToApproval = useCallback(
229 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
230 if (
231 !selectedThreadShell ||
232 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
233 ) {
234 return;
235 }
236 if (
237 activePendingApprovals.find((approval) => approval.requestId === requestId)
238 ?.responseCapability !== "live"
239 ) {
240 return;
241 }
242
243 setRespondingApprovalId(requestId);
244 const result = await respondToApproval({
245 environmentId: selectedThreadShell.environmentId,
246 input: {
247 threadId: selectedThreadShell.id,
248 requestId,
249 decision,
250 },
251 });
252 setRespondingApprovalId((current) => (current === requestId ? null : current));
253 return result;
254 },
255 [activePendingApprovals, respondToApproval, selectedThreadShell],
256 );
257
258 const onSubmitUserInput = useCallback(async () => {
259 if (
260 !selectedThreadShell ||
261 !activePendingUserInput ||
262 activePendingUserInput.responseCapability === "not_resumable" ||
263 !activePendingUserInputAnswers ||
264 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
265 ) {
266 return;
267 }
268
269 const responseKey = questionAttachmentDraftKey(
270 selectedThreadShell.environmentId,
271 selectedThreadShell.id,
272 activePendingUserInput.requestId,
273 "",
274 );
275 if (userInputResponsesInFlight.current.has(responseKey)) return;
276 const attachmentsByQuestionId = new Map<
277 string,
278 import("@t3tools/contracts").UserInputAttachments[string]
279 >();
280 for (const question of activePendingUserInput.questions) {
281 const key = questionAttachmentDraftKey(
282 selectedThreadShell.environmentId,
283 selectedThreadShell.id,
284 activePendingUserInput.requestId,
285 question.id,
286 );
287 if ((appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0) return;
288 const attachments = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments ?? [];
289 if (attachments.length === 0) continue;
290 if (
291 attachments.some(
292 (attachment) =>
293 !attachment.uploadedAttachmentId ||
294 attachment.uploadEnvironmentId !== selectedThreadShell.environmentId,
295 )
296 ) {
297 Alert.alert(
298 "Attachments are not ready",
299 "Wait for uploads to finish, or retry failed uploads.",
300 );
301 return;
302 }
303 attachmentsByQuestionId.set(
304 question.id,
305 attachments.map((attachment) => ({
306 type: attachment.type,
307 id: attachment.uploadedAttachmentId!,
308 name: attachment.name,
309 mimeType: attachment.mimeType,
310 sizeBytes: attachment.sizeBytes,
311 })),
312 );
313 }
314 userInputResponsesInFlight.current.add(responseKey);
315 setRespondingUserInputId(activePendingUserInput.requestId);
316 const result = await respondToUserInput({
317 environmentId: selectedThreadShell.environmentId,
318 input: {
319 threadId: selectedThreadShell.id,
320 requestId: activePendingUserInput.requestId,
321 answers: activePendingUserInputAnswers,
322 ...(attachmentsByQuestionId.size > 0
323 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
324 : {}),
325 },
326 });
327 userInputResponsesInFlight.current.delete(responseKey);
328 setRespondingUserInputId((current) =>
329 current === activePendingUserInput.requestId ? null : current,
330 );
331 return result;
332 }, [
333 activePendingUserInput,
334 activePendingUserInputAnswers,
335 respondToUserInput,
336 selectedThreadShell,
337 ]);
338
339 // Closes an async question without messaging the agent.
340 const onDismissUserInput = useCallback(async () => {
341 if (
342 !selectedThreadShell ||
343 !activePendingUserInput ||
344 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
345 ) {
346 return;
347 }
348
349 setRespondingUserInputId(activePendingUserInput.requestId);
350 const result = await dismissUserInput({
351 environmentId: selectedThreadShell.environmentId,
352 input: {
353 threadId: selectedThreadShell.id,
354 requestId: activePendingUserInput.requestId,
355 },
356 });
357 setRespondingUserInputId((current) =>
358 current === activePendingUserInput.requestId ? null : current,
359 );
360 return result;
361 }, [activePendingUserInput, dismissUserInput, selectedThreadShell]);
362
363 return {
364 activePendingApproval,
365 activePendingUserInput,
366 activePendingUserInputDrafts,
367 activePendingUserInputAnswers,
368 respondingApprovalId,
369 respondingUserInputId,
370 onRespondToApproval,
371 onSelectUserInputOption,
372 onChangeUserInputCustomAnswer,
373 onSubmitUserInput,
374 onDismissUserInput,
375 };
376}