1import * as Dialog from "../components/ui/Dialog";
2import { useServerConfigs } from "./entities";
3import {
4 questionAttachmentDraftKey,
5 questionAttachmentDraftPrefix,
6 questionAttachmentPreparationAtom,
7} from "./question-attachments";
8import {
9 clearComposerDraft,
10 composerDraftsAtom,
11 questionAttachmentDraftsAtom,
12} from "./use-composer-drafts";
13import {
14 composerAttachmentUploadsAtom,
15 composerAttachmentUploadBlockReason,
16 composerAttachmentsStillUploading,
17} from "./composer-attachment-uploads";
18import { useAtomValue } from "@effect/atom-react";
19import { useRef, useState } from "react";
20
21import {
22 type EnvironmentId,
23 type ProviderApprovalDecision,
24 type RuntimeRequestId,
25 type ThreadId,
26} from "@t3tools/contracts";
27import {
28 type PendingThreadRequests,
29 type ThreadUserInputQuestion,
30 onThreadRequestsResolved,
31 pruneResolvedUserInputDrafts,
32 resolvedQuestionDraftKeys,
33 seedUserInputDraftAnswers,
34 userInputDraftKey,
35} from "@t3tools/client-runtime/state/thread-requests";
36import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
37import { Atom } from "effect/reactivity";
38
39import { environmentThreadDetails, threadEnvironment } from "./threads";
40import {
41 buildPendingUserInputAnswers,
42 setPendingUserInputCustomAnswer,
43 togglePendingUserInputOptionSelection,
44 type PendingUserInputDraftAnswer,
45} from "@t3tools/client-runtime/state/pending-user-input";
46import { appAtomRegistry } from "./atom-registry";
47import { useSelectedThreadPendingRequests } from "./use-thread-detail";
48import { useThreadSelection } from "./use-thread-selection";
49import { useAtomCommand } from "./use-atom-command";
50import { readEnvironmentScope } from "./session";
51
52const EMPTY_PENDING_REQUESTS: PendingThreadRequests = { approvals: [], userInputs: [] };
53
54const userInputDraftsByRequestKeyAtom = Atom.make<
55 Record<string, Record<string, PendingUserInputDraftAnswer>>
56>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts"));
57
58// Writes start from the suggested answer the user was looking at.
59function seededDraft(
60 drafts: Record<string, PendingUserInputDraftAnswer> | undefined,
61 question: ThreadUserInputQuestion,
62): PendingUserInputDraftAnswer | undefined {
63 return seedUserInputDraftAnswers([question], drafts ?? {})[question.id];
64}
65
66function setUserInputDraftOption(
67 requestKey: string,
68 question: ThreadUserInputQuestion,
69 value: string,
70): void {
71 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
72 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
73 ...current,
74 [requestKey]: {
75 ...current[requestKey],
76 [question.id]: togglePendingUserInputOptionSelection(
77 question,
78 seededDraft(current[requestKey], question),
79 value,
80 ),
81 },
82 });
83}
84
85function setUserInputDraftCustomAnswer(
86 requestKey: string,
87 question: ThreadUserInputQuestion,
88 customAnswer: string,
89): void {
90 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
91 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
92 ...current,
93 [requestKey]: {
94 ...current[requestKey],
95 [question.id]: setPendingUserInputCustomAnswer(
96 question,
97 seededDraft(current[requestKey], question),
98 customAnswer,
99 ),
100 },
101 });
102}
103
104/** Drops a thread's answer and attachment drafts for every request outside `pendingRequestIds`. */
105function pruneResolvedRequestDrafts(
106 environmentId: EnvironmentId,
107 threadId: ThreadId,
108 pendingRequestIds: ReadonlyArray<RuntimeRequestId>,
109): void {
110 const drafts = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
111 const prunedDrafts = pruneResolvedUserInputDrafts(
112 drafts,
113 environmentId,
114 threadId,
115 pendingRequestIds,
116 );
117 if (prunedDrafts !== drafts) appAtomRegistry.set(userInputDraftsByRequestKeyAtom, prunedDrafts);
118
119 const prefix = questionAttachmentDraftPrefix(environmentId, threadId);
120 const attachmentDrafts = appAtomRegistry.get(questionAttachmentDraftsAtom(prefix));
121 const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) };
122 let changed = false;
123 for (const key of resolvedQuestionDraftKeys(
124 new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)]),
125 prefix,
126 pendingRequestIds,
127 )) {
128 if (attachmentDrafts[key]) clearComposerDraft(key);
129 if (key in counts) {
130 delete counts[key];
131 changed = true;
132 }
133 }
134 if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts);
135}
136
137// Resolution can come from anywhere: another device, an interrupt, the turn
138// ending, or a reload over drafts for requests that resolved meanwhile.
139onThreadRequestsResolved(({ environmentId, threadId, pendingRequestIds }) =>
140 pruneResolvedRequestDrafts(environmentId, threadId, pendingRequestIds),
141);
142
143/**
144 * Drops `answeredRequestId`'s drafts as soon as the server accepts the
145 * response, before the projection push that resolves it lands.
146 */
147function collectAnsweredRequestDrafts(
148 environmentId: EnvironmentId,
149 threadId: ThreadId,
150 answeredRequestId: RuntimeRequestId,
151) {
152 const current = appAtomRegistry.get(
153 environmentThreadDetails.pendingRequestsAtom({ environmentId, threadId }),
154 );
155 // Missing detail is not resolution.
156 if (current === null) return;
157 pruneResolvedRequestDrafts(
158 environmentId,
159 threadId,
160 [...current.approvals, ...current.userInputs]
161 .map((request) => request.requestId)
162 .filter((requestId) => requestId !== answeredRequestId),
163 );
164}
165
166export function useSelectedThreadRequests() {
167 const respondToApproval = useAtomCommand(
168 threadEnvironment.respondToApproval,
169 "thread approval response",
170 );
171 const respondToUserInput = useAtomCommand(
172 threadEnvironment.respondToUserInput,
173 "thread user input response",
174 );
175 const dismissUserInput = useAtomCommand(
176 threadEnvironment.dismissUserInput,
177 "thread user input dismissal",
178 );
179 const { selectedThread: selectedThreadShell } = useThreadSelection();
180 const pendingRequests = useSelectedThreadPendingRequests();
181 const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom);
182 const userInputResponsesInFlight = useRef(new Set<string>());
183 const [respondingApprovalId, setRespondingApprovalId] = useState<RuntimeRequestId | null>(null);
184 const [respondingUserInputId, setRespondingUserInputId] = useState<RuntimeRequestId | null>(null);
185
186 const activePendingApprovals = pendingRequests?.approvals ?? EMPTY_PENDING_REQUESTS.approvals;
187 const activePendingApproval = activePendingApprovals[0] ?? null;
188 const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs;
189 const activePendingUserInput = activePendingUserInputs[0] ?? null;
190 const questionServerConfigs = useServerConfigs();
191 const attachmentDrafts = useAtomValue(
192 questionAttachmentDraftsAtom(
193 selectedThreadShell
194 ? questionAttachmentDraftPrefix(selectedThreadShell.environmentId, selectedThreadShell.id)
195 : "",
196 ),
197 );
198 const preparationCounts = useAtomValue(questionAttachmentPreparationAtom);
199 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
200 // Suggested answers are shown until the user edits them; only edits are stored.
201 const activePendingUserInputAnswerDrafts =
202 activePendingUserInput && selectedThreadShell
203 ? seedUserInputDraftAnswers(
204 activePendingUserInput.questions,
205 userInputDraftsByRequestKey[
206 userInputDraftKey(
207 selectedThreadShell.environmentId,
208 selectedThreadShell.id,
209 activePendingUserInput.requestId,
210 )
211 ] ?? {},
212 )
213 : {};
214 const activePendingUserInputDrafts =
215 activePendingUserInput && selectedThreadShell
216 ? Object.fromEntries(
217 activePendingUserInput.questions.map((question) => {
218 const key = questionAttachmentDraftKey(
219 selectedThreadShell.environmentId,
220 selectedThreadShell.id,
221 activePendingUserInput.requestId,
222 question.id,
223 );
224 const attachments = attachmentDrafts[key]?.attachments ?? [];
225 const uploadInput = {
226 environmentId: selectedThreadShell.environmentId,
227 attachments,
228 serverConfig: questionServerConfigs.get(selectedThreadShell.environmentId) ?? null,
229 states: uploadStates,
230 };
231 return [
232 question.id,
233 {
234 ...activePendingUserInputAnswerDrafts[question.id],
235 attachmentCount: attachments.length,
236 attachmentsBlocked:
237 (attachments.length > 0 &&
238 questionServerConfigs.get(selectedThreadShell.environmentId) === undefined) ||
239 (preparationCounts[key] ?? 0) > 0 ||
240 composerAttachmentsStillUploading(uploadInput) ||
241 composerAttachmentUploadBlockReason({
242 ...uploadInput,
243 connected: true,
244 }) !== null,
245 },
246 ];
247 }),
248 )
249 : {};
250 const activePendingUserInputAnswers = activePendingUserInput
251 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts)
252 : null;
253
254 const onSelectUserInputOption = (
255 requestId: RuntimeRequestId,
256 question: ThreadUserInputQuestion,
257 value: string,
258 ) => {
259 if (!selectedThreadShell) {
260 return;
261 }
262
263 const requestKey = userInputDraftKey(
264 selectedThreadShell.environmentId,
265 selectedThreadShell.id,
266 requestId,
267 );
268 setUserInputDraftOption(requestKey, question, value);
269 };
270
271 const onChangeUserInputCustomAnswer = (
272 requestId: RuntimeRequestId,
273 questionId: string,
274 customAnswer: string,
275 ) => {
276 const question = activePendingUserInputs
277 .find((request) => request.requestId === requestId)
278 ?.questions.find((entry) => entry.id === questionId);
279 if (!selectedThreadShell || !question) {
280 return;
281 }
282
283 const requestKey = userInputDraftKey(
284 selectedThreadShell.environmentId,
285 selectedThreadShell.id,
286 requestId,
287 );
288 setUserInputDraftCustomAnswer(requestKey, question, customAnswer);
289 };
290
291 const onRespondToApproval = async (
292 requestId: RuntimeRequestId,
293 decision: ProviderApprovalDecision,
294 feedback?: string,
295 ) => {
296 if (
297 !selectedThreadShell ||
298 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
299 ) {
300 return;
301 }
302 if (
303 activePendingApprovals.find((approval) => approval.requestId === requestId)
304 ?.responseCapability !== "live"
305 ) {
306 return;
307 }
308
309 setRespondingApprovalId(requestId);
310 const result = await respondToApproval({
311 environmentId: selectedThreadShell.environmentId,
312 input: {
313 threadId: selectedThreadShell.id,
314 requestId,
315 decision,
316 ...(feedback === undefined ? {} : { feedback }),
317 },
318 });
319 setRespondingApprovalId((current) => (current === requestId ? null : current));
320 return result;
321 };
322
323 const onSubmitUserInput = async () => {
324 if (
325 !selectedThreadShell ||
326 !activePendingUserInput ||
327 activePendingUserInput.responseCapability === "not_resumable" ||
328 !activePendingUserInputAnswers ||
329 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
330 ) {
331 return;
332 }
333
334 const responseKey = questionAttachmentDraftKey(
335 selectedThreadShell.environmentId,
336 selectedThreadShell.id,
337 activePendingUserInput.requestId,
338 "",
339 );
340 if (userInputResponsesInFlight.current.has(responseKey)) return;
341 const attachmentsByQuestionId = new Map<
342 string,
343 import("@t3tools/contracts").UserInputAttachments[string]
344 >();
345 for (const question of activePendingUserInput.questions) {
346 const key = questionAttachmentDraftKey(
347 selectedThreadShell.environmentId,
348 selectedThreadShell.id,
349 activePendingUserInput.requestId,
350 question.id,
351 );
352 if ((appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0) return;
353 const attachments = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments ?? [];
354 if (attachments.length === 0) continue;
355 if (
356 attachments.some(
357 (attachment) =>
358 !attachment.uploadedAttachmentId ||
359 attachment.uploadEnvironmentId !== selectedThreadShell.environmentId,
360 )
361 ) {
362 Dialog.alert(
363 "Attachments are not ready",
364 "Wait for uploads to finish, or retry failed uploads.",
365 );
366 return;
367 }
368 attachmentsByQuestionId.set(
369 question.id,
370 attachments.map((attachment) => ({
371 type: attachment.type,
372 id: attachment.uploadedAttachmentId!,
373 name: attachment.name,
374 mimeType: attachment.mimeType,
375 sizeBytes: attachment.sizeBytes,
376 })),
377 );
378 }
379 userInputResponsesInFlight.current.add(responseKey);
380 setRespondingUserInputId(activePendingUserInput.requestId);
381 const result = await respondToUserInput({
382 environmentId: selectedThreadShell.environmentId,
383 input: {
384 threadId: selectedThreadShell.id,
385 requestId: activePendingUserInput.requestId,
386 answers: activePendingUserInputAnswers,
387 ...(attachmentsByQuestionId.size > 0
388 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
389 : {}),
390 },
391 });
392 userInputResponsesInFlight.current.delete(responseKey);
393 setRespondingUserInputId((current) =>
394 current === activePendingUserInput.requestId ? null : current,
395 );
396 if (result._tag === "Success") {
397 collectAnsweredRequestDrafts(
398 selectedThreadShell.environmentId,
399 selectedThreadShell.id,
400 activePendingUserInput.requestId,
401 );
402 }
403 return result;
404 };
405
406 const onDismissUserInput = async () => {
407 if (
408 !selectedThreadShell ||
409 !activePendingUserInput ||
410 !readEnvironmentScope(selectedThreadShell.environmentId, AuthOrchestrationOperateScope)
411 ) {
412 return;
413 }
414
415 setRespondingUserInputId(activePendingUserInput.requestId);
416 const result = await dismissUserInput({
417 environmentId: selectedThreadShell.environmentId,
418 input: {
419 threadId: selectedThreadShell.id,
420 requestId: activePendingUserInput.requestId,
421 },
422 });
423 setRespondingUserInputId((current) =>
424 current === activePendingUserInput.requestId ? null : current,
425 );
426 if (result._tag === "Success") {
427 collectAnsweredRequestDrafts(
428 selectedThreadShell.environmentId,
429 selectedThreadShell.id,
430 activePendingUserInput.requestId,
431 );
432 }
433 return result;
434 };
435
436 return {
437 activePendingApproval,
438 activePendingApprovals,
439 activePendingUserInput,
440 activePendingUserInputDrafts,
441 activePendingUserInputAnswers,
442 respondingApprovalId,
443 respondingUserInputId,
444 onRespondToApproval,
445 onSelectUserInputOption,
446 onChangeUserInputCustomAnswer,
447 onSubmitUserInput,
448 onDismissUserInput,
449 };
450}