| 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,6 +148,7 @@ import { |
| 148 | } from "./chat/timelineScrollAnchoring"; | 148 | } from "./chat/timelineScrollAnchoring"; |
| 149 | import { | 149 | import { |
| 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 === questionId | 8826 | (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 | ); |
| 8845 | 8859 |
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 | } |
| 3102 | 3112 | ||
| 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 | ]); |
| 3119 | 3125 |
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"; |
| 10 | import { | 10 | import { |
| 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"; |
| 22 | import { carryDisplacedCustomAnswerIntoPrompt } from "./pendingUserInput"; | ||
| 21 | import { formatTerminalContextReference } from "./lib/terminalContext"; | 23 | import { formatTerminalContextReference } from "./lib/terminalContext"; |
| 22 | 24 | ||
| 23 | const terminalReference = formatTerminalContextReference({ | 25 | const terminalReference = formatTerminalContextReference({ |
| ... | @@ -482,6 +484,34 @@ describe("expandCollapsedComposerCursor", () => { | ... | @@ -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 | describe("collapseExpandedComposerCursor", () => { | 515 | describe("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 | } |
| 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 | export function parseStandaloneComposerSlashCommand( | 280 | export 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"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | |||
| 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,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 | export function togglePendingUserInputOptionSelection( | 107 | export function togglePendingUserInputOptionSelection( |
| 86 | question: UserInputQuestion, | 108 | question: UserInputQuestion, |
| 87 | draft: PendingUserInputDraftAnswer | undefined, | 109 | draft: PendingUserInputDraftAnswer | undefined, |