| author | |
| committer | |
| log | e36725682bd2935f1bcfdf81660cb569b5f3df29 |
| tree | 64ee16c639e95728978ca4ad26666902ca23c15f |
| parent | dcf8942304cfef382af55c0068cb2f04c86757fc |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 112 insertions(+), 8 deletions(-)
apps/web/src/components/ChatView.tsx+14| ... | ... | @@ -148,6 +148,7 @@ import { |
| 148 | 148 | } from "./chat/timelineScrollAnchoring"; |
| 149 | 149 | import { |
| 150 | 150 | buildPendingUserInputAnswers, |
| 151 | carryDisplacedCustomAnswerIntoPrompt, | |
| 151 | 152 | derivePendingUserInputProgress, |
| 152 | 153 | setPendingUserInputCustomAnswer, |
| 153 | 154 | togglePendingUserInputOptionSelection, |
| ... | ... | @@ -8810,6 +8811,16 @@ export default function ChatView(props: ChatViewProps) { |
| 8810 | 8811 | if (!activePendingUserInput) { |
| 8811 | 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 | 8824 | setPendingUserInputAnswersByRequestId((existing) => { |
| 8814 | 8825 | const question = |
| 8815 | 8826 | (activePendingProgress?.activeQuestion?.id === questionId |
| ... | ... | @@ -8839,7 +8850,10 @@ export default function ChatView(props: ChatViewProps) { |
| 8839 | 8850 | activePendingProgress?.activeQuestion, |
| 8840 | 8851 | activePendingUserInput, |
| 8841 | 8852 | activePendingRequestKey, |
| 8853 | composerDraftTarget, | |
| 8842 | 8854 | composerRef, |
| 8855 | pendingUserInputAnswersByRequestId, | |
| 8856 | setComposerDraftPrompt, | |
| 8843 | 8857 | ], |
| 8844 | 8858 | ); |
| 8845 | 8859 |
apps/web/src/components/chat/ChatComposer.tsx+14-8| ... | ... | @@ -72,6 +72,7 @@ import { |
| 72 | 72 | type ComposerSubmissionIntent, |
| 73 | 73 | type ComposerTrigger, |
| 74 | 74 | collapseExpandedComposerCursor, |
| 75 | composerStateAtPromptEnd, | |
| 75 | 76 | composerSubmissionIntentForEnter, |
| 76 | 77 | detectComposerTrigger, |
| 77 | 78 | expandCollapsedComposerCursor, |
| ... | ... | @@ -3080,6 +3081,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3080 | 3081 | useEffect(() => { |
| 3081 | 3082 | const nextCustomAnswer = activePendingProgress?.customAnswer; |
| 3082 | 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 | 3093 | lastSyncedPendingInputRef.current = null; |
| 3084 | 3094 | return; |
| 3085 | 3095 | } |
| ... | ... | @@ -3101,19 +3111,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3101 | 3111 | } |
| 3102 | 3112 | |
| 3103 | 3113 | 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); | |
| 3112 | 3117 | setComposerHighlightedItemId(null); |
| 3113 | 3118 | }, [ |
| 3114 | 3119 | activePendingProgress?.customAnswer, |
| 3115 | 3120 | activePendingProgress?.activeQuestion?.id, |
| 3116 | 3121 | activePendingUserInput?.requestId, |
| 3122 | prompt, | |
| 3117 | 3123 | promptRef, |
| 3118 | 3124 | ]); |
| 3119 | 3125 |
apps/web/src/composer-logic.test.ts+30| ... | ... | @@ -10,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 10 | 10 | import { |
| 11 | 11 | clampCollapsedComposerCursor, |
| 12 | 12 | collapseExpandedComposerCursor, |
| 13 | composerStateAtPromptEnd, | |
| 13 | 14 | composerSubmissionIntentForEnter, |
| 14 | 15 | detectComposerTrigger, |
| 15 | 16 | expandCollapsedComposerCursor, |
| ... | ... | @@ -18,6 +19,7 @@ import { |
| 18 | 19 | parseStandaloneComposerSlashCommand, |
| 19 | 20 | replaceTextRange, |
| 20 | 21 | } from "./composer-logic"; |
| 22 | import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput"; | |
| 21 | 23 | import { formatTerminalContextReference } from "./lib/terminalContext"; |
| 22 | 24 | |
| 23 | 25 | const terminalReference = formatTerminalContextReference({ |
| ... | ... | @@ -482,6 +484,34 @@ describe("expandCollapsedComposerCursor", () => { |
| 482 | 484 | }); |
| 483 | 485 | }); |
| 484 | 486 | |
| 487 | describe("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 | ||
| 485 | 515 | describe("collapseExpandedComposerCursor", () => { |
| 486 | 516 | it("keeps cursor unchanged when no mention segment is present", () => { |
| 487 | 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 | 265 | }; |
| 266 | 266 | } |
| 267 | 267 | |
| 268 | /** Caret and trigger after replacing composer text and continuing at the end. */ | |
| 269 | export 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 | ||
| 268 | 280 | export function parseStandaloneComposerSlashCommand( |
| 269 | 281 | text: string, |
| 270 | 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 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | buildPendingUserInputAnswers, |
| 5 | carryDisplacedCustomAnswerIntoPrompt, | |
| 5 | 6 | countAnsweredPendingUserInputQuestions, |
| 6 | 7 | derivePendingUserInputProgress, |
| 7 | 8 | resolvePendingUserInputAnswer, |
| ... | ... | @@ -321,3 +322,22 @@ it("accepts attachment-only answers after every upload finishes", () => { |
| 321 | 322 | }), |
| 322 | 323 | ).toBeNull(); |
| 323 | 324 | }); |
| 325 | ||
| 326 | describe("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 | 82 | }; |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | const 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 | */ | |
| 93 | export 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 | ||
| 85 | 107 | export function togglePendingUserInputOptionSelection( |
| 86 | 108 | question: UserInputQuestion, |
| 87 | 109 | draft: PendingUserInputDraftAnswer | undefined, |