authorgravatar for yashafaid@gmail.comCestercian <yashafaid@gmail.com> 2026-09-20 03:39:00+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-19 15:09:00-07:00
loge36725682bd2935f1bcfdf81660cb569b5f3df29
tree64ee16c639e95728978ca4ad26666902ca23c15f
parentdcf8942304cfef382af55c0068cb2f04c86757fc
signature Signed by PGP key B5690EEEBB952194

fix(web): keep typed text when a question option is clicked (#12577)


6 files changed, 112 insertions(+), 8 deletions(-)

apps/web/src/components/ChatView.tsx+14
...@@ -148,6 +148,7 @@ import {...@@ -148,6 +148,7 @@ import {
148} from "./chat/timelineScrollAnchoring";148} from "./chat/timelineScrollAnchoring";
149import {149import {
150 buildPendingUserInputAnswers,150 buildPendingUserInputAnswers,
151 carryDisplacedCustomAnswerIntoPrompt,
151 derivePendingUserInputProgress,152 derivePendingUserInputProgress,
152 setPendingUserInputCustomAnswer,153 setPendingUserInputCustomAnswer,
153 togglePendingUserInputOptionSelection,154 togglePendingUserInputOptionSelection,
...@@ -8810,6 +8811,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -8810,6 +8811,16 @@ export default function ChatView(props: ChatViewProps) {
8810 if (!activePendingUserInput) {8811 if (!activePendingUserInput) {
8811 return;8812 return;
8812 }8813 }
8814 // The option replaces the custom answer. Anything typed there is the
8815 // user's text, so it goes back to the thread draft instead of vanishing.
8816 const displacedAnswer =
8817 pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer;
8818 const currentPrompt =
8819 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? "";
8820 const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);
8821 if (nextPrompt !== currentPrompt) {
8822 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
8823 }
8813 setPendingUserInputAnswersByRequestId((existing) => {8824 setPendingUserInputAnswersByRequestId((existing) => {
8814 const question =8825 const question =
8815 (activePendingProgress?.activeQuestion?.id === questionId8826 (activePendingProgress?.activeQuestion?.id === questionId
...@@ -8839,7 +8850,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -8839,7 +8850,10 @@ export default function ChatView(props: ChatViewProps) {
8839 activePendingProgress?.activeQuestion,8850 activePendingProgress?.activeQuestion,
8840 activePendingUserInput,8851 activePendingUserInput,
8841 activePendingRequestKey,8852 activePendingRequestKey,
8853 composerDraftTarget,
8842 composerRef,8854 composerRef,
8855 pendingUserInputAnswersByRequestId,
8856 setComposerDraftPrompt,
8843 ],8857 ],
8844 );8858 );
88458859
apps/web/src/components/chat/ChatComposer.tsx+14-8
...@@ -72,6 +72,7 @@ import {...@@ -72,6 +72,7 @@ import {
72 type ComposerSubmissionIntent,72 type ComposerSubmissionIntent,
73 type ComposerTrigger,73 type ComposerTrigger,
74 collapseExpandedComposerCursor,74 collapseExpandedComposerCursor,
75 composerStateAtPromptEnd,
75 composerSubmissionIntentForEnter,76 composerSubmissionIntentForEnter,
76 detectComposerTrigger,77 detectComposerTrigger,
77 expandCollapsedComposerCursor,78 expandCollapsedComposerCursor,
...@@ -3080,6 +3081,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3080,6 +3081,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3080 useEffect(() => {3081 useEffect(() => {
3081 const nextCustomAnswer = activePendingProgress?.customAnswer;3082 const nextCustomAnswer = activePendingProgress?.customAnswer;
3082 if (typeof nextCustomAnswer !== "string") {3083 if (typeof nextCustomAnswer !== "string") {
3084 // The question is gone and the editor shows the thread draft again. The
3085 // ref still holds the last answer text, and Send reads the ref. Place
3086 // the caret at the end so the next keystroke appends.
3087 if (lastSyncedPendingInputRef.current !== null) {
3088 promptRef.current = prompt;
3089 const { cursor, trigger } = composerStateAtPromptEnd(prompt);
3090 setComposerCursor(cursor);
3091 setComposerTrigger(trigger);
3092 }
3083 lastSyncedPendingInputRef.current = null;3093 lastSyncedPendingInputRef.current = null;
3084 return;3094 return;
3085 }3095 }
...@@ -3101,19 +3111,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3101,19 +3111,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3101 }3111 }
31023112
3103 promptRef.current = nextCustomAnswer;3113 promptRef.current = nextCustomAnswer;
3104 const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);3114 const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer);
3105 setComposerCursor(nextCursor);3115 setComposerCursor(cursor);
3106 setComposerTrigger(3116 setComposerTrigger(trigger);
3107 detectComposerTrigger(
3108 nextCustomAnswer,
3109 expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
3110 ),
3111 );
3112 setComposerHighlightedItemId(null);3117 setComposerHighlightedItemId(null);
3113 }, [3118 }, [
3114 activePendingProgress?.customAnswer,3119 activePendingProgress?.customAnswer,
3115 activePendingProgress?.activeQuestion?.id,3120 activePendingProgress?.activeQuestion?.id,
3116 activePendingUserInput?.requestId,3121 activePendingUserInput?.requestId,
3122 prompt,
3117 promptRef,3123 promptRef,
3118 ]);3124 ]);
31193125
apps/web/src/composer-logic.test.ts+30
...@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
10import {10import {
11 clampCollapsedComposerCursor,11 clampCollapsedComposerCursor,
12 collapseExpandedComposerCursor,12 collapseExpandedComposerCursor,
13 composerStateAtPromptEnd,
13 composerSubmissionIntentForEnter,14 composerSubmissionIntentForEnter,
14 detectComposerTrigger,15 detectComposerTrigger,
15 expandCollapsedComposerCursor,16 expandCollapsedComposerCursor,
...@@ -18,6 +19,7 @@ import {...@@ -18,6 +19,7 @@ import {
18 parseStandaloneComposerSlashCommand,19 parseStandaloneComposerSlashCommand,
19 replaceTextRange,20 replaceTextRange,
20} from "./composer-logic";21} from "./composer-logic";
22import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput";
21import { formatTerminalContextReference } from "./lib/terminalContext";23import { formatTerminalContextReference } from "./lib/terminalContext";
2224
23const terminalReference = formatTerminalContextReference({25const terminalReference = formatTerminalContextReference({
...@@ -482,6 +484,34 @@ describe("expandCollapsedComposerCursor", () => {...@@ -482,6 +484,34 @@ describe("expandCollapsedComposerCursor", () => {
482 });484 });
483});485});
484486
487describe("composerStateAtPromptEnd", () => {
488 it("puts the caret at the end of a restored parked draft", () => {
489 const prompt = carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half");
490
491 expect(composerStateAtPromptEnd(prompt)).toEqual({
492 cursor: prompt.length,
493 trigger: null,
494 });
495 });
496
497 it("collapses mention chips so the next keystroke lands after the draft", () => {
498 const prompt = carryDisplacedCustomAnswerIntoPrompt("", "see @AGENTS.md please");
499
500 expect(composerStateAtPromptEnd(prompt).cursor).toBe("see ".length + 1 + " please".length);
501 expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(0);
502 expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(prompt.length);
503 });
504
505 it("keeps a trailing mention trigger when the restored draft ends with @", () => {
506 const prompt = "look at @";
507
508 expect(composerStateAtPromptEnd(prompt)).toEqual({
509 cursor: prompt.length,
510 trigger: { kind: "path", query: "", rangeStart: "look at ".length, rangeEnd: prompt.length },
511 });
512 });
513});
514
485describe("collapseExpandedComposerCursor", () => {515describe("collapseExpandedComposerCursor", () => {
486 it("keeps cursor unchanged when no mention segment is present", () => {516 it("keeps cursor unchanged when no mention segment is present", () => {
487 expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5);517 expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5);
apps/web/src/composer-logic.ts+12
...@@ -265,6 +265,18 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos...@@ -265,6 +265,18 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
265 };265 };
266}266}
267267
268/** Caret and trigger after replacing composer text and continuing at the end. */
269export function composerStateAtPromptEnd(text: string): {
270 cursor: number;
271 trigger: ComposerTrigger | null;
272} {
273 const cursor = collapseExpandedComposerCursor(text, text.length);
274 return {
275 cursor,
276 trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)),
277 };
278}
279
268export function parseStandaloneComposerSlashCommand(280export function parseStandaloneComposerSlashCommand(
269 text: string,281 text: string,
270): Exclude<ComposerSlashCommand, "model"> | null {282): Exclude<ComposerSlashCommand, "model"> | null {
apps/web/src/pendingUserInput.test.ts+20
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 buildPendingUserInputAnswers,4 buildPendingUserInputAnswers,
5 carryDisplacedCustomAnswerIntoPrompt,
5 countAnsweredPendingUserInputQuestions,6 countAnsweredPendingUserInputQuestions,
6 derivePendingUserInputProgress,7 derivePendingUserInputProgress,
7 resolvePendingUserInputAnswer,8 resolvePendingUserInputAnswer,
...@@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => {...@@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => {
321 }),322 }),
322 ).toBeNull();323 ).toBeNull();
323});324});
325
326describe("carryDisplacedCustomAnswerIntoPrompt", () => {
327 it("keeps the thread draft when nothing was typed into the answer", () => {
328 expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft");
329 expect(carryDisplacedCustomAnswerIntoPrompt("draft", " ")).toBe("draft");
330 });
331
332 it("moves the typed answer into an empty thread draft", () => {
333 expect(carryDisplacedCustomAnswerIntoPrompt("", "also rename the flag ")).toBe(
334 "also rename the flag",
335 );
336 });
337
338 it("appends the typed answer after an existing thread draft", () => {
339 expect(carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half")).toBe(
340 "first half\n\nsecond half",
341 );
342 });
343});
apps/web/src/pendingUserInput.ts+22
...@@ -82,6 +82,28 @@ export function setPendingUserInputCustomAnswer(...@@ -82,6 +82,28 @@ export function setPendingUserInputCustomAnswer(
82 };82 };
83}83}
8484
85const DISPLACED_ANSWER_SEPARATOR = "\n\n";
86
87/**
88 * Selecting an option replaces the custom answer, because a non-empty custom
89 * answer outranks selected options in `resolvePendingUserInputAnswer`. Text the
90 * user typed into the answer field must not vanish on that click: it moves back
91 * into the thread draft, after whatever was already waiting there.
92 */
93export function carryDisplacedCustomAnswerIntoPrompt(
94 prompt: string,
95 customAnswer: string | undefined,
96): string {
97 const displaced = customAnswer?.trim() ?? "";
98 if (displaced.length === 0) {
99 return prompt;
100 }
101 if (prompt.trim().length === 0) {
102 return displaced;
103 }
104 return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`;
105}
106
85export function togglePendingUserInputOptionSelection(107export function togglePendingUserInputOptionSelection(
86 question: UserInputQuestion,108 question: UserInputQuestion,
87 draft: PendingUserInputDraftAnswer | undefined,109 draft: PendingUserInputDraftAnswer | undefined,