diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79d393fc8d1f180c147e6b7053c13f61f5fbd66c..667eb0e11f6c6d6b71959ff7f50a321baebb6552 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -148,6 +148,7 @@ import { } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -8810,6 +8811,16 @@ export default function ChatView(props: ChatViewProps) { if (!activePendingUserInput) { return; } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + } setPendingUserInputAnswersByRequestId((existing) => { const question = (activePendingProgress?.activeQuestion?.id === questionId @@ -8839,7 +8850,10 @@ export default function ChatView(props: ChatViewProps) { activePendingProgress?.activeQuestion, activePendingUserInput, activePendingRequestKey, + composerDraftTarget, composerRef, + pendingUserInputAnswersByRequestId, + setComposerDraftPrompt, ], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8f91f08c709328f7113b99f81fdbe31e440e5776..255f61a0c752014dc8a16358f5c567829a01326e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -72,6 +72,7 @@ import { type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, + composerStateAtPromptEnd, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -3080,6 +3081,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { + // The question is gone and the editor shows the thread draft again. The + // ref still holds the last answer text, and Send reads the ref. Place + // the caret at the end so the next keystroke appends. + if (lastSyncedPendingInputRef.current !== null) { + promptRef.current = prompt; + const { cursor, trigger } = composerStateAtPromptEnd(prompt); + setComposerCursor(cursor); + setComposerTrigger(trigger); + } lastSyncedPendingInputRef.current = null; return; } @@ -3101,19 +3111,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); + const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); + setComposerCursor(cursor); + setComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, + prompt, promptRef, ]); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index d4509516cba7e0fea571dc26ca42e5b5d5ef0e2d..83f143a546fd8c386eb2c71b562f4d0254871649 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, + composerStateAtPromptEnd, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -18,6 +19,7 @@ import { parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; +import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput"; import { formatTerminalContextReference } from "./lib/terminalContext"; const terminalReference = formatTerminalContextReference({ @@ -482,6 +484,34 @@ describe("expandCollapsedComposerCursor", () => { }); }); +describe("composerStateAtPromptEnd", () => { + it("puts the caret at the end of a restored parked draft", () => { + const prompt = carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half"); + + expect(composerStateAtPromptEnd(prompt)).toEqual({ + cursor: prompt.length, + trigger: null, + }); + }); + + it("collapses mention chips so the next keystroke lands after the draft", () => { + const prompt = carryDisplacedCustomAnswerIntoPrompt("", "see @AGENTS.md please"); + + expect(composerStateAtPromptEnd(prompt).cursor).toBe("see ".length + 1 + " please".length); + expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(0); + expect(composerStateAtPromptEnd(prompt).cursor).not.toBe(prompt.length); + }); + + it("keeps a trailing mention trigger when the restored draft ends with @", () => { + const prompt = "look at @"; + + expect(composerStateAtPromptEnd(prompt)).toEqual({ + cursor: prompt.length, + trigger: { kind: "path", query: "", rangeStart: "look at ".length, rangeEnd: prompt.length }, + }); + }); +}); + describe("collapseExpandedComposerCursor", () => { it("keeps cursor unchanged when no mention segment is present", () => { expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index a2ed12b86b9ebe638a260684aa9c2ae46e03f0c7..ff638a6d2e46c63ef7691d627a5dfc039d96f987 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -265,6 +265,18 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +/** Caret and trigger after replacing composer text and continuing at the end. */ +export function composerStateAtPromptEnd(text: string): { + cursor: number; + trigger: ComposerTrigger | null; +} { + const cursor = collapseExpandedComposerCursor(text, text.length); + return { + cursor, + trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), + }; +} + export function parseStandaloneComposerSlashCommand( text: string, ): Exclude | null { diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index f2f093a5c8268e05f8213b62c5c3cabadca10e40..350087c3aaffcd10061c2b726774d9641aaacdf8 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildPendingUserInputAnswers, + carryDisplacedCustomAnswerIntoPrompt, countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, resolvePendingUserInputAnswer, @@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => { }), ).toBeNull(); }); + +describe("carryDisplacedCustomAnswerIntoPrompt", () => { + it("keeps the thread draft when nothing was typed into the answer", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("draft", undefined)).toBe("draft"); + expect(carryDisplacedCustomAnswerIntoPrompt("draft", " ")).toBe("draft"); + }); + + it("moves the typed answer into an empty thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("", "also rename the flag ")).toBe( + "also rename the flag", + ); + }); + + it("appends the typed answer after an existing thread draft", () => { + expect(carryDisplacedCustomAnswerIntoPrompt("first half\n", "second half")).toBe( + "first half\n\nsecond half", + ); + }); +}); diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 5ac004649a2715f4659486ac6e229417dfb83f2d..5d22e56105aa632b45d4dbf43d072f9bfddbff79 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -82,6 +82,28 @@ export function setPendingUserInputCustomAnswer( }; } +const DISPLACED_ANSWER_SEPARATOR = "\n\n"; + +/** + * Selecting an option replaces the custom answer, because a non-empty custom + * answer outranks selected options in `resolvePendingUserInputAnswer`. Text the + * user typed into the answer field must not vanish on that click: it moves back + * into the thread draft, after whatever was already waiting there. + */ +export function carryDisplacedCustomAnswerIntoPrompt( + prompt: string, + customAnswer: string | undefined, +): string { + const displaced = customAnswer?.trim() ?? ""; + if (displaced.length === 0) { + return prompt; + } + if (prompt.trim().length === 0) { + return displaced; + } + return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; +} + export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined,