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<string, Record<string, PendingUserInputDraftAnswer>>>({}).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<ApprovalRequestId | null>(null); const userInputResponsesInFlight = useRef(new Set<string>()); const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>( 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, };}
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<string, Record<string, PendingUserInputDraftAnswer>>
>({}).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<ApprovalRequestId | null>(null);
const userInputResponsesInFlight = useRef(new Set<string>());
const [respondingUserInputId, setRespondingUserInputId] = useState<ApprovalRequestId | null>(
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,
};
}