From 8d1b2de48b6bc5c609f051c5d4b8ff3302769f91 Mon Sep 17 00:00:00 2001 From: jamesx0416 Date: Mon, 16 Mar 2026 19:46:02 +1100 Subject: [PATCH 1/7] Persist pending input drafts and fix preset/custom answer handling - Move pending user-input draft state into a per-thread draft store - Track explicit answer source (`option` vs `custom`) to avoid stale custom text overriding preset picks - Preserve pending input edits/cursor behavior across caret moves and thread navigation - Update pending-input panel flow to auto-advance/submit on option select and add coverage --- apps/web/src/components/ChatView.tsx | 304 ++++++++++++++---- .../chat/ComposerPendingUserInputPanel.tsx | 64 ++-- apps/web/src/pendingUserInput.test.ts | 90 +++++- apps/web/src/pendingUserInput.ts | 60 +++- apps/web/src/pendingUserInputDraftStore.ts | 190 +++++++++++ 5 files changed, 608 insertions(+), 100 deletions(-) create mode 100644 apps/web/src/pendingUserInputDraftStore.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf5bb9de5e9..533efcb2ad2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,10 +78,16 @@ import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, + setPendingUserInputSelectedOption, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useUiStateStore } from "../uiStateStore"; +import { + usePendingUserInputDraftStore, + usePendingUserInputThreadDraft, +} from "../pendingUserInputDraftStore"; +import { useStore } from "../store"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -243,6 +249,9 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +// ponytail: window during which we suppress restoring a just-cleared custom +// draft back into the composer, so clicking a preset doesn't echo stale text. +const PENDING_CUSTOM_RESTORE_SUPPRESSION_MS = 500; const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); @@ -1078,7 +1087,15 @@ function ChatViewContent(props: ChatViewProps) { ? store.getDraftSession(draftId) : null, ); - const promptRef = useRef(""); + const pendingUserInputDraftThread = usePendingUserInputThreadDraft(threadId); + const setPendingUserInputDraftQuestionIndex = usePendingUserInputDraftStore( + (store) => store.setQuestionIndex, + ); + const setPendingUserInputDraftAnswer = usePendingUserInputDraftStore((store) => store.setAnswer); + const clearInactivePendingUserInputDraftRequests = usePendingUserInputDraftStore( + (store) => store.clearInactiveRequests, + ); + const promptRef = useRef(prompt); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); @@ -1104,12 +1121,10 @@ function ChatViewContent(props: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); - const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< - Record> - >({}); - const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = - useState>({}); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -1693,17 +1708,21 @@ function ChatViewContent(props: ChatViewProps) { () => derivePendingUserInputs(threadActivities), [threadActivities], ); + const activePendingUserInputRequestIds = useMemo( + () => pendingUserInputs.map((pendingUserInput) => pendingUserInput.requestId), + [pendingUserInputs], + ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => activePendingUserInput - ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? + ? (pendingUserInputDraftThread.answersByRequestId[activePendingUserInput.requestId] ?? EMPTY_PENDING_USER_INPUT_ANSWERS) : EMPTY_PENDING_USER_INPUT_ANSWERS, - [activePendingUserInput, pendingUserInputAnswersByRequestId], + [activePendingUserInput, pendingUserInputDraftThread.answersByRequestId], ); const activePendingQuestionIndex = activePendingUserInput - ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) + ? (pendingUserInputDraftThread.questionIndexByRequestId[activePendingUserInput.requestId] ?? 0) : 0; const activePendingProgress = useMemo( () => @@ -1776,6 +1795,95 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.session ?? null, localDispatchStartedAt, ); + const isComposerApprovalState = activePendingApproval !== null; + const hasComposerHeader = + isComposerApprovalState || + pendingUserInputs.length > 0 || + (showPlanFollowUpPrompt && activeProposedPlan !== null); + const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + useEffect(() => { + clearInactivePendingUserInputDraftRequests(threadId, activePendingUserInputRequestIds); + }, [activePendingUserInputRequestIds, clearInactivePendingUserInputDraftRequests, threadId]); + const lastSyncedPendingInputRef = useRef<{ + requestId: string | null; + questionId: string | null; + } | null>(null); + const pendingAnswerWriteRef = useRef<{ + requestId: string | null; + questionId: string | null; + source: "option" | "custom" | null; + }>({ + requestId: null, + questionId: null, + source: null, + }); + const suppressedPendingCustomRestoreRef = useRef<{ + requestId: string | null; + value: string | null; + expiresAtMs: number; + }>({ + requestId: null, + value: null, + expiresAtMs: 0, + }); + useEffect(() => { + const nextCustomAnswer = activePendingProgress?.customAnswer; + if (typeof nextCustomAnswer !== "string") { + lastSyncedPendingInputRef.current = null; + pendingAnswerWriteRef.current = { + requestId: null, + questionId: null, + source: null, + }; + suppressedPendingCustomRestoreRef.current = { + requestId: null, + value: null, + expiresAtMs: 0, + }; + return; + } + const nextRequestId = activePendingUserInput?.requestId ?? null; + const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; + const questionChanged = + lastSyncedPendingInputRef.current?.requestId !== nextRequestId || + lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; + const textChangedExternally = promptRef.current !== nextCustomAnswer; + + lastSyncedPendingInputRef.current = { + requestId: nextRequestId, + questionId: nextQuestionId, + }; + if ( + pendingAnswerWriteRef.current.requestId !== nextRequestId || + pendingAnswerWriteRef.current.questionId !== nextQuestionId + ) { + pendingAnswerWriteRef.current = { + requestId: nextRequestId, + questionId: nextQuestionId, + source: activePendingProgress?.activeDraft?.answerSource ?? null, + }; + } + + if (!questionChanged && !textChangedExternally) { + return; + } + + promptRef.current = nextCustomAnswer; + const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); + setComposerCursor(nextCursor); + setComposerTrigger( + detectComposerTrigger( + nextCustomAnswer, + expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), + ), + ); + setComposerHighlightedItemId(null); + }, [ + activePendingProgress?.activeDraft?.answerSource, + activePendingProgress?.customAnswer, + activePendingUserInput?.requestId, + activePendingProgress?.activeQuestion?.id, + ]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -3210,6 +3318,14 @@ function ChatViewContent(props: ChatViewProps) { }; }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); + useEffect(() => { + if (activePendingProgress?.activeQuestion) { + return; + } + promptRef.current = prompt; + setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); + }, [activePendingProgress?.activeQuestion, prompt]); + useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -3974,45 +4090,101 @@ function ChatViewContent(props: ChatViewProps) { if (!activePendingUserInput) { return; } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: nextQuestionIndex, - })); + setPendingUserInputDraftQuestionIndex( + threadId, + activePendingUserInput.requestId, + nextQuestionIndex, + ); }, - [activePendingUserInput], + [activePendingUserInput, setPendingUserInputDraftQuestionIndex, threadId], ); const onSelectActivePendingUserInputOption = useCallback( - (questionId: string, optionLabel: string) => { + ( + questionId: string, + optionLabel: string, + options?: { + advanceToNextQuestion?: boolean; + submitIfComplete?: boolean; + }, + ) => { if (!activePendingUserInput) { return; } - setPendingUserInputAnswersByRequestId((existing) => { - const question = - (activePendingProgress?.activeQuestion?.id === questionId - ? activePendingProgress.activeQuestion - : undefined) ?? - activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question) { - return existing; - } - - return { - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: togglePendingUserInputOptionSelection( - question, - existing[activePendingUserInput.requestId]?.[questionId], - optionLabel, - ), - }, + const question = + activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return; + } + const previousDraft = activePendingDraftAnswers[questionId]; + const isMultiSelect = question.multiSelect === true; + // ponytail: option source is authoritative so a stale custom draft cannot + // override a clicked preset at submit (#918/#528 race). + const nextDraftAnswer = isMultiSelect + ? togglePendingUserInputOptionSelection(question, previousDraft, optionLabel) + : setPendingUserInputSelectedOption(previousDraft, optionLabel); + pendingAnswerWriteRef.current = { + requestId: activePendingUserInput.requestId, + questionId, + source: "option", + }; + if (!isMultiSelect) { + suppressedPendingCustomRestoreRef.current = { + requestId: activePendingUserInput.requestId, + value: + typeof previousDraft?.customAnswer === "string" && previousDraft.customAnswer.length > 0 + ? previousDraft.customAnswer + : null, + expiresAtMs: performance.now() + PENDING_CUSTOM_RESTORE_SUPPRESSION_MS, }; - }); + } + setPendingUserInputDraftAnswer( + threadId, + activePendingUserInput.requestId, + questionId, + nextDraftAnswer, + ); promptRef.current = ""; - composerRef.current?.resetCursorState({ cursor: 0 }); + setComposerCursor(0); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + // Multi-select toggles in place; single-select advances or submits. + if (isMultiSelect) { + return; + } + if (options?.submitIfComplete) { + const nextResolvedAnswers = buildPendingUserInputAnswers(activePendingUserInput.questions, { + ...activePendingDraftAnswers, + [questionId]: nextDraftAnswer, + }); + if (nextResolvedAnswers) { + void onRespondToUserInput(activePendingUserInput.requestId, nextResolvedAnswers); + } + return; + } + if ( + options?.advanceToNextQuestion && + activePendingProgress && + !activePendingProgress.isLastQuestion + ) { + setPendingUserInputDraftQuestionIndex( + threadId, + activePendingUserInput.requestId, + activePendingProgress.questionIndex + 1, + ); + } }, - [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], + [ + activePendingDraftAnswers, + activePendingProgress, + activePendingUserInput, + onRespondToUserInput, + setPendingUserInputDraftAnswer, + setPendingUserInputDraftQuestionIndex, + threadId, + ], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -4026,27 +4198,48 @@ function ChatViewContent(props: ChatViewProps) { if (!activePendingUserInput) { return; } - promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[questionId], - value, - ), - }, - })); - const snapshot = composerRef.current?.readSnapshot(); if ( - snapshot?.value !== value || - snapshot.cursor !== nextCursor || - snapshot.expandedCursor !== expandedCursor + suppressedPendingCustomRestoreRef.current.requestId === activePendingUserInput.requestId && + suppressedPendingCustomRestoreRef.current.value !== null && + suppressedPendingCustomRestoreRef.current.value === value && + suppressedPendingCustomRestoreRef.current.expiresAtMs >= performance.now() + ) { + return; + } + suppressedPendingCustomRestoreRef.current = { + requestId: null, + value: null, + expiresAtMs: 0, + }; + if ( + pendingAnswerWriteRef.current.requestId === activePendingUserInput.requestId && + pendingAnswerWriteRef.current.questionId === questionId && + pendingAnswerWriteRef.current.source === "option" && + value === activePendingDraftAnswers[questionId]?.customAnswer ) { - composerRef.current?.focusAt(nextCursor); + return; } + pendingAnswerWriteRef.current = { + requestId: activePendingUserInput.requestId, + questionId, + source: + value.trim().length > 0 + ? "custom" + : (activePendingDraftAnswers[questionId]?.answerSource ?? null), + }; + promptRef.current = value; + setPendingUserInputDraftAnswer( + threadId, + activePendingUserInput.requestId, + questionId, + setPendingUserInputCustomAnswer(activePendingDraftAnswers[questionId], value), + ); + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), + ); }, - [activePendingUserInput, composerRef], + [activePendingDraftAnswers, activePendingUserInput, setPendingUserInputDraftAnswer, threadId], ); const onAdvanceActivePendingUserInput = useCallback(() => { @@ -4834,6 +5027,7 @@ function ChatViewContent(props: ChatViewProps) { onPreviousActivePendingUserInputQuestion={ onPreviousActivePendingUserInputQuestion } + } onChangeActivePendingUserInputCustomAnswer={ onChangeActivePendingUserInputCustomAnswer } diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index bf869d25c66..4bc0f84199e 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,5 +1,5 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; -import { memo, useEffect, useEffectEvent, useRef, useState } from "react"; +import { memo, useEffect, useEffectEvent, useState } from "react"; import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, @@ -13,8 +13,14 @@ interface PendingUserInputPanelProps { respondingRequestIds: ApprovalRequestId[]; answers: Record; questionIndex: number; - onToggleOption: (questionId: string, optionLabel: string) => void; - onAdvance: () => void; + onSelectOption: ( + questionId: string, + optionLabel: string, + options?: { + advanceToNextQuestion?: boolean; + submitIfComplete?: boolean; + }, + ) => void; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ @@ -22,8 +28,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn respondingRequestIds, answers, questionIndex, - onToggleOption, - onAdvance, + onSelectOption, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; @@ -36,8 +41,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn isResponding={respondingRequestIds.includes(activePrompt.requestId)} answers={answers} questionIndex={questionIndex} - onToggleOption={onToggleOption} - onAdvance={onAdvance} + onSelectOption={onSelectOption} /> ); }); @@ -47,29 +51,28 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isResponding, answers, questionIndex, - onToggleOption, - onAdvance, + onSelectOption, }: { prompt: PendingUserInput; isResponding: boolean; answers: Record; questionIndex: number; - onToggleOption: (questionId: string, optionLabel: string) => void; - onAdvance: () => void; + onSelectOption: ( + questionId: string, + optionLabel: string, + options?: { + advanceToNextQuestion?: boolean; + submitIfComplete?: boolean; + }, + ) => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; - const autoAdvanceTimerRef = useRef(null); - const onAdvanceRef = useRef(onAdvance); const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ questionId: string; optionLabel: string; } | null>(null); - useEffect(() => { - onAdvanceRef.current = onAdvance; - }, [onAdvance]); - useEffect(() => { if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) { return; @@ -91,29 +94,20 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( progress.selectedOptionLabels, ]); - // Clear auto-advance timer on unmount - useEffect(() => { - return () => { - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - }; - }, []); - + // ponytail: deterministic auto-advance/submit replaces the prior timeout race. + // Selecting a preset immediately advances (or submits on the last question) + // instead of waiting on a timer that could fire after stale custom edits. const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { if (activeQuestion?.multiSelect) { - onToggleOption(questionId, optionLabel); + onSelectOption(questionId, optionLabel); return; } setOptimisticSingleSelect({ questionId, optionLabel }); - onToggleOption(questionId, optionLabel); - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - autoAdvanceTimerRef.current = window.setTimeout(() => { - autoAdvanceTimerRef.current = null; - onAdvanceRef.current(); - }, 200); + onSelectOption( + questionId, + optionLabel, + progress.isLastQuestion ? { submitIfComplete: true } : { advanceToNextQuestion: true }, + ); }); // Keyboard shortcut: number keys 1-9 select corresponding options when focus is diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 3d1cb336e1f..5edd0f88106 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -7,6 +7,7 @@ import { findFirstUnansweredPendingUserInputQuestionIndex, resolvePendingUserInputAnswer, setPendingUserInputCustomAnswer, + setPendingUserInputSelectedOption, togglePendingUserInputOptionSelection, } from "./pendingUserInput"; @@ -41,7 +42,27 @@ const multiSelectQuestion = { } as const; describe("resolvePendingUserInputAnswer", () => { - it("prefers a custom answer over selected options", () => { + it("resolves a custom answer when the explicit source is custom", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + answerSource: "custom", + selectedOptionLabels: ["Keep current envelope"], + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("resolves a selected option when the explicit source is option", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + answerSource: "option", + selectedOptionLabels: ["Scaffold only"], + customAnswer: "Old custom answer", + }), + ).toBe("Scaffold only"); + }); + + it("prefers a custom answer over a selected option for legacy drafts", () => { expect( resolvePendingUserInputAnswer(singleSelectQuestion, { selectedOptionLabels: ["Orchestration-first"], @@ -70,20 +91,55 @@ describe("resolvePendingUserInputAnswer", () => { expect( setPendingUserInputCustomAnswer( { - selectedOptionLabels: ["Server", "Web"], + answerSource: "option", + selectedOptionLabels: ["Preserve existing tags"], }, "doesn't matter", ), ).toEqual({ + answerSource: "custom", customAnswer: "doesn't matter", }); }); + + it("keeps the selected option active when custom text is cleared", () => { + expect( + setPendingUserInputCustomAnswer( + { + answerSource: "option", + selectedOptionLabels: ["Preserve existing tags"], + }, + "", + ), + ).toEqual({ + answerSource: "option", + selectedOptionLabels: ["Preserve existing tags"], + customAnswer: "", + }); + }); + + it("sets the selected option as the explicit answer source", () => { + expect( + setPendingUserInputSelectedOption( + { + answerSource: "custom", + customAnswer: "Keep the old custom answer", + }, + "Preserve existing tags", + ), + ).toEqual({ + answerSource: "option", + selectedOptionLabels: ["Preserve existing tags"], + customAnswer: "", + }); + }); }); describe("togglePendingUserInputOptionSelection", () => { it("toggles options for multi-select questions", () => { expect(togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Server")).toEqual( { + answerSource: "option", customAnswer: "", selectedOptionLabels: ["Server"], }, @@ -98,6 +154,7 @@ describe("togglePendingUserInputOptionSelection", () => { "Server", ), ).toEqual({ + answerSource: "option", customAnswer: "", selectedOptionLabels: ["Web"], }); @@ -125,9 +182,11 @@ describe("buildPendingUserInputAnswers", () => { ], { scope: { + answerSource: "option", selectedOptionLabels: ["Orchestration-first"], }, compat: { + answerSource: "custom", customAnswer: "Keep the current envelope for one release window", }, }, @@ -176,6 +235,7 @@ describe("pending user input question progress", () => { expect( countAnsweredPendingUserInputQuestions(questions, { scope: { + answerSource: "option", selectedOptionLabels: ["Orchestration-first"], }, }), @@ -186,6 +246,7 @@ describe("pending user input question progress", () => { expect( findFirstUnansweredPendingUserInputQuestionIndex(questions, { scope: { + answerSource: "option", selectedOptionLabels: ["Orchestration-first"], }, }), @@ -196,9 +257,11 @@ describe("pending user input question progress", () => { expect( findFirstUnansweredPendingUserInputQuestionIndex(questions, { scope: { + answerSource: "option", selectedOptionLabels: ["Orchestration-first"], }, compat: { + answerSource: "custom", customAnswer: "Keep it for one release window", }, }), @@ -211,6 +274,7 @@ describe("pending user input question progress", () => { questions, { scope: { + answerSource: "option", selectedOptionLabels: ["Orchestration-first"], }, }, @@ -222,6 +286,7 @@ describe("pending user input question progress", () => { selectedOptionLabels: ["Orchestration-first"], customAnswer: "", resolvedAnswer: "Orchestration-first", + usingCustomAnswer: false, answeredQuestionCount: 1, isLastQuestion: false, isComplete: false, @@ -247,4 +312,25 @@ describe("pending user input question progress", () => { isComplete: true, }); }); + + it("marks preset selections as active even if a stale custom value exists", () => { + expect( + derivePendingUserInputProgress( + questions, + { + scope: { + answerSource: "option", + selectedOptionLabels: ["Orchestration-first"], + customAnswer: "stale custom answer", + }, + }, + 0, + ), + ).toMatchObject({ + selectedOptionLabels: ["Orchestration-first"], + customAnswer: "stale custom answer", + resolvedAnswer: "Orchestration-first", + usingCustomAnswer: false, + }); + }); }); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index d3a7a129378..a0e6574f9e6 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,6 +1,13 @@ import type { UserInputQuestion } from "@t3tools/contracts"; +// ponytail: answerSource makes the active input source authoritative so a +// clicked preset cannot be overwritten by stale custom editor state at submit +// (the #918/#528 preset/custom race). Kept as a discriminator over upstream's +// existing selectedOptionLabels[] shape to preserve multiSelect. +export type PendingUserInputAnswerSource = "option" | "custom"; + export interface PendingUserInputDraftAnswer { + answerSource?: PendingUserInputAnswerSource; selectedOptionLabels?: string[]; customAnswer?: string; } @@ -50,11 +57,22 @@ export function resolvePendingUserInputAnswer( draft: PendingUserInputDraftAnswer | undefined, ): string | string[] | null { const customAnswer = normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer) { + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + + if (draft?.answerSource === "custom") { return customAnswer; } + if (draft?.answerSource === "option") { + if (question.multiSelect) { + return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; + } + return selectedOptionLabels[0] ?? null; + } - const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + // No explicit source — legacy fallback (custom takes precedence). + if (customAnswer) { + return customAnswer; + } if (question.multiSelect) { return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; } @@ -66,14 +84,20 @@ export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabels = - customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const normalizedCustomAnswer = normalizeDraftAnswer(customAnswer); + const selectedOptionLabels = normalizedCustomAnswer + ? undefined + : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const answerSource: PendingUserInputAnswerSource | undefined = normalizedCustomAnswer + ? "custom" + : selectedOptionLabels.length > 0 + ? "option" + : undefined; return { + ...(answerSource ? { answerSource } : {}), customAnswer, - ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + ...(selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), }; } @@ -89,6 +113,7 @@ export function togglePendingUserInputOptionSelection( : [...selectedOptionLabels, optionLabel]; return { + answerSource: nextSelectedOptionLabels.length > 0 ? "option" : undefined, customAnswer: "", ...(nextSelectedOptionLabels.length > 0 ? { selectedOptionLabels: nextSelectedOptionLabels } @@ -97,11 +122,23 @@ export function togglePendingUserInputOptionSelection( } return { + answerSource: "option", customAnswer: "", selectedOptionLabels: [optionLabel], }; } +export function setPendingUserInputSelectedOption( + _draft: PendingUserInputDraftAnswer | undefined, + selectedOptionLabel: string, +): PendingUserInputDraftAnswer { + return { + answerSource: "option", + selectedOptionLabels: [selectedOptionLabel], + customAnswer: "", + }; +} + export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, @@ -152,6 +189,13 @@ export function derivePendingUserInputProgress( ? resolvePendingUserInputAnswer(activeQuestion, activeDraft) : null; const customAnswer = activeDraft?.customAnswer ?? ""; + const normalizedCustomAnswer = normalizeDraftAnswer(customAnswer); + const usingCustomAnswer = + activeDraft?.answerSource === "custom" + ? normalizedCustomAnswer !== null + : activeDraft?.answerSource === "option" + ? false + : normalizedCustomAnswer !== null; const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); const isLastQuestion = questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; @@ -163,7 +207,7 @@ export function derivePendingUserInputProgress( selectedOptionLabels: normalizeSelectedOptionLabels(activeDraft?.selectedOptionLabels), customAnswer, resolvedAnswer, - usingCustomAnswer: customAnswer.trim().length > 0, + usingCustomAnswer, answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, diff --git a/apps/web/src/pendingUserInputDraftStore.ts b/apps/web/src/pendingUserInputDraftStore.ts new file mode 100644 index 00000000000..970d42a3e27 --- /dev/null +++ b/apps/web/src/pendingUserInputDraftStore.ts @@ -0,0 +1,190 @@ +import type { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { createDebouncedStorage } from "./composerDraftStore"; +import type { PendingUserInputDraftAnswer } from "./pendingUserInput"; + +export const PENDING_USER_INPUT_DRAFT_STORAGE_KEY = "t3code:pending-user-input-drafts:v1"; + +const pendingUserInputDebouncedStorage = + typeof localStorage !== "undefined" + ? createDebouncedStorage(localStorage) + : { getItem: () => null, setItem: () => {}, removeItem: () => {}, flush: () => {} }; + +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + pendingUserInputDebouncedStorage.flush(); + }); +} + +interface PendingUserInputThreadDraftState { + answersByRequestId: Record>; + questionIndexByRequestId: Record; +} + +interface PendingUserInputDraftStoreState { + draftsByThreadId: Record; + setQuestionIndex: ( + threadId: ThreadId, + requestId: ApprovalRequestId, + questionIndex: number, + ) => void; + setAnswer: ( + threadId: ThreadId, + requestId: ApprovalRequestId, + questionId: string, + answer: PendingUserInputDraftAnswer, + ) => void; + clearInactiveRequests: ( + threadId: ThreadId, + activeRequestIds: ReadonlyArray, + ) => void; +} + +const EMPTY_PENDING_USER_INPUT_THREAD_DRAFT = Object.freeze({ + answersByRequestId: {}, + questionIndexByRequestId: {}, +}) as PendingUserInputThreadDraftState; + +function shouldRemoveThreadDraft(draft: PendingUserInputThreadDraftState | undefined): boolean { + if (!draft) { + return true; + } + return ( + Object.keys(draft.answersByRequestId).length === 0 && + Object.keys(draft.questionIndexByRequestId).length === 0 + ); +} + +export const usePendingUserInputDraftStore = create()( + persist( + (set) => ({ + draftsByThreadId: {}, + setQuestionIndex: (threadId, requestId, questionIndex) => { + if (threadId.length === 0 || requestId.length === 0) { + return; + } + set((state) => { + const threadDraft = + state.draftsByThreadId[threadId] ?? EMPTY_PENDING_USER_INPUT_THREAD_DRAFT; + const nextQuestionIndex = Math.max(0, Math.floor(questionIndex)); + if (threadDraft.questionIndexByRequestId[requestId] === nextQuestionIndex) { + return state; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + answersByRequestId: threadDraft.answersByRequestId, + questionIndexByRequestId: { + ...threadDraft.questionIndexByRequestId, + [requestId]: nextQuestionIndex, + }, + }, + }, + }; + }); + }, + setAnswer: (threadId, requestId, questionId, answer) => { + if (threadId.length === 0 || requestId.length === 0 || questionId.length === 0) { + return; + } + set((state) => { + const threadDraft = + state.draftsByThreadId[threadId] ?? EMPTY_PENDING_USER_INPUT_THREAD_DRAFT; + const requestAnswers = threadDraft.answersByRequestId[requestId] ?? {}; + const currentAnswer = requestAnswers[questionId]; + if ( + currentAnswer?.answerSource === answer.answerSource && + currentAnswer?.customAnswer === answer.customAnswer && + JSON.stringify(currentAnswer?.selectedOptionLabels) === + JSON.stringify(answer.selectedOptionLabels) + ) { + return state; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + answersByRequestId: { + ...threadDraft.answersByRequestId, + [requestId]: { + ...requestAnswers, + [questionId]: answer, + }, + }, + questionIndexByRequestId: threadDraft.questionIndexByRequestId, + }, + }, + }; + }); + }, + clearInactiveRequests: (threadId, activeRequestIds) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const threadDraft = state.draftsByThreadId[threadId]; + if (!threadDraft) { + return state; + } + const activeRequestIdSet = new Set(activeRequestIds); + let answersChanged = false; + const nextAnswersByRequestId = Object.fromEntries( + Object.entries(threadDraft.answersByRequestId).filter(([requestId]) => { + const keep = activeRequestIdSet.has(requestId as ApprovalRequestId); + answersChanged ||= !keep; + return keep; + }), + ) as PendingUserInputThreadDraftState["answersByRequestId"]; + let indexChanged = false; + const nextQuestionIndexByRequestId = Object.fromEntries( + Object.entries(threadDraft.questionIndexByRequestId).filter(([requestId]) => { + const keep = activeRequestIdSet.has(requestId as ApprovalRequestId); + indexChanged ||= !keep; + return keep; + }), + ) as PendingUserInputThreadDraftState["questionIndexByRequestId"]; + if (!answersChanged && !indexChanged) { + return state; + } + const nextThreadDraft: PendingUserInputThreadDraftState = { + answersByRequestId: nextAnswersByRequestId, + questionIndexByRequestId: nextQuestionIndexByRequestId, + }; + if (shouldRemoveThreadDraft(nextThreadDraft)) { + const { [threadId]: _removed, ...restDraftsByThreadId } = state.draftsByThreadId; + return { draftsByThreadId: restDraftsByThreadId }; + } + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: nextThreadDraft, + }, + }; + }); + }, + }), + { + name: PENDING_USER_INPUT_DRAFT_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => pendingUserInputDebouncedStorage), + partialize: (state) => ({ + draftsByThreadId: Object.fromEntries( + Object.entries(state.draftsByThreadId).filter( + ([, draft]) => !shouldRemoveThreadDraft(draft), + ), + ) as Record, + }), + }, + ), +); + +export function usePendingUserInputThreadDraft( + threadId: ThreadId, +): PendingUserInputThreadDraftState { + return usePendingUserInputDraftStore( + (state) => state.draftsByThreadId[threadId] ?? EMPTY_PENDING_USER_INPUT_THREAD_DRAFT, + ); +} From 73f39803da4fe60c3b1f0b31220b6d8780f11715 Mon Sep 17 00:00:00 2001 From: jamesx0416 Date: Wed, 18 Mar 2026 18:14:47 +1100 Subject: [PATCH 2/7] Fix plan input draft handling across thread switches - Keep secondary snapshot thread sessions aligned with their thread IDs - Only clear inactive pending-input drafts when an active thread exists - Pass user-input responding request IDs to the composer pending-input panel --- apps/web/src/components/ChatView.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 533efcb2ad2..c7789d5d6d1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1802,8 +1802,16 @@ function ChatViewContent(props: ChatViewProps) { (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; useEffect(() => { + if (!activeThread) { + return; + } clearInactivePendingUserInputDraftRequests(threadId, activePendingUserInputRequestIds); - }, [activePendingUserInputRequestIds, clearInactivePendingUserInputDraftRequests, threadId]); + }, [ + activePendingUserInputRequestIds, + activeThread, + clearInactivePendingUserInputDraftRequests, + threadId, + ]); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -5027,7 +5035,6 @@ function ChatViewContent(props: ChatViewProps) { onPreviousActivePendingUserInputQuestion={ onPreviousActivePendingUserInputQuestion } - } onChangeActivePendingUserInputCustomAnswer={ onChangeActivePendingUserInputCustomAnswer } From 47d43864bde970c4a3e8048bc3e865d2f6e4e50f Mon Sep 17 00:00:00 2001 From: jamesx0416 Date: Mon, 22 Jun 2026 22:12:59 +1000 Subject: [PATCH 3/7] Address CodeRabbit review feedback on pending-input flow - Pass explicit isResponding (from activePendingIsResponding) to the pending- user-input panel so presets disable during thread.user-input.respond, instead of the generic approval respondingRequestIds that left them clickable. - Guard clearInactivePendingUserInputDraftRequests to skip pruning while thread activities have not hydrated, preventing restored drafts from being deleted during a brief empty-activities window on thread switch. - Collapse whitespace-only custom drafts to empty string in setPendingUserInputCustomAnswer so pure-whitespace text is never persisted. - Update ChatComposer to the panel's new onSelectOption prop surface. --- apps/web/src/components/ChatView.tsx | 7 +++++++ apps/web/src/components/chat/ChatComposer.tsx | 10 ++++------ .../components/chat/ComposerPendingUserInputPanel.tsx | 7 +++---- apps/web/src/pendingUserInput.ts | 5 ++++- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c7789d5d6d1..69c2da50cbb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1805,11 +1805,18 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThread) { return; } + // ponytail: skip pruning until the thread's activities have hydrated, otherwise + // a brief empty-activities window on thread switch would delete restored drafts + // (activePendingUserInputRequestIds is derived from threadActivities). + if (threadActivities === EMPTY_ACTIVITIES && activePendingUserInputRequestIds.length === 0) { + return; + } clearInactivePendingUserInputDraftRequests(threadId, activePendingUserInputRequestIds); }, [ activePendingUserInputRequestIds, activeThread, clearInactivePendingUserInputDraftRequests, + threadActivities, threadId, ]); const lastSyncedPendingInputRef = useRef<{ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3a5e06bce06..c334f3780e1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2114,11 +2114,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) : showPlanFollowUpPrompt && activeProposedPlan ? ( @@ -2154,11 +2153,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) >
; questionIndex: number; onSelectOption: ( @@ -25,7 +24,7 @@ interface PendingUserInputPanelProps { export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ pendingUserInputs, - respondingRequestIds, + isResponding, answers, questionIndex, onSelectOption, @@ -38,7 +37,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn 0 ? { selectedOptionLabels } : {}), }; } From 6763920bebc1b76e821aa2856a30a1e3bd862c17 Mon Sep 17 00:00:00 2001 From: jamesx0416 Date: Wed, 24 Jun 2026 20:42:46 +1000 Subject: [PATCH 4/7] Remove dead code: unused import, stale state, and removed onAdvance prop --- apps/web/src/components/ChatView.tsx | 11 ----------- apps/web/src/components/chat/ChatComposer.tsx | 2 -- 2 files changed, 13 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 69c2da50cbb..1e6ee276780 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,7 +87,6 @@ import { usePendingUserInputDraftStore, usePendingUserInputThreadDraft, } from "../pendingUserInputDraftStore"; -import { useStore } from "../store"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, @@ -1122,9 +1121,6 @@ function ChatViewContent(props: ChatViewProps) { ApprovalRequestId[] >([]); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); - const [planSidebarOpen, setPlanSidebarOpen] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -1795,12 +1791,6 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.session ?? null, localDispatchStartedAt, ); - const isComposerApprovalState = activePendingApproval !== null; - const hasComposerHeader = - isComposerApprovalState || - pendingUserInputs.length > 0 || - (showPlanFollowUpPrompt && activeProposedPlan !== null); - const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; useEffect(() => { if (!activeThread) { return; @@ -5038,7 +5028,6 @@ function ChatViewContent(props: ChatViewProps) { onImplementPlanInNewThread={onImplementPlanInNewThread} onRespondToApproval={onRespondToApproval} onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} - onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} onPreviousActivePendingUserInputQuestion={ onPreviousActivePendingUserInputQuestion } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c334f3780e1..6ceb8b9cd28 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -516,7 +516,6 @@ export interface ChatComposerProps { decision: ProviderApprovalDecision, ) => Promise; onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; - onAdvanceActivePendingUserInput: () => void; onPreviousActivePendingUserInputQuestion: () => void; onChangeActivePendingUserInputCustomAnswer: ( questionId: string, @@ -599,7 +598,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onImplementPlanInNewThread, onRespondToApproval, onSelectActivePendingUserInputOption, - onAdvanceActivePendingUserInput, onPreviousActivePendingUserInputQuestion, onChangeActivePendingUserInputCustomAnswer, onProviderModelSelect, From 74ee23c8628b545fefc4527a1dcd6b5fc382259a Mon Sep 17 00:00:00 2001 From: jamesx0416 Date: Wed, 24 Jun 2026 21:01:01 +1000 Subject: [PATCH 5/7] Replace JSON.stringify deep array compare with shallow element check --- apps/web/src/pendingUserInputDraftStore.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pendingUserInputDraftStore.ts b/apps/web/src/pendingUserInputDraftStore.ts index 970d42a3e27..7f442400ab6 100644 --- a/apps/web/src/pendingUserInputDraftStore.ts +++ b/apps/web/src/pendingUserInputDraftStore.ts @@ -95,11 +95,18 @@ export const usePendingUserInputDraftStore = create l === nextLabels[i])); if ( currentAnswer?.answerSource === answer.answerSource && currentAnswer?.customAnswer === answer.customAnswer && - JSON.stringify(currentAnswer?.selectedOptionLabels) === - JSON.stringify(answer.selectedOptionLabels) + labelsEqual ) { return state; } From 0a536b92fe8b9cb8ed01b68e1e818c7fa45c321a Mon Sep 17 00:00:00 2001 From: James <105842516+jamesx0416@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:51:32 +1000 Subject: [PATCH 6/7] Apply suggestions from code review Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- apps/web/src/pendingUserInput.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index d754b1bae36..5a2012193e3 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -100,7 +100,7 @@ export function setPendingUserInputCustomAnswer( return { ...(answerSource ? { answerSource } : {}), customAnswer: storedCustomAnswer, - ...(selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), }; } From e64daf7fded4035d6289913f70feeeea147c5a5f Mon Sep 17 00:00:00 2001 From: James <105842516+jamesx0416@users.noreply.github.com> Date: Sun, 5 Jul 2026 08:25:34 +0000 Subject: [PATCH 7/7] Fix pending input cursor typecheck --- apps/web/src/components/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1e6ee276780..e879aa4ff22 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4198,7 +4198,7 @@ function ChatViewContent(props: ChatViewProps) { value: string, nextCursor: number, expandedCursor: number, - _cursorAdjacentToMention: boolean, + cursorAdjacentToMention: boolean, ) => { if (!activePendingUserInput) { return;