diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3e76a11c45ba..867621c341e0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,6 +25,7 @@ import { import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { useScratchProject } from "../hooks/useScratchProject"; import { useStableCallback } from "../hooks/useStableCallback"; +import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { @@ -41,13 +42,6 @@ import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; -import { - questionAttachmentDraftId, - questionAttachmentDraftPrefix, - clearQuestionAttachmentDraft, - useQuestionAttachmentPreparation, -} from "../questionAttachments"; -import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, @@ -67,7 +61,6 @@ import { type ModelSelection, type ProjectScript, type ProjectId, - type ProviderApprovalDecision, type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, @@ -120,7 +113,6 @@ import { type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; import { resolveThreadLastVisitedAt } from "./Sidebar.logic"; -import { derivePendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; import { parseScopedThreadKey, scopedThreadKey, @@ -192,8 +184,6 @@ import { parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { - derivePendingApprovals, - derivePendingUserInputs, derivePhase, deriveTimelineEntriesFromVisibleTurnItemsWithState, selectHandoffImageResources, @@ -204,7 +194,6 @@ import { findLatestProposedPlan, hasActionableProposedPlan, isLatestRunSettled, - type PendingUserInput, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { @@ -215,16 +204,6 @@ import { type TimelineRunObservation, type TimelineScrollMode, } from "./chat/timelineScrollAnchoring"; -import { - buildPendingUserInputAnswers, - carryDisplacedCustomAnswerIntoPrompt, - derivePendingUserInputProgress, - projectPendingUserInputAnswer, - setPendingUserInputCustomAnswer, - togglePendingUserInputOptionSelection, - type PendingUserInputDraftAnswer, -} from "../pendingUserInput"; -import { seedUserInputDraftAnswers } from "@t3tools/client-runtime/state/thread-requests"; import { useUiStateStore } from "../uiStateStore"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { @@ -632,7 +611,6 @@ const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; // broadcast to all shell subscribers. const VISIT_DISPATCH_THROTTLE_MS = 10_000; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; -const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition( isDraftHeroState: boolean, animationsActive: boolean, @@ -1596,40 +1574,7 @@ function makeThreadErrorSetter(input: { }; } -function makePendingCustomAnswerSetter(input: { - readonly activePendingUserInput: PendingUserInput | null; - readonly activePendingRequestKey: string; - readonly promptRef: { current: string }; - readonly setPendingUserInputAnswersByRequestId: ( - update: SetStateAction>>, - ) => void; -}) { - return (questionId: string, value: string) => { - const { activePendingUserInput, activePendingRequestKey } = input; - if (!activePendingUserInput) { - return; - } - const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question || question.allowCustomAnswer === false) { - return; - } - input.promptRef.current = value; - input.setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingRequestKey]: { - ...existing[activePendingRequestKey], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingRequestKey]?.[questionId], - value, - question, - ), - }, - })); - // The editor owns this selection. Resetting it before the answer renders - // can re-enter Lexical with offsets from the previous skill token. - }; -} - +/** Draft id for editing a queued run of `threadRef`. */ function makeQueuedEditDraftTarget(threadRef: ScopedThreadRef) { return (runId: RunId) => DraftId.make(`queued-edit:${scopedThreadKey(threadRef)}:${runId}`); } @@ -1724,15 +1669,6 @@ export default function ChatView(props: ChatViewProps) { label: "load earlier thread history", reportFailure: false, }); - const respondToThreadApproval = useOrchestrationCommand(threadEnvironment.respondToApproval, { - reportFailure: false, - }); - const respondToThreadUserInput = useOrchestrationCommand(threadEnvironment.respondToUserInput, { - reportFailure: false, - }); - const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, { - reportFailure: false, - }); const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, { reportFailure: false, }); @@ -1978,8 +1914,6 @@ export default function ChatView(props: ChatViewProps) { const isRevertingCheckpoint = useComposerDraftStore((store) => store.rewindingThreadKeys.has(routeThreadKey), ); - const userInputResponsesInFlight = useRef(new Set()); - const [respondingRequestIds, setRespondingRequestIds] = useState([]); useEffect(() => { setIsWorkspaceFileDragActive(false); @@ -1991,14 +1925,6 @@ export default function ChatView(props: ChatViewProps) { window.addEventListener("dragend", clearWorkspaceFileDrag); return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); }, [isWorkspaceFileDragActive]); - const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< - RuntimeRequestId[] - >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> - >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState(null); @@ -3280,172 +3206,50 @@ export default function ChatView(props: ChatViewProps) { conversationProviderStatus !== null && conversationProviderStatus.supportsConversationRollback !== false; const phase = derivePhase(activeRuntime); - const pendingRequests = useMemo( - () => - serverProjection === null - ? { approvals: [], userInputs: [] } - : derivePendingThreadRequests(serverProjection), - [serverProjection], - ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(pendingRequests.approvals), - [pendingRequests.approvals], - ); - const pendingUserInputs = useMemo( - () => derivePendingUserInputs(pendingRequests.userInputs), - [pendingRequests.userInputs], - ); - const activePendingUserInput = pendingUserInputs[0] ?? null; - const activePendingRequestKey = JSON.stringify([ - environmentId, - activeThreadId, - activePendingUserInput?.requestId, - ]); - const activePendingAnswerDrafts = - pendingUserInputAnswersByRequestId[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; - if ( - activePendingUserInput && - seedUserInputDraftAnswers(activePendingUserInput.questions, activePendingAnswerDrafts) !== - activePendingAnswerDrafts - ) { - setPendingUserInputAnswersByRequestId((existing) => { - const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; - const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); - return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded }; - }); - } - const pendingQuestionDraftKeys = useMemo( + const setThreadError = useMemo( () => - activeThreadId - ? pendingUserInputs.flatMap((request) => - request.questions.map((question) => - questionAttachmentDraftId( - environmentId, - activeThreadId, - request.requestId, - question.id, - ), - ), - ) - : [], - [activeThreadId, environmentId, pendingUserInputs], - ); - const questionComposerDrafts = useComposerDraftStore( - useShallow((state) => - Object.fromEntries( - pendingQuestionDraftKeys.map((key) => [key, state.draftsByThreadKey[key]]), - ), - ), - ); - const questionUploadsBlocked = useAttachmentUploadStore( - useShallow((state) => - Object.fromEntries( - pendingQuestionDraftKeys.map((key) => { - const draft = questionComposerDrafts[key]; - const attachments = draft ? [...draft.images, ...draft.files] : []; - return [ - key, - attachments.some((attachment) => { - const upload = state.uploadsByImageId[attachment.id]; - return upload?.status !== "ready" || upload.environmentId !== environmentId; - }), - ]; - }), - ), - ), - ); - const questionPreparations = useQuestionAttachmentPreparation( - useShallow((state) => - Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])), - ), - ); - useEffect(() => { - if (!activeThread) return; - const questionThread = activeThread; - const currentRequests = pendingUserInputs; - const prefix = questionAttachmentDraftPrefix(environmentId, questionThread.id); - const retained = new Set( - currentRequests.flatMap((request) => - request.questions.map((question) => - questionAttachmentDraftId( - environmentId, - questionThread.id, - request.requestId, - question.id, - ), - ), - ), - ); - const keys = new Set([ - ...Object.keys(useComposerDraftStore.getState().draftsByThreadKey), - ...Object.keys(useQuestionAttachmentPreparation.getState().counts), - ]); - for (const key of keys) { - if (key.startsWith(prefix) && !retained.has(DraftId.make(key))) - clearQuestionAttachmentDraft(DraftId.make(key)); - } - }, [environmentId, activeThread, pendingUserInputs]); - const activePendingDraftAnswers = useMemo(() => { - if (!activePendingUserInput || !activeThreadId) return EMPTY_PENDING_USER_INPUT_ANSWERS; - return Object.fromEntries( - activePendingUserInput.questions.map((question) => { - const key = questionAttachmentDraftId( - environmentId, - activeThreadId, - activePendingUserInput.requestId, - question.id, - ); - const draft = questionComposerDrafts[key]; - const attachments = draft ? [...draft.images, ...draft.files] : []; - return [ - question.id, - { - ...pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[question.id], - attachmentCount: attachments.length, - attachmentsBlocked: - (attachments.length > 0 && !supportsQuestionAttachments) || - (questionPreparations[key] ?? 0) > 0 || - questionUploadsBlocked[key] === true, - }, - ]; + makeThreadErrorSetter({ + routeThreadRef, + routeThreadKey, + draftId, + serverThreadRef: serverThread + ? { threadId: serverThread.id, environmentId: serverThread.environmentId } + : null, + setLocalServerErrorsByThreadKey, + setLocalDraftErrorsByDraftId, }), - ); - }, [ + [draftId, routeThreadKey, routeThreadRef, serverThread?.id, serverThread?.environmentId], + ); + const { + pendingApprovals, + pendingUserInputs, + activePendingApproval, activePendingUserInput, - activeThreadId, + activePendingDraftAnswers, + activePendingQuestionIndex, + activePendingProgress, + activePendingResolvedAnswers, + activePendingIsResponding, + respondingRequestIds, + onRespondToApproval: onRespondToApprovalStable, + onDismissUserInput: onDismissUserInputStable, + onSelectActivePendingUserInputOption: onSelectActivePendingUserInputOptionStable, + onChangeActivePendingUserInputCustomAnswer, + onAdvanceActivePendingUserInput, + onAdvanceActivePendingUserInputStable, + onPreviousActivePendingUserInputQuestion: onPreviousActivePendingUserInputQuestionStable, + } = usePendingThreadRequests({ environmentId, - questionComposerDrafts, - questionUploadsBlocked, + activeThreadId, + activeThread, + serverProjection, supportsQuestionAttachments, - questionPreparations, - pendingUserInputAnswersByRequestId, - activePendingRequestKey, - ]); - const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingRequestKey] ?? 0) - : 0; - const activePendingProgress = useMemo( - () => - activePendingUserInput - ? derivePendingUserInputProgress( - activePendingUserInput.questions, - activePendingDraftAnswers, - activePendingQuestionIndex, - ) - : null, - [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], - ); - const activePendingResolvedAnswers = useMemo( - () => - activePendingUserInput - ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) - : null, - [activePendingDraftAnswers, activePendingUserInput], - ); - const activePendingIsResponding = activePendingUserInput - ? activePendingUserInput.responseCapability === "not_resumable" || - respondingUserInputRequestIds.includes(activePendingUserInput.requestId) - : false; + composerDraftTarget, + setComposerDraftPrompt, + promptRef, + composerRef, + setThreadError, + }); const activeProposedPlan = useMemo(() => { if (!latestRunSettled) { return null; @@ -3459,7 +3263,11 @@ export default function ChatView(props: ChatViewProps) { hasActionableProposedPlan: hasActionableProposedPlan(activeProposedPlan), hasComposerAttachments: composerHasAttachments, }); - const activePendingApproval = pendingApprovals[0] ?? null; + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ readonly key: string; readonly threadKey: string; @@ -4467,20 +4275,6 @@ export default function ChatView(props: ChatViewProps) { null; const hasReachedSplitLimit = (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; - const setThreadError = useMemo( - () => - makeThreadErrorSetter({ - routeThreadRef, - routeThreadKey, - draftId, - serverThreadRef: serverThread - ? { threadId: serverThread.id, environmentId: serverThread.environmentId } - : null, - setLocalServerErrorsByThreadKey, - setLocalDraftErrorsByDraftId, - }), - [draftId, routeThreadKey, routeThreadRef, serverThread?.id, serverThread?.environmentId], - ); const focusComposer = () => { composerRef.current?.focusAtEnd(); @@ -9716,237 +9510,6 @@ export default function ChatView(props: ChatViewProps) { } }; - const onRespondToApproval = async ( - requestId: RuntimeRequestId, - decision: ProviderApprovalDecision, - ) => { - if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - return; - if ( - pendingApprovals.find((approval) => approval.requestId === requestId)?.responseCapability !== - "live" - ) - return; - - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - const result = await respondToThreadApproval({ - environmentId, - input: { - threadId: activeThreadId, - requestId, - decision, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : "Failed to submit approval decision.", - ); - } - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }; - const onRespondToApprovalStable = useStableCallback(onRespondToApproval); - - const onRespondToUserInput = async ( - requestId: RuntimeRequestId, - answers: Record, - ) => { - if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - return; - const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); - if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; - const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); - if (userInputResponsesInFlight.current.has(responseKey)) return; - const attachmentsByQuestionId = new Map< - string, - import("@t3tools/contracts").UserInputAttachments[string] - >(); - const providerAnswers = { ...answers }; - for (const question of pendingInput.questions) { - const target = questionAttachmentDraftId( - environmentId, - activeThreadId, - requestId, - question.id, - ); - if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; - const draft = useComposerDraftStore.getState().getComposerDraft(target); - providerAnswers[question.id] = projectPendingUserInputAnswer( - answers[question.id], - draft?.threadContexts ?? [], - ); - const attachments = draft ? [...draft.images, ...draft.files] : []; - if (attachments.length === 0) continue; - const uploaded = getUploadedAttachments({ environmentId, images: attachments }); - if (!uploaded) { - setThreadError( - activeThreadId, - "Wait for attachments to finish uploading, or remove failed uploads.", - ); - return; - } - attachmentsByQuestionId.set( - question.id, - uploaded as import("@t3tools/contracts").UserInputAttachments[string], - ); - } - userInputResponsesInFlight.current.add(responseKey); - - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - const result = await respondToThreadUserInput({ - environmentId, - input: { - threadId: activeThreadId, - requestId, - answers: providerAnswers, - ...(attachmentsByQuestionId.size > 0 - ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } - : {}), - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : "Failed to submit user input.", - ); - } - userInputResponsesInFlight.current.delete(responseKey); - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }; - - const onDismissUserInput = async (requestId: RuntimeRequestId) => { - if (!activeThreadId) return; - - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - const result = await dismissThreadUserInput({ - environmentId, - input: { threadId: activeThreadId, requestId }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : "Failed to dismiss the question.", - ); - } - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }; - const onDismissUserInputStable = useStableCallback(onDismissUserInput); - - const setActivePendingUserInputQuestionIndex = (nextQuestionIndex: number) => { - if (!activePendingUserInput) { - return; - } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingRequestKey]: nextQuestionIndex, - })); - }; - - const onSelectActivePendingUserInputOption = (questionId: string, optionValue: string) => { - if (!activePendingUserInput) { - return; - } - const displacedAnswer = - pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; - const currentPrompt = - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; - const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); - if (nextPrompt !== currentPrompt) { - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - if (activeThreadId) { - const store = useComposerDraftStore.getState(); - const questionTarget = questionAttachmentDraftId( - environmentId, - activeThreadId, - activePendingUserInput.requestId, - questionId, - ); - store.addThreadContexts( - composerDraftTarget, - store.getComposerDraft(questionTarget)?.threadContexts ?? [], - { appendReference: false }, - ); - } - } - setPendingUserInputAnswersByRequestId((existing) => { - const question = - (activePendingProgress?.activeQuestion?.id === questionId - ? activePendingProgress.activeQuestion - : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question) { - return existing; - } - - return { - ...existing, - [activePendingRequestKey]: { - ...existing[activePendingRequestKey], - [questionId]: togglePendingUserInputOptionSelection( - question, - existing[activePendingRequestKey]?.[questionId], - optionValue, - ), - }, - }; - }); - promptRef.current = ""; - composerRef.current?.resetCursorState({ cursor: 0 }); - }; - const onSelectActivePendingUserInputOptionStable = useStableCallback( - onSelectActivePendingUserInputOption, - ); - - const onChangeActivePendingUserInputCustomAnswer = useMemo( - () => - makePendingCustomAnswerSetter({ - activePendingUserInput, - activePendingRequestKey, - promptRef, - setPendingUserInputAnswersByRequestId, - }), - [activePendingUserInput, activePendingRequestKey], - ); - - const onAdvanceActivePendingUserInput = () => { - if ( - !activePendingUserInput || - activePendingUserInput.responseCapability === "not_resumable" || - !activePendingProgress - ) { - return; - } - if (activePendingProgress.isLastQuestion) { - if (activePendingResolvedAnswers) { - void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); - } - return; - } - setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); - }; - const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput); - - const onPreviousActivePendingUserInputQuestion = () => { - if (!activePendingProgress) { - return; - } - setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); - }; - const onPreviousActivePendingUserInputQuestionStable = useStableCallback( - onPreviousActivePendingUserInputQuestion, - ); - async function onSubmitPlanFollowUp({ text, context, diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts new file mode 100644 index 000000000000..8ec231d2f915 --- /dev/null +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -0,0 +1,551 @@ +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type OrchestrationV2ThreadProjection, + type ProviderApprovalDecision, + type RuntimeRequestId, + type ScopedThreadRef, + type ThreadId, + type UserInputAttachments, +} from "@t3tools/contracts"; +import { + derivePendingThreadRequests, + seedUserInputDraftAnswers, +} from "@t3tools/client-runtime/state/thread-requests"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useEffect, useMemo, useRef, useState, type RefObject, type SetStateAction } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { useStableCallback } from "../../hooks/useStableCallback"; +import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; +import { + buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, + derivePendingUserInputProgress, + projectPendingUserInputAnswer, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, + type PendingUserInputDraftAnswer, +} from "../../pendingUserInput"; +import { + clearQuestionAttachmentDraft, + questionAttachmentDraftId, + questionAttachmentDraftPrefix, + useQuestionAttachmentPreparation, +} from "../../questionAttachments"; +import { + derivePendingApprovals, + derivePendingUserInputs, + type PendingUserInput, +} from "../../session-logic"; +import { readEnvironmentScope } from "../../state/session"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useOrchestrationCommand } from "../../state/use-orchestration-command"; +import type { ChatComposerHandle } from "./ChatComposer"; + +const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; + +/** Writes the custom answer for a question of the pending user input the making render saw. */ +function makePendingCustomAnswerSetter(input: { + readonly activePendingUserInput: PendingUserInput | null; + readonly activePendingRequestKey: string; + readonly promptRef: { current: string }; + readonly setPendingUserInputAnswersByRequestId: ( + update: SetStateAction>>, + ) => void; +}) { + return (questionId: string, value: string) => { + const { activePendingUserInput, activePendingRequestKey } = input; + if (!activePendingUserInput) { + return; + } + const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question || question.allowCustomAnswer === false) { + return; + } + input.promptRef.current = value; + input.setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingRequestKey]: { + ...existing[activePendingRequestKey], + [questionId]: setPendingUserInputCustomAnswer( + existing[activePendingRequestKey]?.[questionId], + value, + question, + ), + }, + })); + // The editor owns this selection. Resetting it before the answer renders + // can re-enter Lexical with offsets from the previous skill token. + }; +} + +/** + * Pending approvals and user-input questions for the active thread: what is + * waiting, the user's in-progress answers, and the commands that respond. + */ +export function usePendingThreadRequests(input: { + readonly environmentId: EnvironmentId; + readonly activeThreadId: ThreadId | null; + // The effect that prunes stale question drafts re-runs when this object changes. + readonly activeThread: { readonly id: ThreadId } | undefined; + readonly serverProjection: OrchestrationV2ThreadProjection | null; + readonly supportsQuestionAttachments: boolean; + readonly composerDraftTarget: ScopedThreadRef | DraftId; + readonly setComposerDraftPrompt: (target: ScopedThreadRef | DraftId, prompt: string) => void; + readonly promptRef: { current: string }; + readonly composerRef: RefObject; + readonly setThreadError: (threadId: ThreadId, error: string | null) => void; +}) { + const { + environmentId, + activeThreadId, + activeThread, + serverProjection, + supportsQuestionAttachments, + composerDraftTarget, + setComposerDraftPrompt, + promptRef, + composerRef, + setThreadError, + } = input; + const respondToThreadApproval = useOrchestrationCommand(threadEnvironment.respondToApproval, { + reportFailure: false, + }); + const respondToThreadUserInput = useOrchestrationCommand(threadEnvironment.respondToUserInput, { + reportFailure: false, + }); + const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, { + reportFailure: false, + }); + const userInputResponsesInFlight = useRef(new Set()); + const [respondingRequestIds, setRespondingRequestIds] = useState([]); + const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< + RuntimeRequestId[] + >([]); + const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< + Record> + >({}); + const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = + useState>({}); + const pendingRequests = useMemo( + () => + serverProjection === null + ? { approvals: [], userInputs: [] } + : derivePendingThreadRequests(serverProjection), + [serverProjection], + ); + const pendingApprovals = useMemo( + () => derivePendingApprovals(pendingRequests.approvals), + [pendingRequests.approvals], + ); + const pendingUserInputs = useMemo( + () => derivePendingUserInputs(pendingRequests.userInputs), + [pendingRequests.userInputs], + ); + const activePendingUserInput = pendingUserInputs[0] ?? null; + const activePendingRequestKey = JSON.stringify([ + environmentId, + activeThreadId, + activePendingUserInput?.requestId, + ]); + const activePendingAnswerDrafts = + pendingUserInputAnswersByRequestId[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; + if ( + activePendingUserInput && + seedUserInputDraftAnswers(activePendingUserInput.questions, activePendingAnswerDrafts) !== + activePendingAnswerDrafts + ) { + setPendingUserInputAnswersByRequestId((existing) => { + const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; + const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); + return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded }; + }); + } + const pendingQuestionDraftKeys = useMemo( + () => + activeThreadId + ? pendingUserInputs.flatMap((request) => + request.questions.map((question) => + questionAttachmentDraftId( + environmentId, + activeThreadId, + request.requestId, + question.id, + ), + ), + ) + : [], + [activeThreadId, environmentId, pendingUserInputs], + ); + const questionComposerDrafts = useComposerDraftStore( + useShallow((state) => + Object.fromEntries( + pendingQuestionDraftKeys.map((key) => [key, state.draftsByThreadKey[key]]), + ), + ), + ); + const questionUploadsBlocked = useAttachmentUploadStore( + useShallow((state) => + Object.fromEntries( + pendingQuestionDraftKeys.map((key) => { + const draft = questionComposerDrafts[key]; + const attachments = draft ? [...draft.images, ...draft.files] : []; + return [ + key, + attachments.some((attachment) => { + const upload = state.uploadsByImageId[attachment.id]; + return upload?.status !== "ready" || upload.environmentId !== environmentId; + }), + ]; + }), + ), + ), + ); + const questionPreparations = useQuestionAttachmentPreparation( + useShallow((state) => + Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])), + ), + ); + useEffect(() => { + if (!activeThread) return; + const questionThread = activeThread; + const currentRequests = pendingUserInputs; + const prefix = questionAttachmentDraftPrefix(environmentId, questionThread.id); + const retained = new Set( + currentRequests.flatMap((request) => + request.questions.map((question) => + questionAttachmentDraftId( + environmentId, + questionThread.id, + request.requestId, + question.id, + ), + ), + ), + ); + const keys = new Set([ + ...Object.keys(useComposerDraftStore.getState().draftsByThreadKey), + ...Object.keys(useQuestionAttachmentPreparation.getState().counts), + ]); + for (const key of keys) { + if (key.startsWith(prefix) && !retained.has(DraftId.make(key))) + clearQuestionAttachmentDraft(DraftId.make(key)); + } + }, [environmentId, activeThread, pendingUserInputs]); + const activePendingDraftAnswers = useMemo(() => { + if (!activePendingUserInput || !activeThreadId) return EMPTY_PENDING_USER_INPUT_ANSWERS; + return Object.fromEntries( + activePendingUserInput.questions.map((question) => { + const key = questionAttachmentDraftId( + environmentId, + activeThreadId, + activePendingUserInput.requestId, + question.id, + ); + const draft = questionComposerDrafts[key]; + const attachments = draft ? [...draft.images, ...draft.files] : []; + return [ + question.id, + { + ...pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[question.id], + attachmentCount: attachments.length, + attachmentsBlocked: + (attachments.length > 0 && !supportsQuestionAttachments) || + (questionPreparations[key] ?? 0) > 0 || + questionUploadsBlocked[key] === true, + }, + ]; + }), + ); + }, [ + activePendingUserInput, + activeThreadId, + environmentId, + questionComposerDrafts, + questionUploadsBlocked, + supportsQuestionAttachments, + questionPreparations, + pendingUserInputAnswersByRequestId, + activePendingRequestKey, + ]); + const activePendingQuestionIndex = activePendingUserInput + ? (pendingUserInputQuestionIndexByRequestId[activePendingRequestKey] ?? 0) + : 0; + const activePendingProgress = useMemo( + () => + activePendingUserInput + ? derivePendingUserInputProgress( + activePendingUserInput.questions, + activePendingDraftAnswers, + activePendingQuestionIndex, + ) + : null, + [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], + ); + const activePendingResolvedAnswers = useMemo( + () => + activePendingUserInput + ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) + : null, + [activePendingDraftAnswers, activePendingUserInput], + ); + const activePendingIsResponding = activePendingUserInput + ? activePendingUserInput.responseCapability === "not_resumable" || + respondingUserInputRequestIds.includes(activePendingUserInput.requestId) + : false; + const activePendingApproval = pendingApprovals[0] ?? null; + + const onRespondToApproval = async ( + requestId: RuntimeRequestId, + decision: ProviderApprovalDecision, + ) => { + if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + return; + if ( + pendingApprovals.find((approval) => approval.requestId === requestId)?.responseCapability !== + "live" + ) + return; + + setRespondingRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await respondToThreadApproval({ + environmentId, + input: { + threadId: activeThreadId, + requestId, + decision, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to submit approval decision.", + ); + } + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; + const onRespondToApprovalStable = useStableCallback(onRespondToApproval); + + const onRespondToUserInput = async ( + requestId: RuntimeRequestId, + answers: Record, + ) => { + if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + return; + const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); + if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; + const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); + if (userInputResponsesInFlight.current.has(responseKey)) return; + const attachmentsByQuestionId = new Map(); + const providerAnswers = { ...answers }; + for (const question of pendingInput.questions) { + const target = questionAttachmentDraftId( + environmentId, + activeThreadId, + requestId, + question.id, + ); + if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; + const draft = useComposerDraftStore.getState().getComposerDraft(target); + providerAnswers[question.id] = projectPendingUserInputAnswer( + answers[question.id], + draft?.threadContexts ?? [], + ); + const attachments = draft ? [...draft.images, ...draft.files] : []; + if (attachments.length === 0) continue; + const uploaded = getUploadedAttachments({ environmentId, images: attachments }); + if (!uploaded) { + setThreadError( + activeThreadId, + "Wait for attachments to finish uploading, or remove failed uploads.", + ); + return; + } + attachmentsByQuestionId.set(question.id, uploaded as UserInputAttachments[string]); + } + userInputResponsesInFlight.current.add(responseKey); + + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await respondToThreadUserInput({ + environmentId, + input: { + threadId: activeThreadId, + requestId, + answers: providerAnswers, + ...(attachmentsByQuestionId.size > 0 + ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } + : {}), + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to submit user input.", + ); + } + userInputResponsesInFlight.current.delete(responseKey); + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; + + // Closes an async question without messaging the agent. The server records + // the dismissal so every client releases the composer. + const onDismissUserInput = async (requestId: RuntimeRequestId) => { + if (!activeThreadId) return; + + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await dismissThreadUserInput({ + environmentId, + input: { threadId: activeThreadId, requestId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to dismiss the question.", + ); + } + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; + const onDismissUserInputStable = useStableCallback(onDismissUserInput); + + const setActivePendingUserInputQuestionIndex = (nextQuestionIndex: number) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputQuestionIndexByRequestId((existing) => ({ + ...existing, + [activePendingRequestKey]: nextQuestionIndex, + })); + }; + + const onSelectActivePendingUserInputOption = (questionId: string, optionValue: string) => { + if (!activePendingUserInput) { + return; + } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + if (activeThreadId) { + const store = useComposerDraftStore.getState(); + const questionTarget = questionAttachmentDraftId( + environmentId, + activeThreadId, + activePendingUserInput.requestId, + questionId, + ); + store.addThreadContexts( + composerDraftTarget, + store.getComposerDraft(questionTarget)?.threadContexts ?? [], + { appendReference: false }, + ); + } + } + setPendingUserInputAnswersByRequestId((existing) => { + const question = + (activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return existing; + } + + return { + ...existing, + [activePendingRequestKey]: { + ...existing[activePendingRequestKey], + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[activePendingRequestKey]?.[questionId], + optionValue, + ), + }, + }; + }); + promptRef.current = ""; + composerRef.current?.resetCursorState({ cursor: 0 }); + }; + const onSelectActivePendingUserInputOptionStable = useStableCallback( + onSelectActivePendingUserInputOption, + ); + + const onChangeActivePendingUserInputCustomAnswer = useMemo( + () => + makePendingCustomAnswerSetter({ + activePendingUserInput, + activePendingRequestKey, + promptRef, + setPendingUserInputAnswersByRequestId, + }), + [activePendingUserInput, activePendingRequestKey, promptRef], + ); + + const onAdvanceActivePendingUserInput = () => { + if ( + !activePendingUserInput || + activePendingUserInput.responseCapability === "not_resumable" || + !activePendingProgress + ) { + return; + } + if (activePendingProgress.isLastQuestion) { + if (activePendingResolvedAnswers) { + void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); + } + return; + } + setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); + }; + const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput); + + const onPreviousActivePendingUserInputQuestion = () => { + if (!activePendingProgress) { + return; + } + setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); + }; + const onPreviousActivePendingUserInputQuestionStable = useStableCallback( + onPreviousActivePendingUserInputQuestion, + ); + + return { + pendingApprovals, + pendingUserInputs, + activePendingApproval, + activePendingUserInput, + activePendingDraftAnswers, + activePendingQuestionIndex, + activePendingProgress, + activePendingResolvedAnswers, + activePendingIsResponding, + respondingRequestIds, + onRespondToApproval: onRespondToApprovalStable, + onDismissUserInput: onDismissUserInputStable, + onSelectActivePendingUserInputOption: onSelectActivePendingUserInputOptionStable, + onChangeActivePendingUserInputCustomAnswer, + onAdvanceActivePendingUserInput, + onAdvanceActivePendingUserInputStable, + onPreviousActivePendingUserInputQuestion: onPreviousActivePendingUserInputQuestionStable, + }; +}