diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d8b74c8cc..8a4002cb8 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { + EventId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -25,6 +26,7 @@ import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; const THREAD_ID = "thread-browser-test" as ThreadId; +const SECOND_THREAD_ID = "thread-browser-test-2" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; @@ -298,6 +300,103 @@ function createDraftOnlySnapshot(): OrchestrationReadModel { }; } +function createSnapshotWithPendingUserInputThreads(): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-pending-input-target" as MessageId, + targetText: "pending input thread", + }); + + return { + ...snapshot, + threads: [ + { + ...snapshot.threads[0]!, + id: THREAD_ID, + title: "Pending input thread", + activities: [ + { + id: EventId.makeUnsafe("evt-user-input-requested"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "req-plan-1", + questions: [ + { + id: "scope", + header: "Scope", + question: "Which scope should the fix target first?", + options: [ + { + label: "Client-local", + description: "Persist answers in local draft state", + }, + { + label: "Server-wide", + description: "Persist answers in the server read model", + }, + ], + }, + { + id: "compat", + header: "Compatibility", + question: "How strict should compatibility be?", + options: [ + { + label: "Keep current behavior", + description: "Preserve existing runtime behavior", + }, + { + label: "Allow cleanup", + description: "Permit small UX cleanup during the fix", + }, + ], + }, + ], + }, + turnId: null, + sequence: 1, + createdAt: isoAt(50), + }, + ], + }, + { + id: SECOND_THREAD_ID, + projectId: PROJECT_ID, + title: "Other thread", + model: "gpt-5", + interactionMode: "default", + runtimeMode: "full-access", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + messages: [ + createUserMessage({ + id: "msg-user-other-thread" as MessageId, + text: "other thread", + offsetSeconds: 400, + }), + ], + activities: [], + proposedPlans: [], + checkpoints: [], + session: { + threadId: SECOND_THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + ], + }; +} + function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-target" as MessageId, @@ -1048,6 +1147,96 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("persists pending plan-mode answers across thread re-entry", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInputThreads(), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("Which scope should the fix target first?"); + }, + { timeout: 8_000, interval: 16 }, + ); + + const firstAnswerButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes("Client-local"), + ) as HTMLButtonElement | null, + "Unable to find the first pending input option button.", + ); + firstAnswerButton.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("How strict should compatibility be?"); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect( + useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.pendingUserInputsByRequestId[ + "req-plan-1" + ], + ).toMatchObject({ + questionIndex: 1, + answersByQuestionId: { + scope: { + selectedOptionLabel: "Client-local", + }, + }, + }); + + await mounted.router.navigate({ + to: "/$threadId", + params: { threadId: SECOND_THREAD_ID }, + }); + await waitForURL( + mounted.router, + (path) => path === `/${SECOND_THREAD_ID}`, + "Route should change to the second thread.", + ); + + await mounted.router.navigate({ + to: "/$threadId", + params: { threadId: THREAD_ID }, + }); + await waitForURL( + mounted.router, + (path) => path === `/${THREAD_ID}`, + "Route should change back to the original thread.", + ); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("How strict should compatibility be?"); + expect(document.body.textContent).not.toContain( + "Which scope should the fix target first?", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + expect( + useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.pendingUserInputsByRequestId[ + "req-plan-1" + ], + ).toMatchObject({ + questionIndex: 1, + answersByQuestionId: { + scope: { + selectedOptionLabel: "Client-local", + }, + }, + }); + } finally { + await mounted.cleanup(); + } + }); + it("keeps long proposed plans lightweight until the user expands them", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..0837a5fdc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -612,6 +612,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); + const setComposerDraftPendingUserInputAnswer = useComposerDraftStore( + (store) => store.setPendingUserInputAnswer, + ); + const setComposerDraftPendingUserInputQuestionIndex = useComposerDraftStore( + (store) => store.setPendingUserInputQuestionIndex, + ); + const syncComposerDraftPendingUserInputRequests = useComposerDraftStore( + (store) => store.syncPendingUserInputRequests, + ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftThreadByProjectId = useComposerDraftStore( @@ -642,11 +651,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> - >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); @@ -976,13 +980,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const activePendingDraftAnswers = useMemo( () => activePendingUserInput - ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? - EMPTY_PENDING_USER_INPUT_ANSWERS) + ? (composerDraft.pendingUserInputsByRequestId[activePendingUserInput.requestId] + ?.answersByQuestionId ?? EMPTY_PENDING_USER_INPUT_ANSWERS) : EMPTY_PENDING_USER_INPUT_ANSWERS, - [activePendingUserInput, pendingUserInputAnswersByRequestId], + [activePendingUserInput, composerDraft.pendingUserInputsByRequestId], ); const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) + ? (composerDraft.pendingUserInputsByRequestId[activePendingUserInput.requestId] + ?.questionIndex ?? 0) : 0; const activePendingProgress = useMemo( () => @@ -1005,6 +1010,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const activePendingIsResponding = activePendingUserInput ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) : false; + useEffect(() => { + if (!activeThread?.id) { + return; + } + syncComposerDraftPendingUserInputRequests( + activeThread.id, + pendingUserInputs.map((pendingUserInput) => pendingUserInput.requestId), + ); + }, [activeThread?.id, pendingUserInputs, syncComposerDraftPendingUserInputRequests]); const activeProposedPlan = useMemo(() => { if (!latestTurnSettled) { return null; @@ -2899,12 +2913,13 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingUserInput) { return; } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: nextQuestionIndex, - })); + setComposerDraftPendingUserInputQuestionIndex( + threadId, + activePendingUserInput.requestId, + nextQuestionIndex, + ); }, - [activePendingUserInput], + [activePendingUserInput, setComposerDraftPendingUserInputQuestionIndex, threadId], ); const onSelectActivePendingUserInputOption = useCallback( @@ -2912,21 +2927,20 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activePendingUserInput) { return; } - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: { - selectedOptionLabel: optionLabel, - customAnswer: "", - }, + setComposerDraftPendingUserInputAnswer( + threadId, + activePendingUserInput.requestId, + questionId, + { + selectedOptionLabel: optionLabel, + customAnswer: "", }, - })); + ); promptRef.current = ""; setComposerCursor(0); setComposerTrigger(null); }, - [activePendingUserInput], + [activePendingUserInput, setComposerDraftPendingUserInputAnswer, threadId], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -2935,16 +2949,12 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[questionId], - value, - ), - }, - })); + setComposerDraftPendingUserInputAnswer( + threadId, + activePendingUserInput.requestId, + questionId, + setPendingUserInputCustomAnswer(activePendingDraftAnswers[questionId], value), + ); setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention @@ -2952,7 +2962,12 @@ export default function ChatView({ threadId }: ChatViewProps) { : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), ); }, - [activePendingUserInput], + [ + activePendingDraftAnswers, + activePendingUserInput, + setComposerDraftPendingUserInputAnswer, + threadId, + ], ); const onAdvanceActivePendingUserInput = useCallback(() => { @@ -3288,16 +3303,15 @@ export default function ChatView({ threadId }: ChatViewProps) { promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [activePendingQuestion.id]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], - next.text, - ), - }, - })); + setComposerDraftPendingUserInputAnswer( + threadId, + activePendingUserInput.requestId, + activePendingQuestion.id, + setPendingUserInputCustomAnswer( + activePendingDraftAnswers[activePendingQuestion.id], + next.text, + ), + ); } else { setPrompt(next.text); } @@ -3308,7 +3322,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return true; }, - [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], + [ + activePendingDraftAnswers, + activePendingProgress?.activeQuestion, + activePendingUserInput, + setComposerDraftPendingUserInputAnswer, + setPrompt, + threadId, + ], ); const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 927a16060..2d4a8c99a 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -158,6 +158,140 @@ describe("composerDraftStore clearComposerContent", () => { }); }); +describe("composerDraftStore pending user input drafts", () => { + const threadId = ThreadId.makeUnsafe("thread-pending-user-input"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("stores pending user input answers and question index inside the thread draft", () => { + const store = useComposerDraftStore.getState(); + + store.setPendingUserInputAnswer(threadId, "req-1", "scope", { + selectedOptionLabel: "Client-local", + customAnswer: "", + }); + store.setPendingUserInputQuestionIndex(threadId, "req-1", 1); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + pendingUserInputsByRequestId: { + "req-1": { + questionIndex: 1, + answersByQuestionId: { + scope: { + selectedOptionLabel: "Client-local", + customAnswer: "", + }, + }, + }, + }, + }); + }); + + it("preserves pending user input drafts even when the normal composer content is empty", () => { + const store = useComposerDraftStore.getState(); + + store.setPendingUserInputAnswer(threadId, "req-1", "scope", { + selectedOptionLabel: "Client-local", + customAnswer: "", + }); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeDefined(); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.pendingUserInputsByRequestId, + ).toMatchObject({ + "req-1": { + questionIndex: 0, + answersByQuestionId: { + scope: { + selectedOptionLabel: "Client-local", + customAnswer: "", + }, + }, + }, + }); + }); + + it("clears only the targeted pending user input request", () => { + const store = useComposerDraftStore.getState(); + + store.setPendingUserInputAnswer(threadId, "req-1", "scope", { + selectedOptionLabel: "Client-local", + }); + store.setPendingUserInputAnswer(threadId, "req-2", "compat", { + customAnswer: "Keep it local", + }); + + store.clearPendingUserInputRequest(threadId, "req-1"); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + pendingUserInputsByRequestId: { + "req-2": { + questionIndex: 0, + answersByQuestionId: { + compat: { + customAnswer: "Keep it local", + }, + }, + }, + }, + }); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.pendingUserInputsByRequestId[ + "req-1" + ], + ).toBeUndefined(); + }); + + it("removes stale pending user input requests when synced with the active request list", () => { + const store = useComposerDraftStore.getState(); + + store.setPendingUserInputAnswer(threadId, "req-1", "scope", { + selectedOptionLabel: "Client-local", + }); + store.setPendingUserInputAnswer(threadId, "req-2", "compat", { + customAnswer: "Keep it local", + }); + + store.syncPendingUserInputRequests(threadId, ["req-2"]); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + pendingUserInputsByRequestId: { + "req-2": { + questionIndex: 0, + answersByQuestionId: { + compat: { + customAnswer: "Keep it local", + }, + }, + }, + }, + }); + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.pendingUserInputsByRequestId[ + "req-1" + ], + ).toBeUndefined(); + }); + + it("clears pending user input drafts when the thread draft is cleared", () => { + const store = useComposerDraftStore.getState(); + + store.setPendingUserInputAnswer(threadId, "req-1", "scope", { + selectedOptionLabel: "Client-local", + }); + + store.clearThreadDraft(threadId); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); +}); + describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.makeUnsafe("project-a"); const otherProjectId = ProjectId.makeUnsafe("project-b"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..9051ba483 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; +import type { PendingUserInputDraftAnswer } from "./pendingUserInput"; import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; @@ -81,6 +82,7 @@ interface PersistedComposerThreadDraftState { effort?: CodexReasoningEffort | null; codexFastMode?: boolean | null; serviceTier?: string | null; + pendingUserInputsByRequestId?: Record; } interface PersistedDraftThreadState { @@ -110,6 +112,17 @@ interface ComposerThreadDraftState { interactionMode: ProviderInteractionMode | null; effort: CodexReasoningEffort | null; codexFastMode: boolean; + pendingUserInputsByRequestId: Record; +} + +interface PersistedPendingUserInputRequestDraftState { + questionIndex: number; + answersByQuestionId: Record; +} + +interface PendingUserInputRequestDraftState { + questionIndex: number; + answersByQuestionId: Record; } export interface DraftThreadState { @@ -177,6 +190,19 @@ interface ComposerDraftStoreState { threadId: ThreadId, attachments: PersistedComposerImageAttachment[], ) => void; + setPendingUserInputAnswer: ( + threadId: ThreadId, + requestId: string, + questionId: string, + draftAnswer: PendingUserInputDraftAnswer | undefined, + ) => void; + setPendingUserInputQuestionIndex: ( + threadId: ThreadId, + requestId: string, + questionIndex: number, + ) => void; + syncPendingUserInputRequests: (threadId: ThreadId, requestIds: string[]) => void; + clearPendingUserInputRequest: (threadId: ThreadId, requestId: string) => void; clearComposerContent: (threadId: ThreadId) => void; clearThreadDraft: (threadId: ThreadId) => void; } @@ -190,9 +216,12 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; +const EMPTY_PENDING_USER_INPUTS_BY_REQUEST_ID: Record = + {}; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); +Object.freeze(EMPTY_PENDING_USER_INPUTS_BY_REQUEST_ID); const EMPTY_THREAD_DRAFT = Object.freeze({ prompt: "", images: EMPTY_IMAGES, @@ -204,7 +233,8 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ interactionMode: null, effort: null, codexFastMode: false, -}) as ComposerThreadDraftState; + pendingUserInputsByRequestId: EMPTY_PENDING_USER_INPUTS_BY_REQUEST_ID, +}) satisfies ComposerThreadDraftState; const REASONING_EFFORT_VALUES = new Set( REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, @@ -222,6 +252,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { interactionMode: null, effort: null, codexFastMode: false, + pendingUserInputsByRequestId: {}, }; } @@ -241,7 +272,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && - draft.codexFastMode === false + draft.codexFastMode === false && + Object.keys(draft.pendingUserInputsByRequestId).length === 0 ); } @@ -300,6 +332,66 @@ function normalizeDraftThreadEnvMode( return fallbackWorktreePath ? "worktree" : "local"; } +function normalizePendingUserInputDraftAnswer(value: unknown): PendingUserInputDraftAnswer | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as Record; + const selectedOptionLabel = + typeof candidate.selectedOptionLabel === "string" && candidate.selectedOptionLabel.length > 0 + ? candidate.selectedOptionLabel + : undefined; + const customAnswer = + typeof candidate.customAnswer === "string" ? candidate.customAnswer : undefined; + if (selectedOptionLabel === undefined && customAnswer === undefined) { + return null; + } + return { + ...(selectedOptionLabel !== undefined ? { selectedOptionLabel } : {}), + ...(customAnswer !== undefined ? { customAnswer } : {}), + }; +} + +function normalizePendingUserInputRequestDraftState( + value: unknown, +): PendingUserInputRequestDraftState | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as Record; + const rawAnswersByQuestionId = candidate.answersByQuestionId; + const answersByQuestionId: Record = {}; + if (rawAnswersByQuestionId && typeof rawAnswersByQuestionId === "object") { + for (const [questionId, draftAnswer] of Object.entries( + rawAnswersByQuestionId as Record, + )) { + if (questionId.length === 0) { + continue; + } + const normalizedAnswer = normalizePendingUserInputDraftAnswer(draftAnswer); + if (!normalizedAnswer) { + continue; + } + answersByQuestionId[questionId] = normalizedAnswer; + } + } + + const rawQuestionIndex = candidate.questionIndex; + const questionIndex = + typeof rawQuestionIndex === "number" && Number.isFinite(rawQuestionIndex) + ? Math.max(0, Math.floor(rawQuestionIndex)) + : 0; + + if (questionIndex === 0 && Object.keys(answersByQuestionId).length === 0) { + return null; + } + + return { + questionIndex, + answersByQuestionId, + }; +} + function normalizePersistedComposerDraftState(value: unknown): PersistedComposerDraftStoreState { if (!value || typeof value !== "object") { return EMPTY_PERSISTED_DRAFT_STORE_STATE; @@ -427,6 +519,24 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer const codexFastMode = draftCandidate.codexFastMode === true || (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast"); + const pendingUserInputsByRequestId: Record = {}; + if ( + draftCandidate.pendingUserInputsByRequestId && + typeof draftCandidate.pendingUserInputsByRequestId === "object" + ) { + for (const [requestId, requestDraft] of Object.entries( + draftCandidate.pendingUserInputsByRequestId as Record, + )) { + if (requestId.length === 0) { + continue; + } + const normalizedRequestDraft = normalizePendingUserInputRequestDraftState(requestDraft); + if (!normalizedRequestDraft) { + continue; + } + pendingUserInputsByRequestId[requestId] = normalizedRequestDraft; + } + } if ( prompt.length === 0 && attachments.length === 0 && @@ -435,7 +545,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer !runtimeMode && !interactionMode && !effort && - !codexFastMode + !codexFastMode && + Object.keys(pendingUserInputsByRequestId).length === 0 ) { continue; } @@ -448,6 +559,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer ...(interactionMode ? { interactionMode } : {}), ...(effort ? { effort } : {}), ...(codexFastMode ? { codexFastMode } : {}), + ...(Object.keys(pendingUserInputsByRequestId).length > 0 + ? { pendingUserInputsByRequestId } + : {}), }; } return { @@ -554,6 +668,11 @@ function toHydratedThreadDraft( interactionMode: persistedDraft.interactionMode ?? null, effort: persistedDraft.effort ?? null, codexFastMode: persistedDraft.codexFastMode === true, + pendingUserInputsByRequestId: Object.fromEntries( + Object.entries(persistedDraft.pendingUserInputsByRequestId ?? {}).map( + ([requestId, requestDraft]) => [requestId, requestDraft], + ), + ), }; } @@ -1144,6 +1263,152 @@ export const useComposerDraftStore = create()( }); }); }, + setPendingUserInputAnswer: (threadId, requestId, questionId, draftAnswer) => { + if (threadId.length === 0 || requestId.length === 0 || questionId.length === 0) { + return; + } + const normalizedDraftAnswer = normalizePendingUserInputDraftAnswer(draftAnswer); + set((state) => { + const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); + const existingRequestDraft = existing.pendingUserInputsByRequestId[requestId] ?? { + questionIndex: 0, + answersByQuestionId: {}, + }; + const nextAnswersByQuestionId = { ...existingRequestDraft.answersByQuestionId }; + if (normalizedDraftAnswer) { + nextAnswersByQuestionId[questionId] = normalizedDraftAnswer; + } else { + delete nextAnswersByQuestionId[questionId]; + } + const nextPendingUserInputsByRequestId = { + ...existing.pendingUserInputsByRequestId, + }; + if ( + existingRequestDraft.questionIndex === 0 && + Object.keys(nextAnswersByQuestionId).length === 0 + ) { + delete nextPendingUserInputsByRequestId[requestId]; + } else { + nextPendingUserInputsByRequestId[requestId] = { + questionIndex: existingRequestDraft.questionIndex, + answersByQuestionId: nextAnswersByQuestionId, + }; + } + const nextDraft: ComposerThreadDraftState = { + ...existing, + pendingUserInputsByRequestId: nextPendingUserInputsByRequestId, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, + setPendingUserInputQuestionIndex: (threadId, requestId, questionIndex) => { + if (threadId.length === 0 || requestId.length === 0) { + return; + } + const nextQuestionIndex = + Number.isFinite(questionIndex) && questionIndex > 0 ? Math.floor(questionIndex) : 0; + set((state) => { + const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); + const existingRequestDraft = existing.pendingUserInputsByRequestId[requestId] ?? { + questionIndex: 0, + answersByQuestionId: {}, + }; + if (existingRequestDraft.questionIndex === nextQuestionIndex) { + return state; + } + const nextPendingUserInputsByRequestId = { + ...existing.pendingUserInputsByRequestId, + }; + if ( + nextQuestionIndex === 0 && + Object.keys(existingRequestDraft.answersByQuestionId).length === 0 + ) { + delete nextPendingUserInputsByRequestId[requestId]; + } else { + nextPendingUserInputsByRequestId[requestId] = { + questionIndex: nextQuestionIndex, + answersByQuestionId: existingRequestDraft.answersByQuestionId, + }; + } + const nextDraft: ComposerThreadDraftState = { + ...existing, + pendingUserInputsByRequestId: nextPendingUserInputsByRequestId, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, + syncPendingUserInputRequests: (threadId, requestIds) => { + if (threadId.length === 0) { + return; + } + const openRequestIds = new Set(requestIds.filter((requestId) => requestId.length > 0)); + set((state) => { + const existing = state.draftsByThreadId[threadId]; + if (!existing) { + return state; + } + const nextPendingUserInputsByRequestId = Object.fromEntries( + Object.entries(existing.pendingUserInputsByRequestId).filter(([requestId]) => + openRequestIds.has(requestId), + ), + ); + if ( + Object.keys(nextPendingUserInputsByRequestId).length === + Object.keys(existing.pendingUserInputsByRequestId).length + ) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...existing, + pendingUserInputsByRequestId: nextPendingUserInputsByRequestId, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, + clearPendingUserInputRequest: (threadId, requestId) => { + if (threadId.length === 0 || requestId.length === 0) { + return; + } + set((state) => { + const existing = state.draftsByThreadId[threadId]; + if (!existing || existing.pendingUserInputsByRequestId[requestId] === undefined) { + return state; + } + const nextPendingUserInputsByRequestId = { + ...existing.pendingUserInputsByRequestId, + }; + delete nextPendingUserInputsByRequestId[requestId]; + const nextDraft: ComposerThreadDraftState = { + ...existing, + pendingUserInputsByRequestId: nextPendingUserInputsByRequestId, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); + }, clearComposerContent: (threadId) => { if (threadId.length === 0) { return; @@ -1223,7 +1488,8 @@ export const useComposerDraftStore = create()( draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && - draft.codexFastMode === false + draft.codexFastMode === false && + Object.keys(draft.pendingUserInputsByRequestId).length === 0 ) { continue; } @@ -1249,6 +1515,19 @@ export const useComposerDraftStore = create()( if (draft.codexFastMode) { persistedDraft.codexFastMode = true; } + if (Object.keys(draft.pendingUserInputsByRequestId).length > 0) { + persistedDraft.pendingUserInputsByRequestId = Object.fromEntries( + Object.entries(draft.pendingUserInputsByRequestId).map( + ([requestId, requestDraft]) => [ + requestId, + { + questionIndex: requestDraft.questionIndex, + answersByQuestionId: requestDraft.answersByQuestionId, + }, + ], + ), + ); + } persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; } return {