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..c0dd9724c 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,11 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.draftThreadsByThreadId[threadId] ?? null, ); 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([]); @@ -625,6 +631,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 +660,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 +1730,28 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt]); + useEffect(() => { + const nextComposerValue = resolveComposerDraftText({ + prompt, + pendingCustomAnswer: activePendingProgress?.customAnswer ?? null, + }); + const pendingSelection = pendingCustomAnswerSelectionRef.current; + const nextCursor = + pendingSelection?.cursor ?? + collapseExpandedComposerCursor(nextComposerValue, nextComposerValue.length); + setComposerHighlightedItemId(null); + setComposerCursor(nextCursor); + setComposerTrigger( + pendingSelection?.trigger ?? + detectComposerTrigger(nextComposerValue, nextComposerValue.length), + ); + pendingCustomAnswerSelectionRef.current = null; + }, [activePendingProgress?.activeQuestion?.id, activePendingProgress?.customAnswer, prompt]); + + useEffect(() => { + pendingCustomAnswerRef.current = activePendingProgress?.customAnswer ?? null; + }, [activePendingProgress?.customAnswer]); + useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { @@ -2202,7 +2193,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 +2435,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } if ( !turnStartSucceeded && - promptRef.current.length === 0 && + visibleComposerValue.length === 0 && composerImagesRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { @@ -2567,39 +2558,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 +2883,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 +2895,18 @@ 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; + 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]: { @@ -2949,18 +2918,25 @@ 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(nextTrigger); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCursor); + }); + } return true; }, - [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], + [ + activePendingProgress?.customAnswer, + activePendingProgress?.activeQuestion, + activePendingUserInput, + setPrompt, + visibleComposerValue, + ], ); const readComposerSnapshot = useCallback((): { @@ -2973,11 +2949,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 +3070,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 +3412,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} 0; - if (hasCustomText) return; + if ( + target instanceof HTMLElement && + target.isContentEditable && + progress.customAnswer.length > 0 + ) { + return; } const digit = Number.parseInt(event.key, 10); if (Number.isNaN(digit) || digit < 1 || digit > 9) return;