From f516c905ba696a44fb412cacad3696f4b01026d7 Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Sat, 14 Mar 2026 17:13:07 +0100 Subject: [PATCH 1/2] fix: preserve composer drafts during pending user input --- apps/web/src/components/ChatView.browser.tsx | 116 +++++++++++ apps/web/src/components/ChatView.logic.ts | 7 + apps/web/src/components/ChatView.tsx | 183 +++++++----------- .../chat/ComposerPendingUserInputPanel.tsx | 12 +- 4 files changed, 196 insertions(+), 122 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..d2193d0b5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -356,6 +356,45 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function createSnapshotWithPendingUserInputAndPlan(): OrchestrationReadModel { + const snapshot = createSnapshotWithLongProposedPlan(); + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + interactionMode: "plan", + activities: [ + { + id: "activity-user-input-requested", + threadId: THREAD_ID, + turnId: null, + kind: "user-input.requested", + createdAt: isoAt(1_010), + order: 1, + payload: { + requestId: "request-browser-test", + questions: [ + { + id: "question-browser-test", + header: "Need an answer", + question: "Pick an option or type a custom answer.", + options: [ + { label: "Yes", description: "Confirm." }, + { label: "No", description: "Decline." }, + ], + }, + ], + }, + }, + ], + updatedAt: isoAt(1_010), + }) + : thread, + ), + }; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { @@ -531,6 +570,23 @@ async function waitForComposerEditor(): Promise { ); } +function composerTextContent(): string { + return document.querySelector('[data-testid="composer-editor"]')?.textContent ?? ""; +} + +async function waitForPendingUserInputComposerEditor(expectedValue?: string): Promise { + return waitForElement(() => { + const editor = document.querySelector('[data-testid="composer-editor"]'); + if (!editor) { + return null; + } + if (expectedValue !== undefined && editor.textContent !== expectedValue) { + return null; + } + return editor; + }, "Unable to find pending user-input composer editor."); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -1247,4 +1303,64 @@ describe("ChatView timeline estimator parity (full app)", () => { await mounted.cleanup(); } }); + + it("routes pending custom-answer typing through the composer while preserving the main draft", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithPendingUserInputAndPlan(), + }); + + try { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + document.execCommand("insertText", false, "main composer draft"); + + await vi.waitFor( + () => { + expect(composerTextContent()).toContain("main composer draft"); + }, + { timeout: 8_000, interval: 16 }, + ); + + composerEditor.focus(); + document.execCommand("selectAll", false); + document.execCommand("insertText", false, "custom follow-up answer"); + + await vi.waitFor( + () => { + expect(composerTextContent()).toBe("custom follow-up answer"); + }, + { timeout: 8_000, interval: 16 }, + ); + + wsRequests.length = 0; + composerEditor.dispatchEvent(new KeyboardEvent("keydown", { key: "2", bubbles: true })); + document.execCommand("insertText", false, "custom follow-up answer2"); + + await vi.waitFor( + () => { + expect(composerTextContent()).toBe("custom follow-up answer2"); + expect(wsRequests.some((request) => request._tag === "thread.user-input.respond")).toBe( + false, + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + const { syncServerReadModel } = useStore.getState(); + syncServerReadModel(createSnapshotWithLongProposedPlan()); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("Add feedback to refine the plan"); + expect(composerTextContent()).toContain("main composer draft"); + }, + { timeout: 8_000, interval: 16 }, + ); + + await waitForPendingUserInputComposerEditor("main composer draft"); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e290431..a669f9708 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -123,3 +123,10 @@ export function getCustomModelOptionsByProvider(settings: { codex: getAppModelOptions("codex", settings.customCodexModels), }; } + +export function resolveComposerDraftText(options: { + prompt: string; + pendingCustomAnswer: string | null; +}): string { + return options.pendingCustomAnswer ?? options.prompt; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762c..78aca4baa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -154,6 +154,7 @@ import { LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, + resolveComposerDraftText, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, @@ -242,6 +243,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.draftThreadsByThreadId[threadId] ?? null, ); const promptRef = useRef(prompt); + const pendingCustomAnswerRef = useRef(null); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -625,6 +627,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const activePendingIsResponding = activePendingUserInput ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) : false; + const visibleComposerValue = resolveComposerDraftText({ + prompt, + pendingCustomAnswer: activePendingProgress?.customAnswer ?? null, + }); const activeProposedPlan = useMemo(() => { if (!latestTurnSettled) { return null; @@ -650,47 +656,6 @@ export default function ChatView({ threadId }: ChatViewProps) { pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const lastSyncedPendingInputRef = useRef<{ - requestId: string | null; - questionId: string | null; - } | null>(null); - useEffect(() => { - const nextCustomAnswer = activePendingProgress?.customAnswer; - if (typeof nextCustomAnswer !== "string") { - lastSyncedPendingInputRef.current = null; - 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 (!questionChanged && !textChangedExternally) { - return; - } - - promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); - setComposerHighlightedItemId(null); - }, [ - activePendingProgress?.customAnswer, - activePendingUserInput?.requestId, - activePendingProgress?.activeQuestion?.id, - ]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); @@ -1761,6 +1726,21 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt]); + useEffect(() => { + const nextComposerValue = resolveComposerDraftText({ + prompt, + pendingCustomAnswer: activePendingProgress?.customAnswer ?? null, + }); + const nextCursor = collapseExpandedComposerCursor(nextComposerValue, nextComposerValue.length); + setComposerHighlightedItemId(null); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(nextComposerValue, nextComposerValue.length)); + }, [activePendingProgress?.activeQuestion?.id, activePendingProgress?.customAnswer, prompt]); + + useEffect(() => { + pendingCustomAnswerRef.current = activePendingProgress?.customAnswer ?? null; + }, [activePendingProgress?.customAnswer]); + useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -2202,7 +2182,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const trimmed = prompt.trim(); + const trimmed = visibleComposerValue.trim(); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -2444,7 +2424,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } if ( !turnStartSucceeded && - promptRef.current.length === 0 && + visibleComposerValue.length === 0 && composerImagesRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { @@ -2567,39 +2547,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }, })); - promptRef.current = ""; - setComposerCursor(0); - setComposerTrigger(null); - }, - [activePendingUserInput], - ); - - const onChangeActivePendingUserInputCustomAnswer = useCallback( - ( - questionId: string, - value: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - ) => { - if (!activePendingUserInput) { - return; - } - promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingUserInput.requestId]?.[questionId], - value, - ), - }, - })); - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), - ); + pendingCustomAnswerRef.current = ""; }, [activePendingUserInput], ); @@ -2924,7 +2872,10 @@ export default function ChatView({ threadId }: ChatViewProps) { replacement: string, options?: { expectedText?: string }, ): boolean => { - const currentText = promptRef.current; + const activePendingQuestion = activePendingProgress?.activeQuestion; + const currentText = activePendingQuestion + ? activePendingProgress.customAnswer + : visibleComposerValue; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); if ( @@ -2933,11 +2884,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ) { return false; } - const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const next = replaceTextRange(currentText, rangeStart, rangeEnd, replacement); const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); - promptRef.current = next.text; - const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { + pendingCustomAnswerRef.current = next.text; setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { @@ -2949,18 +2899,27 @@ export default function ChatView({ threadId }: ChatViewProps) { }, })); } else { + promptRef.current = next.text; setPrompt(next.text); } - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), - ); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCursor); - }); + if (!activePendingQuestion) { + setComposerCursor(nextCursor); + setComposerTrigger( + detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), + ); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCursor); + }); + } return true; }, - [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], + [ + activePendingProgress?.customAnswer, + activePendingProgress?.activeQuestion, + activePendingUserInput, + setPrompt, + visibleComposerValue, + ], ); const readComposerSnapshot = useCallback((): { @@ -2973,11 +2932,11 @@ export default function ChatView({ threadId }: ChatViewProps) { return editorSnapshot; } return { - value: promptRef.current, + value: visibleComposerValue, cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + expandedCursor: expandCollapsedComposerCursor(visibleComposerValue, composerCursor), }; - }, [composerCursor]); + }, [composerCursor, visibleComposerValue]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; @@ -3094,29 +3053,29 @@ export default function ChatView({ threadId }: ChatViewProps) { expandedCursor: number, cursorAdjacentToMention: boolean, ) => { - if (activePendingProgress?.activeQuestion && activePendingUserInput) { - onChangeActivePendingUserInputCustomAnswer( - activePendingProgress.activeQuestion.id, - nextPrompt, - nextCursor, - expandedCursor, - cursorAdjacentToMention, - ); - return; + const activePendingQuestion = activePendingProgress?.activeQuestion; + if (activePendingQuestion && activePendingUserInput) { + pendingCustomAnswerRef.current = nextPrompt; + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [activePendingQuestion.id]: setPendingUserInputCustomAnswer( + existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], + nextPrompt, + ), + }, + })); + } else { + promptRef.current = nextPrompt; + setPrompt(nextPrompt); } - promptRef.current = nextPrompt; - setPrompt(nextPrompt); setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); }, - [ - activePendingProgress?.activeQuestion, - activePendingUserInput, - onChangeActivePendingUserInputCustomAnswer, - setPrompt, - ], + [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], ); const onComposerCommandKey = ( @@ -3436,13 +3395,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ) { return; } - // If the user has started typing a custom answer in the contenteditable - // composer, let digit keys pass through so they can type numbers. - if (target instanceof HTMLElement && target.isContentEditable) { - const hasCustomText = progress.customAnswer.length > 0; - if (hasCustomText) return; - } const digit = Number.parseInt(event.key, 10); if (Number.isNaN(digit) || digit < 1 || digit > 9) return; const optionIndex = digit - 1; From 49a1884fd939cdb31a22797693be42f46af88f36 Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Sat, 14 Mar 2026 17:30:02 +0100 Subject: [PATCH 2/2] fix(web): preserve pending-input shortcuts and cursor state --- apps/web/src/components/ChatView.tsx | 27 +++++++++++++++---- .../chat/ComposerPendingUserInputPanel.tsx | 9 ++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 78aca4baa..c0dd9724c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -244,6 +244,10 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const promptRef = useRef(prompt); const pendingCustomAnswerRef = useRef(null); + const pendingCustomAnswerSelectionRef = useRef<{ + cursor: number; + trigger: ComposerTrigger | null; + } | null>(null); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -1731,10 +1735,17 @@ export default function ChatView({ threadId }: ChatViewProps) { prompt, pendingCustomAnswer: activePendingProgress?.customAnswer ?? null, }); - const nextCursor = collapseExpandedComposerCursor(nextComposerValue, nextComposerValue.length); + const pendingSelection = pendingCustomAnswerSelectionRef.current; + const nextCursor = + pendingSelection?.cursor ?? + collapseExpandedComposerCursor(nextComposerValue, nextComposerValue.length); setComposerHighlightedItemId(null); setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextComposerValue, nextComposerValue.length)); + setComposerTrigger( + pendingSelection?.trigger ?? + detectComposerTrigger(nextComposerValue, nextComposerValue.length), + ); + pendingCustomAnswerSelectionRef.current = null; }, [activePendingProgress?.activeQuestion?.id, activePendingProgress?.customAnswer, prompt]); useEffect(() => { @@ -2886,8 +2897,16 @@ export default function ChatView({ threadId }: ChatViewProps) { } const next = replaceTextRange(currentText, rangeStart, rangeEnd, replacement); const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); + const nextTrigger = detectComposerTrigger( + next.text, + expandCollapsedComposerCursor(next.text, nextCursor), + ); if (activePendingQuestion && activePendingUserInput) { pendingCustomAnswerRef.current = next.text; + pendingCustomAnswerSelectionRef.current = { + cursor: nextCursor, + trigger: nextTrigger, + }; setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { @@ -2904,9 +2923,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (!activePendingQuestion) { setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), - ); + setComposerTrigger(nextTrigger); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCursor); }); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index d32fe87d7..679d8b1d9 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -93,10 +93,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return; + } if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - (target instanceof HTMLElement && target.isContentEditable) + target instanceof HTMLElement && + target.isContentEditable && + progress.customAnswer.length > 0 ) { return; }