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 {
148148} from "./chat/timelineScrollAnchoring";
149149import {
150150 buildPendingUserInputAnswers,
151 carryDisplacedCustomAnswerIntoPrompt,
151152 derivePendingUserInputProgress,
152153 setPendingUserInputCustomAnswer,
153154 togglePendingUserInputOptionSelection,
......@@ -8810,6 +8811,16 @@ export default function ChatView(props: ChatViewProps) {
88108811 if (!activePendingUserInput) {
88118812 return;
88128813 }
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 }
88138824 setPendingUserInputAnswersByRequestId((existing) => {
88148825 const question =
88158826 (activePendingProgress?.activeQuestion?.id === questionId
......@@ -8839,7 +8850,10 @@ export default function ChatView(props: ChatViewProps) {
88398850 activePendingProgress?.activeQuestion,
88408851 activePendingUserInput,
88418852 activePendingRequestKey,
8853 composerDraftTarget,
88428854 composerRef,
8855 pendingUserInputAnswersByRequestId,
8856 setComposerDraftPrompt,
88438857 ],
88448858 );
88458859
apps/web/src/components/chat/ChatComposer.tsx+14-8
......@@ -72,6 +72,7 @@ import {
7272 type ComposerSubmissionIntent,
7373 type ComposerTrigger,
7474 collapseExpandedComposerCursor,
75 composerStateAtPromptEnd,
7576 composerSubmissionIntentForEnter,
7677 detectComposerTrigger,
7778 expandCollapsedComposerCursor,
......@@ -3080,6 +3081,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
30803081 useEffect(() => {
30813082 const nextCustomAnswer = activePendingProgress?.customAnswer;
30823083 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 }
30833093 lastSyncedPendingInputRef.current = null;
30843094 return;
30853095 }
......@@ -3101,19 +3111,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31013111 }
31023112
31033113 promptRef.current = nextCustomAnswer;
3104 const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);
3105 setComposerCursor(nextCursor);
3106 setComposerTrigger(
3107 detectComposerTrigger(
3108 nextCustomAnswer,
3109 expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
3110 ),
3111 );
3114 const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer);
3115 setComposerCursor(cursor);
3116 setComposerTrigger(trigger);
31123117 setComposerHighlightedItemId(null);
31133118 }, [
31143119 activePendingProgress?.customAnswer,
31153120 activePendingProgress?.activeQuestion?.id,
31163121 activePendingUserInput?.requestId,
3122 prompt,
31173123 promptRef,
31183124 ]);
31193125
apps/web/src/composer-logic.test.ts+30
......@@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test";
1010import {
1111 clampCollapsedComposerCursor,
1212 collapseExpandedComposerCursor,
13 composerStateAtPromptEnd,
1314 composerSubmissionIntentForEnter,
1415 detectComposerTrigger,
1516 expandCollapsedComposerCursor,
......@@ -18,6 +19,7 @@ import {
1819 parseStandaloneComposerSlashCommand,
1920 replaceTextRange,
2021} from "./composer-logic";
22import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput";
2123import { formatTerminalContextReference } from "./lib/terminalContext";
2224
2325const terminalReference = formatTerminalContextReference({
......@@ -482,6 +484,34 @@ describe("expandCollapsedComposerCursor", () => {
482484 });
483485});
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
485515describe("collapseExpandedComposerCursor", () => {
486516 it("keeps cursor unchanged when no mention segment is present", () => {
487517 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
265265 };
266266}
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
268280export function parseStandaloneComposerSlashCommand(
269281 text: string,
270282): Exclude<ComposerSlashCommand, "model"> | null {
apps/web/src/pendingUserInput.test.ts+20
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
33import {
44 buildPendingUserInputAnswers,
5 carryDisplacedCustomAnswerIntoPrompt,
56 countAnsweredPendingUserInputQuestions,
67 derivePendingUserInputProgress,
78 resolvePendingUserInputAnswer,
......@@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => {
321322 }),
322323 ).toBeNull();
323324});
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(
8282 };
8383}
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
85107export function togglePendingUserInputOptionSelection(
86108 question: UserInputQuestion,
87109 draft: PendingUserInputDraftAnswer | undefined,