Open History to see the last commit
1import type { ThreadStructure } from "@t3tools/client-runtime/state/shell";
2import {
3 AuthOrchestrationOperateScope,
4 type EnvironmentId,
5 type ProviderApprovalDecision,
6 type RuntimeRequestId,
7 type ScopedThreadRef,
8 type ThreadId,
9 type UserInputAttachments,
10} from "@t3tools/contracts";
11import {
12 derivePendingThreadRequests,
13 seedUserInputDraftAnswers,
14 userInputDraftKey,
15} from "@t3tools/client-runtime/state/thread-requests";
16import {
17 isAtomCommandInterrupted,
18 squashAtomCommandFailure,
19} from "@t3tools/client-runtime/state/runtime";
20import { useRef, useState, type RefObject, type SetStateAction } from "react";
21import { useShallow } from "zustand/react/shallow";
22import { type DraftId, useComposerDraftStore } from "../../composerDraftStore";
23import { useStableCallback } from "../../hooks/useStableCallback";
24import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue";
25import { reviewCommentContextRecord } from "../../lib/composerContextRecords";
26import {
27 buildPendingUserInputAnswers,
28 setPendingUserInputCustomAnswer,
29 togglePendingUserInputOptionSelection,
30 type PendingUserInputDraftAnswer,
31} from "@t3tools/client-runtime/state/pending-user-input";
32import {
33 carryDisplacedCustomAnswerIntoPrompt,
34 withdrawDisplacedCustomAnswerFromPrompt,
35 derivePendingUserInputProgress,
36 projectPendingUserInputAnswer,
37 threadContextsReferencedByAnswer,
38} from "../../pendingUserInput";
39import {
40 questionAttachmentDraftId,
41 usePendingUserInputAnswers,
42 usePendingUserInputQuestionIndex,
43 useQuestionAttachmentPreparation,
44} from "../../questionAttachments";
45import {
46 derivePendingApprovals,
47 derivePendingUserInputs,
48 type PendingUserInput,
49} from "../../session-logic";
50import { readEnvironmentScope } from "../../state/session";
51import { threadEnvironment } from "../../state/threads";
52import { useAtomCommand } from "../../state/use-atom-command";
53import { useOrchestrationCommand } from "../../state/use-orchestration-command";
54import { toastManager } from "../ui/toast";
55
56import type { ChatComposerHandle } from "./ChatComposer";
57
58const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
59const NO_PENDING_REQUESTS = { approvals: [], userInputs: [] } as const;
60const NO_PENDING_APPROVALS: ReturnType<typeof derivePendingApprovals> = [];
61const NO_PENDING_USER_INPUTS: ReturnType<typeof derivePendingUserInputs> = [];
62
63function makePendingCustomAnswerSetter(input: {
64 readonly activePendingUserInput: PendingUserInput | null;
65 readonly activePendingRequestKey: string;
66 readonly writePrompt: (prompt: string) => void;
67 readonly withdrawRestoredAnswer: (questionId: string, value: string) => void;
68 readonly setPendingUserInputAnswersByRequestId: (
69 update: SetStateAction<Record<string, Record<string, PendingUserInputDraftAnswer>>>,
70 ) => void;
71}) {
72 return (questionId: string, value: string) => {
73 const { activePendingUserInput, activePendingRequestKey } = input;
74 if (!activePendingUserInput) {
75 return;
76 }
77 const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
78 if (!question || question.allowCustomAnswer === false) {
79 return;
80 }
81 input.writePrompt(value);
82 input.withdrawRestoredAnswer(questionId, value);
83 input.setPendingUserInputAnswersByRequestId((existing) => ({
84 ...existing,
85 [activePendingRequestKey]: {
86 ...existing[activePendingRequestKey],
87 [questionId]: setPendingUserInputCustomAnswer(
88 question,
89 existing[activePendingRequestKey]?.[questionId],
90 value,
91 ),
92 },
93 }));
94 // Resetting selection before rendering can re-enter Lexical with stale skill-token offsets.
95 };
96}
97
98export function usePendingThreadRequests(input: {
99 readonly environmentId: EnvironmentId;
100 readonly activeThreadId: ThreadId | null;
101 readonly serverProjection: ThreadStructure | null;
102 readonly supportsQuestionAttachments: boolean;
103 readonly composerDraftTarget: ScopedThreadRef | DraftId;
104 readonly setComposerDraftPrompt: (target: ScopedThreadRef | DraftId, prompt: string) => void;
105 readonly promptRef: { current: string };
106 readonly composerRef: RefObject<ChatComposerHandle | null>;
107 readonly setThreadError: (threadId: ThreadId, error: string | null) => void;
108}) {
109 const {
110 environmentId,
111 activeThreadId,
112 serverProjection,
113 supportsQuestionAttachments,
114 composerDraftTarget,
115 setComposerDraftPrompt,
116 promptRef,
117 composerRef,
118 setThreadError,
119 } = input;
120 const respondToThreadApproval = useOrchestrationCommand(threadEnvironment.respondToApproval, {
121 reportFailure: false,
122 });
123 const respondToThreadUserInput = useOrchestrationCommand(threadEnvironment.respondToUserInput, {
124 reportFailure: false,
125 });
126 const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, {
127 reportFailure: false,
128 });
129 const userInputResponsesInFlight = useRef(new Set<string>());
130 // In the order the text went into the thread draft, which is how a copy is found again.
131 const displacedAnswers = useRef<
132 Array<{ readonly draft: string; readonly key: string; readonly text: string }>
133 >([]);
134 // This hook outlives a thread switch, and each thread has its own draft.
135 const displacedDraftKey =
136 typeof composerDraftTarget === "string"
137 ? composerDraftTarget
138 : `${composerDraftTarget.environmentId}:${composerDraftTarget.threadId}`;
139 const [respondingRequestIds, setRespondingRequestIds] = useState<RuntimeRequestId[]>([]);
140 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
141 RuntimeRequestId[]
142 >([]);
143 const setPendingUserInputAnswersByRequestId = usePendingUserInputAnswers.setState;
144 const setPendingUserInputQuestionIndexByRequestId = usePendingUserInputQuestionIndex.setState;
145 const pendingRequests =
146 serverProjection !== null && serverProjection.thread.id === activeThreadId
147 ? derivePendingThreadRequests(serverProjection)
148 : NO_PENDING_REQUESTS;
149 // The projection changes on every streamed event, so shared empties keep the
150 // composer and approval panel from re-rendering while nothing is pending.
151 const pendingApprovals =
152 pendingRequests.approvals.length === 0
153 ? NO_PENDING_APPROVALS
154 : derivePendingApprovals(pendingRequests.approvals);
155 const pendingUserInputs =
156 pendingRequests.userInputs.length === 0
157 ? NO_PENDING_USER_INPUTS
158 : derivePendingUserInputs(pendingRequests.userInputs);
159 const activePendingUserInput = pendingUserInputs[0] ?? null;
160 const activePendingRequestKey = userInputDraftKey(
161 environmentId,
162 activeThreadId,
163 activePendingUserInput?.requestId,
164 );
165 const storedPendingAnswerDrafts = usePendingUserInputAnswers(
166 (state) => state[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS,
167 );
168 // Suggested answers are shown until the user edits them; only edits are stored.
169 const activePendingAnswerDrafts: Record<string, PendingUserInputDraftAnswer> =
170 activePendingUserInput
171 ? seedUserInputDraftAnswers(activePendingUserInput.questions, storedPendingAnswerDrafts)
172 : storedPendingAnswerDrafts;
173 const activePendingQuestionIndex = usePendingUserInputQuestionIndex(
174 (state) => state[activePendingRequestKey] ?? 0,
175 );
176 const pendingQuestionDraftKeys = activeThreadId
177 ? pendingUserInputs.flatMap((request) =>
178 request.questions.map((question) =>
179 questionAttachmentDraftId(environmentId, activeThreadId, request.requestId, question.id),
180 ),
181 )
182 : [];
183 const questionComposerDrafts = useComposerDraftStore(
184 useShallow((state) =>
185 Object.fromEntries(
186 pendingQuestionDraftKeys.map((key) => [key, state.draftsByThreadKey[key]]),
187 ),
188 ),
189 );
190 const questionUploadsBlocked = useAttachmentUploadStore(
191 useShallow((state) =>
192 Object.fromEntries(
193 pendingQuestionDraftKeys.map((key) => {
194 const draft = questionComposerDrafts[key];
195 const attachments = draft ? [...draft.images, ...draft.files] : [];
196 return [
197 key,
198 attachments.some((attachment) => {
199 const upload = state.uploadsByImageId[attachment.id];
200 return upload?.status !== "ready" || upload.environmentId !== environmentId;
201 }),
202 ];
203 }),
204 ),
205 ),
206 );
207 const questionPreparations = useQuestionAttachmentPreparation(
208 useShallow((state) =>
209 Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])),
210 ),
211 );
212 const activePendingDraftAnswers =
213 !activePendingUserInput || !activeThreadId
214 ? EMPTY_PENDING_USER_INPUT_ANSWERS
215 : Object.fromEntries(
216 activePendingUserInput.questions.map((question) => {
217 const key = questionAttachmentDraftId(
218 environmentId,
219 activeThreadId,
220 activePendingUserInput.requestId,
221 question.id,
222 );
223 const draft = questionComposerDrafts[key];
224 const attachments = draft ? [...draft.images, ...draft.files] : [];
225 return [
226 question.id,
227 {
228 ...activePendingAnswerDrafts[question.id],
229 attachmentCount: attachments.length,
230 attachmentsBlocked:
231 (attachments.length > 0 && !supportsQuestionAttachments) ||
232 (questionPreparations[key] ?? 0) > 0 ||
233 questionUploadsBlocked[key] === true,
234 },
235 ];
236 }),
237 );
238 const activePendingProgress = activePendingUserInput
239 ? derivePendingUserInputProgress(
240 activePendingUserInput.questions,
241 activePendingDraftAnswers,
242 activePendingQuestionIndex,
243 )
244 : null;
245 const activePendingResolvedAnswers = activePendingUserInput
246 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers)
247 : null;
248 const activePendingIsResponding = activePendingUserInput
249 ? activePendingUserInput.responseCapability === "not_resumable" ||
250 respondingUserInputRequestIds.includes(activePendingUserInput.requestId)
251 : false;
252 const activePendingApproval = pendingApprovals[0] ?? null;
253
254 const onRespondToApproval = async (
255 requestId: RuntimeRequestId,
256 decision: ProviderApprovalDecision,
257 feedback?: string,
258 ) => {
259 if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
260 return;
261 if (
262 pendingApprovals.find((approval) => approval.requestId === requestId)?.responseCapability !==
263 "live"
264 )
265 return;
266
267 setRespondingRequestIds((existing) =>
268 existing.includes(requestId) ? existing : [...existing, requestId],
269 );
270 const result = await respondToThreadApproval({
271 environmentId,
272 input: {
273 threadId: activeThreadId,
274 requestId,
275 decision,
276 ...(feedback === undefined ? {} : { feedback }),
277 },
278 });
279 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
280 const error = squashAtomCommandFailure(result);
281 setThreadError(
282 activeThreadId,
283 error instanceof Error ? error.message : "Failed to submit approval decision.",
284 );
285 }
286 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
287 return result;
288 };
289 const onRespondToApprovalStable = useStableCallback(onRespondToApproval);
290
291 const onRespondToUserInput = async (
292 requestId: RuntimeRequestId,
293 answers: Record<string, unknown>,
294 ) => {
295 if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
296 return;
297 const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId);
298 if (!pendingInput || pendingInput.responseCapability === "not_resumable") return;
299 const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]);
300 if (userInputResponsesInFlight.current.has(responseKey)) return;
301 const attachmentsByQuestionId = new Map<string, UserInputAttachments[string]>();
302 const providerAnswers = { ...answers };
303 // Diff comments land in the thread draft even when their chip is in an answer.
304 const reviewCommentRecords = (
305 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments ?? []
306 ).map(reviewCommentContextRecord);
307 for (const question of pendingInput.questions) {
308 const target = questionAttachmentDraftId(
309 environmentId,
310 activeThreadId,
311 requestId,
312 question.id,
313 );
314 if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
315 const draft = useComposerDraftStore.getState().getComposerDraft(target);
316 providerAnswers[question.id] = projectPendingUserInputAnswer(answers[question.id], [
317 ...(draft?.threadContexts ?? []),
318 ...reviewCommentRecords,
319 ]);
320 const attachments = draft ? [...draft.images, ...draft.files] : [];
321 if (attachments.length === 0) continue;
322 const uploaded = getUploadedAttachments({ environmentId, images: attachments });
323 if (!uploaded) {
324 setThreadError(
325 activeThreadId,
326 "Wait for attachments to finish uploading, or remove failed uploads.",
327 );
328 return;
329 }
330 attachmentsByQuestionId.set(question.id, uploaded as UserInputAttachments[string]);
331 }
332 userInputResponsesInFlight.current.add(responseKey);
333
334 setRespondingUserInputRequestIds((existing) =>
335 existing.includes(requestId) ? existing : [...existing, requestId],
336 );
337 const result = await respondToThreadUserInput({
338 environmentId,
339 input: {
340 threadId: activeThreadId,
341 requestId,
342 answers: providerAnswers,
343 ...(attachmentsByQuestionId.size > 0
344 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
345 : {}),
346 },
347 });
348 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
349 const error = squashAtomCommandFailure(result);
350 setThreadError(
351 activeThreadId,
352 error instanceof Error ? error.message : "Failed to submit user input.",
353 );
354 }
355 userInputResponsesInFlight.current.delete(responseKey);
356 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
357 return result;
358 };
359
360 const onDismissUserInput = async (requestId: RuntimeRequestId) => {
361 if (!activeThreadId) return;
362
363 setRespondingUserInputRequestIds((existing) =>
364 existing.includes(requestId) ? existing : [...existing, requestId],
365 );
366 const result = await dismissThreadUserInput({
367 environmentId,
368 input: { threadId: activeThreadId, requestId },
369 });
370 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
371 const error = squashAtomCommandFailure(result);
372 setThreadError(
373 activeThreadId,
374 error instanceof Error ? error.message : "Failed to dismiss the question.",
375 );
376 }
377 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
378 return result;
379 };
380 const onDismissUserInputStable = useStableCallback(onDismissUserInput);
381
382 const setActivePendingUserInputQuestionIndex = (nextQuestionIndex: number) => {
383 if (!activePendingUserInput) {
384 return;
385 }
386 setPendingUserInputQuestionIndexByRequestId((existing) => ({
387 ...existing,
388 [activePendingRequestKey]: nextQuestionIndex,
389 }));
390 };
391
392 const onSelectActivePendingUserInputOption = (questionId: string, optionValue: string) => {
393 if (!activePendingUserInput) {
394 return;
395 }
396 // The option replaces the custom answer. Anything typed there is the
397 // user's text, so it goes back to the thread draft instead of vanishing.
398 const displacedAnswer = activePendingAnswerDrafts[questionId]?.customAnswer;
399 const currentPrompt =
400 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? "";
401 const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);
402 if (nextPrompt !== currentPrompt) {
403 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
404 displacedAnswers.current.push({
405 draft: displacedDraftKey,
406 key: `${activePendingRequestKey}:${questionId}`,
407 text: displacedAnswer ?? "",
408 });
409 toastManager.add({
410 type: "info",
411 title: "Your typed text was set aside",
412 description: "It will be back in the composer once the question is answered.",
413 data: { threadRef: activeThreadId ? { environmentId, threadId: activeThreadId } : null },
414 });
415 if (activeThreadId) {
416 const store = useComposerDraftStore.getState();
417 const questionTarget = questionAttachmentDraftId(
418 environmentId,
419 activeThreadId,
420 activePendingUserInput.requestId,
421 questionId,
422 );
423 store.addThreadContexts(
424 composerDraftTarget,
425 threadContextsReferencedByAnswer(
426 displacedAnswer ?? "",
427 store.getComposerDraft(questionTarget)?.threadContexts ?? [],
428 ),
429 { appendReference: false },
430 );
431 }
432 }
433 setPendingUserInputAnswersByRequestId((existing) => {
434 const question =
435 (activePendingProgress?.activeQuestion?.id === questionId
436 ? activePendingProgress.activeQuestion
437 : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId);
438 if (!question) {
439 return existing;
440 }
441
442 return {
443 ...existing,
444 [activePendingRequestKey]: {
445 ...existing[activePendingRequestKey],
446 [questionId]: togglePendingUserInputOptionSelection(
447 question,
448 existing[activePendingRequestKey]?.[questionId],
449 optionValue,
450 ),
451 },
452 };
453 });
454 promptRef.current = "";
455 composerRef.current?.resetCursorState({ cursor: 0 });
456 };
457 const onSelectActivePendingUserInputOptionStable = useStableCallback(
458 onSelectActivePendingUserInputOption,
459 );
460
461 const writePrompt = useStableCallback((prompt: string) => {
462 promptRef.current = prompt;
463 });
464 // Undo can put set-aside text back into the answer; left in the draft too, it is sent twice.
465 const withdrawRestoredAnswer = useStableCallback((questionId: string, value: string) => {
466 const key = `${activePendingRequestKey}:${questionId}`;
467 const entries = displacedAnswers.current;
468 const index = entries.findLastIndex(
469 (entry) =>
470 entry.draft === displacedDraftKey &&
471 entry.key === key &&
472 entry.text.trim() === value.trim(),
473 );
474 const displaced = entries[index];
475 if (displaced === undefined) return;
476 const later = entries
477 .slice(index + 1)
478 .filter((entry) => entry.draft === displaced.draft)
479 .map((entry) => entry.text);
480 displacedAnswers.current = entries.filter((entry) => entry !== displaced);
481 const currentPrompt =
482 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? "";
483 const nextPrompt = withdrawDisplacedCustomAnswerFromPrompt(
484 currentPrompt,
485 displaced.text,
486 later,
487 );
488 if (nextPrompt !== currentPrompt) setComposerDraftPrompt(composerDraftTarget, nextPrompt);
489 });
490 const onChangeActivePendingUserInputCustomAnswer = makePendingCustomAnswerSetter({
491 activePendingUserInput,
492 activePendingRequestKey,
493 writePrompt,
494 withdrawRestoredAnswer,
495 setPendingUserInputAnswersByRequestId,
496 });
497
498 const onAdvanceActivePendingUserInput = () => {
499 if (
500 !activePendingUserInput ||
501 activePendingUserInput.responseCapability === "not_resumable" ||
502 !activePendingProgress
503 ) {
504 return;
505 }
506 if (activePendingProgress.isLastQuestion) {
507 if (activePendingResolvedAnswers) {
508 void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers);
509 }
510 return;
511 }
512 if (!activePendingProgress.canAdvance) return;
513 setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1);
514 };
515 const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput);
516
517 const onPreviousActivePendingUserInputQuestion = () => {
518 if (!activePendingProgress) {
519 return;
520 }
521 setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0));
522 };
523 const onPreviousActivePendingUserInputQuestionStable = useStableCallback(
524 onPreviousActivePendingUserInputQuestion,
525 );
526
527 return {
528 pendingApprovals,
529 pendingUserInputs,
530 activePendingApproval,
531 activePendingUserInput,
532 activePendingDraftAnswers,
533 activePendingQuestionIndex,
534 activePendingProgress,
535 activePendingResolvedAnswers,
536 activePendingIsResponding,
537 respondingRequestIds,
538 onRespondToApproval: onRespondToApprovalStable,
539 onDismissUserInput: onDismissUserInputStable,
540 onSelectActivePendingUserInputOption: onSelectActivePendingUserInputOptionStable,
541 onChangeActivePendingUserInputCustomAnswer,
542 onAdvanceActivePendingUserInput,
543 onAdvanceActivePendingUserInputStable,
544 onPreviousActivePendingUserInputQuestion: onPreviousActivePendingUserInputQuestionStable,
545 };
546}