import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { useServerConfigs } from "./entities"; import { Alert } from "react-native"; import { questionAttachmentDraftKey, questionAttachmentDraftPrefix, questionAttachmentPreparationAtom, } from "./question-attachments"; import { composerDraftsAtom, clearComposerDraft } from "./use-composer-drafts"; import { composerAttachmentUploadsAtom, composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, } from "./composer-attachment-uploads"; import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ApprovalRequestId, type ProviderApprovalDecision, type UserInputQuestion, } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { threadEnvironment } from "../state/threads"; import { scopedRequestKey } from "../lib/scopedEntities"; import { buildPendingUserInputAnswers, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; import { appAtomRegistry } from "./atom-registry"; import { useSelectedThreadDetail } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; import { useAtomCommand } from "./use-atom-command"; const userInputDraftsByRequestKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); function setUserInputDraftOption( requestKey: string, question: UserInputQuestion, value: string, ): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...current, [requestKey]: { ...current[requestKey], [question.id]: togglePendingUserInputOptionSelection( question, current[requestKey]?.[question.id], value, ), }, }); } function setUserInputDraftCustomAnswer( requestKey: string, question: UserInputQuestion, customAnswer: string, ): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...current, [requestKey]: { ...current[requestKey], [question.id]: setPendingUserInputCustomAnswer( question, current[requestKey]?.[question.id], customAnswer, ), }, }); } export function useSelectedThreadRequests() { const respondToApproval = useAtomCommand( threadEnvironment.respondToApproval, "thread approval response", ); const respondToUserInput = useAtomCommand( threadEnvironment.respondToUserInput, "thread user input response", ); const dismissUserInput = useAtomCommand( threadEnvironment.dismissUserInput, "thread user input dismissal", ); const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThread = useSelectedThreadDetail(); const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); const [respondingApprovalId, setRespondingApprovalId] = useState(null); const userInputResponsesInFlight = useRef(new Set()); const [respondingUserInputId, setRespondingUserInputId] = useState( null, ); const { approvals: activePendingApprovals, userInputs: activePendingUserInputs } = useMemo( () => derivePendingRequests(selectedThread?.activities ?? []), [selectedThread?.activities], ); const activePendingApproval = activePendingApprovals[0] ?? null; const activePendingUserInput = activePendingUserInputs[0] ?? null; const questionServerConfigs = useServerConfigs(); const attachmentDrafts = useAtomValue(composerDraftsAtom); const preparationCounts = useAtomValue(questionAttachmentPreparationAtom); const uploadStates = useAtomValue(composerAttachmentUploadsAtom); useEffect(() => { if (!selectedThreadShell || !selectedThread) return; const prefix = questionAttachmentDraftPrefix( selectedThreadShell.environmentId, selectedThreadShell.id, ); const retained = new Set( activePendingUserInputs.flatMap((request) => request.questions.map((question) => questionAttachmentDraftKey( selectedThreadShell.environmentId, selectedThreadShell.id, request.requestId, question.id, ), ), ), ); const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) }; let changed = false; for (const key of new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)])) { if (!key.startsWith(prefix) || retained.has(key)) continue; if (attachmentDrafts[key]) clearComposerDraft(key); if (key in counts) { delete counts[key]; changed = true; } } if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts); }, [activePendingUserInputs, attachmentDrafts, selectedThread, selectedThreadShell]); const activePendingUserInputDrafts = activePendingUserInput && selectedThreadShell ? Object.fromEntries( activePendingUserInput.questions.map((question) => { const key = questionAttachmentDraftKey( selectedThreadShell.environmentId, selectedThreadShell.id, activePendingUserInput.requestId, question.id, ); const attachments = attachmentDrafts[key]?.attachments ?? []; const uploadInput = { environmentId: selectedThreadShell.environmentId, attachments, serverConfig: questionServerConfigs.get(selectedThreadShell.environmentId) ?? null, states: uploadStates, }; return [ question.id, { ...userInputDraftsByRequestKey[ scopedRequestKey( selectedThreadShell.environmentId, activePendingUserInput.requestId, ) ]?.[question.id], attachmentCount: attachments.length, attachmentsBlocked: (attachments.length > 0 && questionServerConfigs.get(selectedThreadShell.environmentId)?.environment .capabilities.questionAttachments !== true) || (preparationCounts[key] ?? 0) > 0 || composerAttachmentsStillUploading(uploadInput) || composerAttachmentUploadBlockReason({ ...uploadInput, connected: true, }) !== null, }, ]; }), ) : {}; const activePendingUserInputAnswers = activePendingUserInput ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts) : null; const onSelectUserInputOption = useCallback( (requestId: ApprovalRequestId, question: UserInputQuestion, value: string) => { if (!selectedThreadShell) { return; } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); setUserInputDraftOption(requestKey, question, value); }, [selectedThreadShell], ); const onChangeUserInputCustomAnswer = useCallback( (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => { const question = activePendingUserInputs .find((request) => request.requestId === requestId) ?.questions.find((entry) => entry.id === questionId); if (!selectedThreadShell || !question) { return; } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); setUserInputDraftCustomAnswer(requestKey, question, customAnswer); }, [activePendingUserInputs, selectedThreadShell], ); const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { if (!selectedThreadShell) { return; } setRespondingApprovalId(requestId); const result = await respondToApproval({ environmentId: selectedThreadShell.environmentId, input: { threadId: selectedThreadShell.id, requestId, decision, }, }); setRespondingApprovalId((current) => (current === requestId ? null : current)); return result; }, [respondToApproval, selectedThreadShell], ); const onSubmitUserInput = useCallback(async () => { if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) { return; } const responseKey = questionAttachmentDraftKey( selectedThreadShell.environmentId, selectedThreadShell.id, activePendingUserInput.requestId, "", ); if (userInputResponsesInFlight.current.has(responseKey)) return; const attachmentsByQuestionId = new Map< string, import("@t3tools/contracts").UserInputAttachments[string] >(); for (const question of activePendingUserInput.questions) { const key = questionAttachmentDraftKey( selectedThreadShell.environmentId, selectedThreadShell.id, activePendingUserInput.requestId, question.id, ); if ((appAtomRegistry.get(questionAttachmentPreparationAtom)[key] ?? 0) > 0) return; const attachments = appAtomRegistry.get(composerDraftsAtom)[key]?.attachments ?? []; if (attachments.length === 0) continue; if ( attachments.some( (attachment) => !attachment.uploadedAttachmentId || attachment.uploadEnvironmentId !== selectedThreadShell.environmentId, ) ) { Alert.alert( "Attachments are not ready", "Wait for uploads to finish, or retry failed uploads.", ); return; } attachmentsByQuestionId.set( question.id, attachments.map((attachment) => ({ type: attachment.type, id: attachment.uploadedAttachmentId!, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, })), ); } userInputResponsesInFlight.current.add(responseKey); setRespondingUserInputId(activePendingUserInput.requestId); const result = await respondToUserInput({ environmentId: selectedThreadShell.environmentId, input: { threadId: selectedThreadShell.id, requestId: activePendingUserInput.requestId, answers: activePendingUserInputAnswers, ...(attachmentsByQuestionId.size > 0 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } : {}), }, }); userInputResponsesInFlight.current.delete(responseKey); setRespondingUserInputId((current) => current === activePendingUserInput.requestId ? null : current, ); return result; }, [ activePendingUserInput, activePendingUserInputAnswers, respondToUserInput, selectedThreadShell, ]); // Closes an async question without messaging the agent. const onDismissUserInput = useCallback(async () => { if (!selectedThreadShell || !activePendingUserInput) { return; } setRespondingUserInputId(activePendingUserInput.requestId); const result = await dismissUserInput({ environmentId: selectedThreadShell.environmentId, input: { threadId: selectedThreadShell.id, requestId: activePendingUserInput.requestId, }, }); setRespondingUserInputId((current) => current === activePendingUserInput.requestId ? null : current, ); return result; }, [activePendingUserInput, dismissUserInput, selectedThreadShell]); return { activePendingApproval, activePendingUserInput, activePendingUserInputDrafts, activePendingUserInputAnswers, respondingApprovalId, respondingUserInputId, onRespondToApproval, onSelectUserInputOption, onChangeUserInputCustomAnswer, onSubmitUserInput, onDismissUserInput, }; }