| author | |
| committer | |
| log | e8f5850b824572bab76bf810696f875eb648eff9 |
| tree | b5da314d77b5a49497b951252f3d66297dd87757 |
| parent | eb1cf7bd0efba7364d53907036f5b7eefb379c39 |
| signature | Signed by PGP key B5690EEEBB952194 |
22 files changed, 570 insertions(+), 155 deletions(-)
apps/mobile/src/features/threads/QuestionAttachments.tsx+2| ... | @@ -189,6 +189,8 @@ export function QuestionAttachments(props: { | ... | @@ -189,6 +189,8 @@ export function QuestionAttachments(props: { |
| 189 | <TextInputWrapper onPaste={paste}> | 189 | <TextInputWrapper onPaste={paste}> |
| 190 | <TextInput | 190 | <TextInput |
| 191 | value={props.value} | 191 | value={props.value} |
| 192 | multiline={props.question.initialAnswer !== undefined} | ||
| 193 | submitBehavior={props.question.initialAnswer !== undefined ? "newline" : undefined} | ||
| 192 | editable={!props.disabled} | 194 | editable={!props.disabled} |
| 193 | onChangeText={props.onChangeText} | 195 | onChangeText={props.onChangeText} |
| 194 | onFocus={() => props.onInputFocusChange?.(true)} | 196 | onFocus={() => props.onInputFocusChange?.(true)} |
apps/mobile/src/lib/threadActivity.test.ts+23| ... | @@ -2011,6 +2011,29 @@ const multiSelectQuestion = { | ... | @@ -2011,6 +2011,29 @@ const multiSelectQuestion = { |
| 2011 | } as const; | 2011 | } as const; |
| 2012 | 2012 | ||
| 2013 | describe("pending user input answers", () => { | 2013 | describe("pending user input answers", () => { |
| 2014 | it("preserves exact editor text, including a deliberately cleared answer", () => { | ||
| 2015 | const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; | ||
| 2016 | expect( | ||
| 2017 | buildPendingUserInputAnswers([question], { | ||
| 2018 | runtime: { customAnswer: question.initialAnswer }, | ||
| 2019 | }), | ||
| 2020 | ).toEqual({ runtime: question.initialAnswer }); | ||
| 2021 | expect( | ||
| 2022 | buildPendingUserInputAnswers([question], { | ||
| 2023 | runtime: { customAnswer: " Edited message\n\n" }, | ||
| 2024 | }), | ||
| 2025 | ).toEqual({ runtime: " Edited message\n\n" }); | ||
| 2026 | expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({ | ||
| 2027 | runtime: "", | ||
| 2028 | }); | ||
| 2029 | expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({ | ||
| 2030 | runtime: " \n", | ||
| 2031 | }); | ||
| 2032 | expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual( | ||
| 2033 | { customAnswer: "" }, | ||
| 2034 | ); | ||
| 2035 | }); | ||
| 2036 | |||
| 2014 | it("replaces single-select options and toggles multi-select options", () => { | 2037 | it("replaces single-select options and toggles multi-select options", () => { |
| 2015 | expect( | 2038 | expect( |
| 2016 | togglePendingUserInputOptionSelection( | 2039 | togglePendingUserInputOptionSelection( |
apps/mobile/src/lib/threadActivity.ts+10-6| ... | @@ -399,8 +399,12 @@ function resolvePendingUserInputAnswer( | ... | @@ -399,8 +399,12 @@ function resolvePendingUserInputAnswer( |
| 399 | ): string | ReadonlyArray<string> | null { | 399 | ): string | ReadonlyArray<string> | null { |
| 400 | if (draft?.attachmentsBlocked) return null; | 400 | if (draft?.attachmentsBlocked) return null; |
| 401 | const customAnswer = | 401 | const customAnswer = |
| 402 | question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); | 402 | question.allowCustomAnswer === false |
| 403 | if (customAnswer) { | 403 | ? null |
| 404 | : question.initialAnswer !== undefined | ||
| 405 | ? (draft?.customAnswer ?? null) | ||
| 406 | : normalizeDraftAnswer(draft?.customAnswer); | ||
| 407 | if (customAnswer !== null && customAnswer.length > 0) { | ||
| 404 | return customAnswer; | 408 | return customAnswer; |
| 405 | } | 409 | } |
| 406 | 410 | ||
| ... | @@ -408,12 +412,12 @@ function resolvePendingUserInputAnswer( | ... | @@ -408,12 +412,12 @@ function resolvePendingUserInputAnswer( |
| 408 | if (question.multiSelect) { | 412 | if (question.multiSelect) { |
| 409 | return selectedOptionValues.length > 0 | 413 | return selectedOptionValues.length > 0 |
| 410 | ? selectedOptionValues | 414 | ? selectedOptionValues |
| 411 | : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 | 415 | : (customAnswer ?? |
| 412 | ? "" | 416 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); |
| 413 | : null; | ||
| 414 | } | 417 | } |
| 415 | return ( | 418 | return ( |
| 416 | selectedOptionValues[0] ?? | 419 | selectedOptionValues[0] ?? |
| 420 | customAnswer ?? | ||
| 417 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) | 421 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) |
| 418 | ); | 422 | ); |
| 419 | } | 423 | } |
| ... | @@ -1633,7 +1637,7 @@ export function setPendingUserInputCustomAnswer( | ... | @@ -1633,7 +1637,7 @@ export function setPendingUserInputCustomAnswer( |
| 1633 | } | 1637 | } |
| 1634 | 1638 | ||
| 1635 | const selectedOptionValues = | 1639 | const selectedOptionValues = |
| 1636 | customAnswer.trim().length > 0 | 1640 | question.initialAnswer !== undefined || customAnswer.trim().length > 0 |
| 1637 | ? undefined | 1641 | ? undefined |
| 1638 | : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); | 1642 | : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); |
| 1639 | return { | 1643 | return { |
apps/mobile/src/state/use-selected-thread-requests.ts+14| ... | @@ -18,6 +18,7 @@ import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/c | ... | @@ -18,6 +18,7 @@ import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/c |
| 18 | import { | 18 | import { |
| 19 | type PendingThreadRequests, | 19 | type PendingThreadRequests, |
| 20 | type ThreadUserInputQuestion, | 20 | type ThreadUserInputQuestion, |
| 21 | seedUserInputDraftAnswers, | ||
| 21 | } from "@t3tools/client-runtime/state/thread-requests"; | 22 | } from "@t3tools/client-runtime/state/thread-requests"; |
| 22 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; | 23 | import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; |
| 23 | import { Atom } from "effect/reactivity"; | 24 | import { Atom } from "effect/reactivity"; |
| ... | @@ -104,6 +105,19 @@ export function useSelectedThreadRequests() { | ... | @@ -104,6 +105,19 @@ export function useSelectedThreadRequests() { |
| 104 | const activePendingApproval = activePendingApprovals[0] ?? null; | 105 | const activePendingApproval = activePendingApprovals[0] ?? null; |
| 105 | const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs; | 106 | const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs; |
| 106 | const activePendingUserInput = activePendingUserInputs[0] ?? null; | 107 | const activePendingUserInput = activePendingUserInputs[0] ?? null; |
| 108 | useEffect(() => { | ||
| 109 | if (!activePendingUserInput || !selectedThreadShell) return; | ||
| 110 | const requestKey = scopedRequestKey( | ||
| 111 | selectedThreadShell.environmentId, | ||
| 112 | activePendingUserInput.requestId, | ||
| 113 | ); | ||
| 114 | const existing = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); | ||
| 115 | const drafts = existing[requestKey] ?? {}; | ||
| 116 | const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); | ||
| 117 | if (seeded !== drafts) { | ||
| 118 | appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...existing, [requestKey]: seeded }); | ||
| 119 | } | ||
| 120 | }, [activePendingUserInput, selectedThreadShell]); | ||
| 107 | const questionServerConfigs = useServerConfigs(); | 121 | const questionServerConfigs = useServerConfigs(); |
| 108 | const attachmentDrafts = useAtomValue(composerDraftsAtom); | 122 | const attachmentDrafts = useAtomValue(composerDraftsAtom); |
| 109 | const preparationCounts = useAtomValue(questionAttachmentPreparationAtom); | 123 | const preparationCounts = useAtomValue(questionAttachmentPreparationAtom); |
apps/server/src/mcp/toolkits/thread/tools.ts+1| ... | @@ -141,6 +141,7 @@ const question = Schema.Struct({ | ... | @@ -141,6 +141,7 @@ const question = Schema.Struct({ |
| 141 | ), | 141 | ), |
| 142 | multiSelect: Schema.optional(Schema.Boolean), | 142 | multiSelect: Schema.optional(Schema.Boolean), |
| 143 | allowCustomAnswer: Schema.optional(Schema.Boolean), | 143 | allowCustomAnswer: Schema.optional(Schema.Boolean), |
| 144 | initialAnswer: Schema.optional(Schema.String), | ||
| 144 | required: Schema.optional(Schema.Boolean), | 145 | required: Schema.optional(Schema.Boolean), |
| 145 | }); | 146 | }); |
| 146 | const pendingRequest = Schema.Struct({ | 147 | const pendingRequest = Schema.Struct({ |
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts+59| ... | @@ -2400,6 +2400,65 @@ describe("PiAdapterV2", () => { | ... | @@ -2400,6 +2400,65 @@ describe("PiAdapterV2", () => { |
| 2400 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | 2400 | }).pipe(Effect.scoped, Effect.provide(layerTest)), |
| 2401 | ); | 2401 | ); |
| 2402 | 2402 | ||
| 2403 | it.effect("keeps the complete editor prefill editable and returns exact edited text", () => | ||
| 2404 | Effect.gen(function* () { | ||
| 2405 | const fake = yield* makeFakePi; | ||
| 2406 | const { runtime, takeEvent } = yield* openRuntime(fake); | ||
| 2407 | yield* runtime.ensureThread({ | ||
| 2408 | threadId: THREAD_ID, | ||
| 2409 | modelSelection: modelSelection("default"), | ||
| 2410 | runtimePolicy, | ||
| 2411 | }); | ||
| 2412 | const prefill = ` fix(server): retain full editor content\n\n${"detail\n".repeat(500)} `; | ||
| 2413 | yield* fake.emit({ | ||
| 2414 | type: "extension_ui_request", | ||
| 2415 | id: "ui-editor", | ||
| 2416 | method: "editor", | ||
| 2417 | title: "Commit message", | ||
| 2418 | prefill, | ||
| 2419 | }); | ||
| 2420 | const event = yield* takeEvent( | ||
| 2421 | (event) => | ||
| 2422 | event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", | ||
| 2423 | ); | ||
| 2424 | if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") | ||
| 2425 | return; | ||
| 2426 | assert.equal(event.turnItem.questions[0]?.initialAnswer, prefill); | ||
| 2427 | assert.deepEqual(event.turnItem.questions[0]?.options, []); | ||
| 2428 | assert.equal(event.turnItem.questions[0]?.question, "Commit message"); | ||
| 2429 | const edited = " fix(server): edited commit message\n\n"; | ||
| 2430 | yield* runtime.respondToRuntimeRequest({ | ||
| 2431 | requestId: event.turnItem.requestId, | ||
| 2432 | answers: { "ui-editor": edited }, | ||
| 2433 | }); | ||
| 2434 | const response = yield* fake.takeRequest("extension_ui_response"); | ||
| 2435 | assert.equal(response["value"], edited); | ||
| 2436 | assert.isUndefined(response["cancelled"]); | ||
| 2437 | yield* fake.emit({ | ||
| 2438 | type: "extension_ui_request", | ||
| 2439 | id: "ui-editor-cleared", | ||
| 2440 | method: "editor", | ||
| 2441 | title: "Commit message", | ||
| 2442 | prefill, | ||
| 2443 | }); | ||
| 2444 | const cleared = yield* takeEvent( | ||
| 2445 | (event) => | ||
| 2446 | event.type === "turn_item.updated" && | ||
| 2447 | event.turnItem.type === "user_input_request" && | ||
| 2448 | event.turnItem.questions.some((question) => question.id === "ui-editor-cleared"), | ||
| 2449 | ); | ||
| 2450 | if (cleared.type !== "turn_item.updated" || cleared.turnItem.type !== "user_input_request") | ||
| 2451 | return; | ||
| 2452 | yield* runtime.respondToRuntimeRequest({ | ||
| 2453 | requestId: cleared.turnItem.requestId, | ||
| 2454 | answers: { "ui-editor-cleared": "" }, | ||
| 2455 | }); | ||
| 2456 | const emptyResponse = yield* fake.takeRequest("extension_ui_response"); | ||
| 2457 | assert.equal(emptyResponse["value"], ""); | ||
| 2458 | assert.isUndefined(emptyResponse["cancelled"]); | ||
| 2459 | }).pipe(Effect.scoped, Effect.provide(layerTest)), | ||
| 2460 | ); | ||
| 2461 | |||
| 2403 | it.effect("raises bridge edit confirmations as file-change approvals", () => | 2462 | it.effect("raises bridge edit confirmations as file-change approvals", () => |
| 2404 | Effect.gen(function* () { | 2463 | Effect.gen(function* () { |
| 2405 | const fake = yield* makeFakePi; | 2464 | const fake = yield* makeFakePi; |
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts+11-15| ... | @@ -3160,25 +3160,21 @@ function piQuestion( | ... | @@ -3160,25 +3160,21 @@ function piQuestion( |
| 3160 | ? event["options"] | 3160 | ? event["options"] |
| 3161 | .filter((option): option is string => typeof option === "string") | 3161 | .filter((option): option is string => typeof option === "string") |
| 3162 | .map((option) => ({ label: option || "Empty value", description: option, value: option })) | 3162 | .map((option) => ({ label: option || "Empty value", description: option, value: option })) |
| 3163 | : [ | 3163 | : method === "editor" |
| 3164 | { | 3164 | ? [] |
| 3165 | label: "Submit empty value", | 3165 | : [ |
| 3166 | description: "Send an empty string to the extension.", | 3166 | { |
| 3167 | value: "", | 3167 | label: "Submit empty value", |
| 3168 | }, | 3168 | description: "Send an empty string to the extension.", |
| 3169 | ]; | 3169 | value: "", |
| 3170 | // The user-input contract has no prefill field, so an editor dialog's | 3170 | }, |
| 3171 | // prefill is surfaced inside the question text; without it the user would | 3171 | ]; |
| 3172 | // edit blind against content they cannot see. | ||
| 3173 | const prefill = method === "editor" ? recordString(event, "prefill") : undefined; | ||
| 3174 | const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; | 3172 | const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; |
| 3175 | return { | 3173 | return { |
| 3176 | id: questionId, | 3174 | id: questionId, |
| 3177 | header: title, | 3175 | header: title, |
| 3178 | question: | 3176 | question, |
| 3179 | prefill === undefined || prefill.length === 0 | 3177 | ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}), |
| 3180 | ? question | ||
| 3181 | : `${question}\n\nCurrent value:\n${prefill.slice(0, 2_000)}`, | ||
| 3182 | options, | 3178 | options, |
| 3183 | }; | 3179 | }; |
| 3184 | } | 3180 | } |
apps/web/src/components/ChatView.tsx+15| ... | @@ -217,6 +217,7 @@ import { | ... | @@ -217,6 +217,7 @@ import { |
| 217 | togglePendingUserInputOptionSelection, | 217 | togglePendingUserInputOptionSelection, |
| 218 | type PendingUserInputDraftAnswer, | 218 | type PendingUserInputDraftAnswer, |
| 219 | } from "../pendingUserInput"; | 219 | } from "../pendingUserInput"; |
| 220 | import { seedUserInputDraftAnswers } from "@t3tools/client-runtime/state/thread-requests"; | ||
| 220 | import { useUiStateStore } from "../uiStateStore"; | 221 | import { useUiStateStore } from "../uiStateStore"; |
| 221 | import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; | 222 | import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; |
| 222 | import { | 223 | import { |
| ... | @@ -3312,6 +3313,19 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3312,6 +3313,19 @@ export default function ChatView(props: ChatViewProps) { |
| 3312 | activeThreadId, | 3313 | activeThreadId, |
| 3313 | activePendingUserInput?.requestId, | 3314 | activePendingUserInput?.requestId, |
| 3314 | ]); | 3315 | ]); |
| 3316 | const activePendingAnswerDrafts = | ||
| 3317 | pendingUserInputAnswersByRequestId[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; | ||
| 3318 | if ( | ||
| 3319 | activePendingUserInput && | ||
| 3320 | seedUserInputDraftAnswers(activePendingUserInput.questions, activePendingAnswerDrafts) !== | ||
| 3321 | activePendingAnswerDrafts | ||
| 3322 | ) { | ||
| 3323 | setPendingUserInputAnswersByRequestId((existing) => { | ||
| 3324 | const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; | ||
| 3325 | const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); | ||
| 3326 | return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded }; | ||
| 3327 | }); | ||
| 3328 | } | ||
| 3315 | const pendingQuestionDraftKeys = useMemo( | 3329 | const pendingQuestionDraftKeys = useMemo( |
| 3316 | () => | 3330 | () => |
| 3317 | activeThreadId | 3331 | activeThreadId |
| ... | @@ -10294,6 +10308,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -10294,6 +10308,7 @@ export default function ChatView(props: ChatViewProps) { |
| 10294 | [questionId]: setPendingUserInputCustomAnswer( | 10308 | [questionId]: setPendingUserInputCustomAnswer( |
| 10295 | existing[activePendingRequestKey]?.[questionId], | 10309 | existing[activePendingRequestKey]?.[questionId], |
| 10296 | value, | 10310 | value, |
| 10311 | question, | ||
| 10297 | ), | 10312 | ), |
| 10298 | }, | 10313 | }, |
| 10299 | })); | 10314 | })); |
apps/web/src/components/ComposerPromptEditor.tsx+2-2| ... | @@ -9,8 +9,8 @@ export type { | ... | @@ -9,8 +9,8 @@ export type { |
| 9 | 9 | ||
| 10 | /** | 10 | /** |
| 11 | * The composer editor. Tiptap in both modes: the `richTextEnabled` setting | 11 | * The composer editor. Tiptap in both modes: the `richTextEnabled` setting |
| 12 | * toggles Markdown styling, never the engine. Plain mode renders every | 12 | * toggles Markdown styling, never the engine. Plain mode keeps Markdown |
| 13 | * marker as a literal character and serializes byte-identically. | 13 | * markers literal; `literalText` also preserves context-token sources as text. |
| 14 | */ | 14 | */ |
| 15 | export function ComposerPromptEditor(props: ComposerPromptEditorProps) { | 15 | export function ComposerPromptEditor(props: ComposerPromptEditorProps) { |
| 16 | return <ComposerPromptEditorTiptap {...props} />; | 16 | return <ComposerPromptEditorTiptap {...props} />; |
apps/web/src/components/ComposerPromptEditorTiptap.tsx+132-91| ... | @@ -140,6 +140,8 @@ export interface ComposerPromptEditorProps { | ... | @@ -140,6 +140,8 @@ export interface ComposerPromptEditorProps { |
| 140 | * literal character. | 140 | * literal character. |
| 141 | */ | 141 | */ |
| 142 | richTextEnabled?: boolean; | 142 | richTextEnabled?: boolean; |
| 143 | /** Preserve editor answers verbatim, without Markdown or context chips. */ | ||
| 144 | literalText?: boolean; | ||
| 143 | /** Draft records behind the prompt's context references, keyed by context id. */ | 145 | /** Draft records behind the prompt's context references, keyed by context id. */ |
| 144 | contextRecords: ComposerDraftContextRecords; | 146 | contextRecords: ComposerDraftContextRecords; |
| 145 | /** Structured clipboard payload for the given referenced ids, or null to skip. */ | 147 | /** Structured clipboard payload for the given referenced ids, or null to skip. */ |
| ... | @@ -773,11 +775,13 @@ const ComposerMarkersExtension = Extension.create({ | ... | @@ -773,11 +775,13 @@ const ComposerMarkersExtension = Extension.create({ |
| 773 | type TiptapEditor = NonNullable<ReturnType<typeof useEditor>>; | 775 | type TiptapEditor = NonNullable<ReturnType<typeof useEditor>>; |
| 774 | 776 | ||
| 775 | export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { | 777 | export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { |
| 776 | // Extensions are creation-time: flipping the setting remounts the editor. | 778 | // Extensions are creation-time, so changing text mode remounts the editor. |
| 777 | // Both halves initialize from the controlled Markdown value, so the draft | 779 | // Every mode initializes from the controlled value, preserving the draft. |
| 778 | // survives the flip. | ||
| 779 | return ( | 780 | return ( |
| 780 | <ComposerPromptEditorTiptapInner key={props.richTextEnabled ? "rich" : "plain"} {...props} /> | 781 | <ComposerPromptEditorTiptapInner |
| 782 | key={props.literalText ? "literal" : props.richTextEnabled ? "rich" : "plain"} | ||
| 783 | {...props} | ||
| 784 | /> | ||
| 781 | ); | 785 | ); |
| 782 | } | 786 | } |
| 783 | 787 | ||
| ... | @@ -806,6 +810,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -806,6 +810,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 806 | value, | 810 | value, |
| 807 | cursor, | 811 | cursor, |
| 808 | richTextEnabled, | 812 | richTextEnabled, |
| 813 | literalText = false, | ||
| 809 | contextRecords, | 814 | contextRecords, |
| 810 | buildContextClipboardFragment, | 815 | buildContextClipboardFragment, |
| 811 | importContextFragment, | 816 | importContextFragment, |
| ... | @@ -830,7 +835,22 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -830,7 +835,22 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 830 | } = props; | 835 | } = props; |
| 831 | // The setting toggles styling, not the engine: both modes are Tiptap. | 836 | // The setting toggles styling, not the engine: both modes are Tiptap. |
| 832 | // Plain mode disables the mark extensions, so markers stay literal text. | 837 | // Plain mode disables the mark extensions, so markers stay literal text. |
| 833 | const richText = richTextEnabled ?? false; | 838 | const richText = !literalText && (richTextEnabled ?? false); |
| 839 | const clampEditorCursor = useCallback( | ||
| 840 | (text: string, position: number) => | ||
| 841 | literalText | ||
| 842 | ? Math.max( | ||
| 843 | 0, | ||
| 844 | Math.min(text.length, Number.isFinite(position) ? Math.floor(position) : text.length), | ||
| 845 | ) | ||
| 846 | : clampCollapsedComposerCursor(text, position), | ||
| 847 | [literalText], | ||
| 848 | ); | ||
| 849 | const expandEditorCursor = useCallback( | ||
| 850 | (text: string, position: number) => | ||
| 851 | literalText ? position : expandCollapsedComposerCursor(text, position), | ||
| 852 | [literalText], | ||
| 853 | ); | ||
| 834 | 854 | ||
| 835 | const onChangeRef = useRef(onChange); | 855 | const onChangeRef = useRef(onChange); |
| 836 | const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); | 856 | const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); |
| ... | @@ -878,13 +898,13 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -878,13 +898,13 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 878 | }; | 898 | }; |
| 879 | }, []); | 899 | }, []); |
| 880 | 900 | ||
| 881 | const initialCursor = clampCollapsedComposerCursor(value, cursor); | 901 | const initialCursor = clampEditorCursor(value, cursor); |
| 882 | const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); | 902 | const initialExpandedCursor = expandEditorCursor(value, initialCursor); |
| 883 | const snapshotRef = useRef({ | 903 | const snapshotRef = useRef({ |
| 884 | value, | 904 | value, |
| 885 | cursor: initialCursor, | 905 | cursor: initialCursor, |
| 886 | expandedCursor: initialExpandedCursor, | 906 | expandedCursor: initialExpandedCursor, |
| 887 | contextIds: collectInlineContextIds(value), | 907 | contextIds: literalText ? [] : collectInlineContextIds(value), |
| 888 | }); | 908 | }); |
| 889 | const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); | 909 | const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); |
| 890 | const isApplyingControlledUpdateRef = useRef(false); | 910 | const isApplyingControlledUpdateRef = useRef(false); |
| ... | @@ -910,62 +930,68 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -910,62 +930,68 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 910 | [onCitationSubmitAndSend, openCitation], | 930 | [onCitationSubmitAndSend, openCitation], |
| 911 | ); | 931 | ); |
| 912 | 932 | ||
| 913 | const handleEditorChange = useCallback((updated: TiptapEditor) => { | 933 | const handleEditorChange = useCallback( |
| 914 | const map = serializeEditorDoc(updated.state.doc); | 934 | (updated: TiptapEditor) => { |
| 915 | const { from, to } = updated.state.selection; | 935 | const map = serializeEditorDoc(updated.state.doc); |
| 916 | const fromFlat = pmToFlat(map, from); | 936 | // Literal answers hold no chips, so reference-shaped text is not a context. |
| 917 | const toFlat = pmToFlat(map, to); | 937 | const contextIds = literalText ? [] : map.contextIds; |
| 918 | const nextValue = map.value; | 938 | const { from, to } = updated.state.selection; |
| 919 | const nextCursor = clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)); | 939 | const fromFlat = pmToFlat(map, from); |
| 920 | const nextExpandedCursor = Math.max( | 940 | const toFlat = pmToFlat(map, to); |
| 921 | 0, | 941 | const nextValue = map.value; |
| 922 | Math.min(map.value.length, flatToMarkdown(map, fromFlat)), | 942 | const nextCursor = clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)); |
| 923 | ); | 943 | const nextExpandedCursor = Math.max( |
| 924 | const nextSelectionRange = { | 944 | 0, |
| 925 | start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), | 945 | Math.min(map.value.length, flatToMarkdown(map, fromFlat)), |
| 926 | end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), | 946 | ); |
| 927 | }; | 947 | const nextSelectionRange = { |
| 928 | const previousSelectionRange = selectionRangeRef.current; | 948 | start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), |
| 929 | selectionRangeRef.current = nextSelectionRange; | 949 | end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), |
| 930 | setIsEmpty(nextValue.length === 0); | 950 | }; |
| 931 | const previousSnapshot = snapshotRef.current; | 951 | const previousSelectionRange = selectionRangeRef.current; |
| 932 | const snapshotChanged = !( | 952 | selectionRangeRef.current = nextSelectionRange; |
| 933 | previousSnapshot.value === nextValue && | 953 | setIsEmpty(nextValue.length === 0); |
| 934 | previousSnapshot.cursor === nextCursor && | 954 | const previousSnapshot = snapshotRef.current; |
| 935 | previousSnapshot.expandedCursor === nextExpandedCursor && | 955 | const snapshotChanged = !( |
| 936 | previousSnapshot.contextIds.length === map.contextIds.length && | 956 | previousSnapshot.value === nextValue && |
| 937 | previousSnapshot.contextIds.every((id, index) => id === map.contextIds[index]) | 957 | previousSnapshot.cursor === nextCursor && |
| 938 | ); | 958 | previousSnapshot.expandedCursor === nextExpandedCursor && |
| 939 | if (isApplyingControlledUpdateRef.current) return; | 959 | previousSnapshot.contextIds.length === contextIds.length && |
| 940 | if (!snapshotChanged) { | 960 | previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) |
| 941 | if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { | 961 | ); |
| 942 | onVisibleSelectionChangeRef.current?.(); | 962 | if (isApplyingControlledUpdateRef.current) return; |
| 963 | if (!snapshotChanged) { | ||
| 964 | if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { | ||
| 965 | onVisibleSelectionChangeRef.current?.(); | ||
| 966 | } | ||
| 967 | return; | ||
| 943 | } | 968 | } |
| 944 | return; | 969 | // A selection-only update while a newer prompt waits to be applied (a chip |
| 945 | } | 970 | // was just inserted through the store) would report stale text and clobber |
| 946 | // A selection-only update while a newer prompt waits to be applied (a chip | 971 | // the prompt. Let the controlled rewrite land instead. |
| 947 | // was just inserted through the store) would report stale text and clobber | 972 | if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { |
| 948 | // the prompt. Let the controlled rewrite land instead. | 973 | return; |
| 949 | if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { | 974 | } |
| 950 | return; | 975 | snapshotRef.current = { |
| 951 | } | 976 | value: nextValue, |
| 952 | snapshotRef.current = { | 977 | cursor: nextCursor, |
| 953 | value: nextValue, | 978 | expandedCursor: nextExpandedCursor, |
| 954 | cursor: nextCursor, | 979 | contextIds, |
| 955 | expandedCursor: nextExpandedCursor, | 980 | }; |
| 956 | contextIds: map.contextIds, | 981 | // A fence holds no chips, so nothing in it should summon the mention or |
| 957 | }; | 982 | // command menu: `@` in code is a decorator, not a file. Suppressing the |
| 958 | // A fence holds no chips, so nothing in it should summon the mention or | 983 | // trigger here also keeps the store from inserting a link the block can |
| 959 | // command menu: `@` in code is a decorator, not a file. Suppressing the | 984 | // only show as literal text, which the store would then count as a chip. |
| 960 | // trigger here also keeps the store from inserting a link the block can | 985 | const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; |
| 961 | // only show as literal text, which the store would then count as a chip. | 986 | const suppressTrigger = |
| 962 | const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; | 987 | literalText || |
| 963 | const suppressTrigger = | 988 | inCodeBlock || |
| 964 | inCodeBlock || | 989 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || |
| 965 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || | 990 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); |
| 966 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); | 991 | onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds); |
| 967 | onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds); | 992 | }, |
| 968 | }, []); | 993 | [clampEditorCursor, literalText], |
| 994 | ); | ||
| 969 | 995 | ||
| 970 | const editorAttributes = useMemo( | 996 | const editorAttributes = useMemo( |
| 971 | () => ({ | 997 | () => ({ |
| ... | @@ -1122,7 +1148,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1122,7 +1148,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1122 | description: shortDescription || found.description?.trim() || null, | 1148 | description: shortDescription || found.description?.trim() || null, |
| 1123 | }; | 1149 | }; |
| 1124 | }, | 1150 | }, |
| 1125 | { styling: richText }, | 1151 | { styling: richText, literalText }, |
| 1126 | ), | 1152 | ), |
| 1127 | editable: !disabled, | 1153 | editable: !disabled, |
| 1128 | editorProps: { | 1154 | editorProps: { |
| ... | @@ -1319,7 +1345,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1319,7 +1345,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1319 | return handled; | 1345 | return handled; |
| 1320 | }, | 1346 | }, |
| 1321 | handleTextInput: (view, from, to, text) => { | 1347 | handleTextInput: (view, from, to, text) => { |
| 1322 | if (text.length !== 1) return false; | 1348 | if (literalText || text.length !== 1) return false; |
| 1323 | const closer = SURROUND_CLOSE[text]; | 1349 | const closer = SURROUND_CLOSE[text]; |
| 1324 | if (!closer || from === to) return false; | 1350 | if (!closer || from === to) return false; |
| 1325 | // Never wrap chips or other atoms, and never wrap styled text: the | 1351 | // Never wrap chips or other atoms, and never wrap styled text: the |
| ... | @@ -1365,11 +1391,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1365,11 +1391,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1365 | return true; | 1391 | return true; |
| 1366 | } | 1392 | } |
| 1367 | const importFragment = importFragmentRef.current; | 1393 | const importFragment = importFragmentRef.current; |
| 1368 | let text = importFragment | 1394 | let text = |
| 1369 | ? importPastedComposerText(clipboardData, importFragment) | 1395 | !literalText && importFragment |
| 1370 | : pastedText; | 1396 | ? importPastedComposerText(clipboardData, importFragment) |
| 1397 | : pastedText; | ||
| 1371 | // Complete chips at paste boundaries just as autocomplete does. | 1398 | // Complete chips at paste boundaries just as autocomplete does. |
| 1372 | const tokens = collectComposerPromptInlineTokens(`${text}\n`); | 1399 | const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); |
| 1373 | const lastToken = tokens.at(-1); | 1400 | const lastToken = tokens.at(-1); |
| 1374 | if ( | 1401 | if ( |
| 1375 | (lastToken?.type === "mention" || lastToken?.type === "skill") && | 1402 | (lastToken?.type === "mention" || lastToken?.type === "skill") && |
| ... | @@ -1397,7 +1424,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1397,7 +1424,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1397 | insertMarkdownParagraphs( | 1424 | insertMarkdownParagraphs( |
| 1398 | text, | 1425 | text, |
| 1399 | skillLabelFor, | 1426 | skillLabelFor, |
| 1400 | { styling: richText, blocks: !nested }, | 1427 | { styling: richText, blocks: !nested, literalText }, |
| 1401 | (content) => { | 1428 | (content) => { |
| 1402 | // Tagged on the same transaction insertContent builds, so the | 1429 | // Tagged on the same transaction insertContent builds, so the |
| 1403 | // paste is one undo step of its own. | 1430 | // paste is one undo step of its own. |
| ... | @@ -1467,9 +1494,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1467,9 +1494,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1467 | const fromFlat = pmToFlat(map, from); | 1494 | const fromFlat = pmToFlat(map, from); |
| 1468 | const next: typeof snapshot = { | 1495 | const next: typeof snapshot = { |
| 1469 | value: map.value, | 1496 | value: map.value, |
| 1470 | cursor: clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)), | 1497 | cursor: clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)), |
| 1471 | expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), | 1498 | expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), |
| 1472 | contextIds: map.contextIds, | 1499 | contextIds: literalText ? [] : map.contextIds, |
| 1473 | }; | 1500 | }; |
| 1474 | const toFlat = pmToFlat(map, to); | 1501 | const toFlat = pmToFlat(map, to); |
| 1475 | selectionRangeRef.current = { | 1502 | selectionRangeRef.current = { |
| ... | @@ -1478,14 +1505,14 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1478,14 +1505,14 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1478 | }; | 1505 | }; |
| 1479 | snapshotRef.current = next; | 1506 | snapshotRef.current = next; |
| 1480 | return next; | 1507 | return next; |
| 1481 | }, [editor]); | 1508 | }, [clampEditorCursor, editor, literalText]); |
| 1482 | 1509 | ||
| 1483 | // Controlled value/cursor from the store (history recall, chip insertion…). | 1510 | // Controlled value/cursor from the store (history recall, chip insertion…). |
| 1484 | useLayoutEffect(() => { | 1511 | useLayoutEffect(() => { |
| 1485 | if (!editor) return; | 1512 | if (!editor) return; |
| 1486 | const initialSelection = !hasAppliedControlledSelectionRef.current; | 1513 | const initialSelection = !hasAppliedControlledSelectionRef.current; |
| 1487 | hasAppliedControlledSelectionRef.current = true; | 1514 | hasAppliedControlledSelectionRef.current = true; |
| 1488 | const normalizedCursor = clampCollapsedComposerCursor(value, cursor); | 1515 | const normalizedCursor = clampEditorCursor(value, cursor); |
| 1489 | const previousSnapshot = snapshotRef.current; | 1516 | const previousSnapshot = snapshotRef.current; |
| 1490 | if ( | 1517 | if ( |
| 1491 | !initialSelection && | 1518 | !initialSelection && |
| ... | @@ -1494,7 +1521,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1494,7 +1521,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1494 | ) { | 1521 | ) { |
| 1495 | return; | 1522 | return; |
| 1496 | } | 1523 | } |
| 1497 | const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); | 1524 | const normalizedExpandedCursor = expandEditorCursor(value, normalizedCursor); |
| 1498 | snapshotRef.current = { | 1525 | snapshotRef.current = { |
| 1499 | value, | 1526 | value, |
| 1500 | cursor: normalizedCursor, | 1527 | cursor: normalizedCursor, |
| ... | @@ -1514,9 +1541,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1514,9 +1541,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1514 | const pendingCitation = | 1541 | const pendingCitation = |
| 1515 | citationRequestRef.current?.value === value ? citationRequestRef.current : null; | 1542 | citationRequestRef.current?.value === value ? citationRequestRef.current : null; |
| 1516 | if (previousSnapshot.value !== value) { | 1543 | if (previousSnapshot.value !== value) { |
| 1517 | editor.commands.setContent(buildDocJson(value, skillLabelFor, { styling: richText }), { | 1544 | editor.commands.setContent( |
| 1518 | emitUpdate: false, | 1545 | buildDocJson(value, skillLabelFor, { styling: richText, literalText }), |
| 1519 | }); | 1546 | { |
| 1547 | emitUpdate: false, | ||
| 1548 | }, | ||
| 1549 | ); | ||
| 1520 | } | 1550 | } |
| 1521 | const map = serializeEditorDoc(editor.state.doc); | 1551 | const map = serializeEditorDoc(editor.state.doc); |
| 1522 | const flat = collapsedToFlat(map, normalizedCursor); | 1552 | const flat = collapsedToFlat(map, normalizedCursor); |
| ... | @@ -1545,7 +1575,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1545,7 +1575,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1545 | queueMicrotask(() => { | 1575 | queueMicrotask(() => { |
| 1546 | isApplyingControlledUpdateRef.current = false; | 1576 | isApplyingControlledUpdateRef.current = false; |
| 1547 | }); | 1577 | }); |
| 1548 | }, [cursor, editor, richText, skillLabelFor, value]); | 1578 | }, [ |
| 1579 | clampEditorCursor, | ||
| 1580 | cursor, | ||
| 1581 | editor, | ||
| 1582 | expandEditorCursor, | ||
| 1583 | literalText, | ||
| 1584 | richText, | ||
| 1585 | skillLabelFor, | ||
| 1586 | value, | ||
| 1587 | ]); | ||
| 1549 | 1588 | ||
| 1550 | const focusAt = useCallback( | 1589 | const focusAt = useCallback( |
| 1551 | (nextCursor: number) => { | 1590 | (nextCursor: number) => { |
| ... | @@ -1559,7 +1598,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1559,7 +1598,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1559 | // through the store). Reporting the editor's stale text now would | 1598 | // through the store). Reporting the editor's stale text now would |
| 1560 | // overwrite that prompt; the pending rewrite places the caret instead. | 1599 | // overwrite that prompt; the pending rewrite places the caret instead. |
| 1561 | if (snapshotRef.current.value !== latestValueRef.current) return; | 1600 | if (snapshotRef.current.value !== latestValueRef.current) return; |
| 1562 | const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); | 1601 | const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor); |
| 1563 | const map = serializeEditorDoc(editor.state.doc); | 1602 | const map = serializeEditorDoc(editor.state.doc); |
| 1564 | const flat = collapsedToFlat(map, boundedCursor); | 1603 | const flat = collapsedToFlat(map, boundedCursor); |
| 1565 | editor.commands.setTextSelection(flatToPm(map, flat)); | 1604 | editor.commands.setTextSelection(flatToPm(map, flat)); |
| ... | @@ -1568,7 +1607,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1568,7 +1607,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1568 | snapshotRef.current = { | 1607 | snapshotRef.current = { |
| 1569 | value: snapshotRef.current.value, | 1608 | value: snapshotRef.current.value, |
| 1570 | cursor: boundedCursor, | 1609 | cursor: boundedCursor, |
| 1571 | expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), | 1610 | expandedCursor: expandEditorCursor(snapshotRef.current.value, boundedCursor), |
| 1572 | contextIds: snapshotRef.current.contextIds, | 1611 | contextIds: snapshotRef.current.contextIds, |
| 1573 | }; | 1612 | }; |
| 1574 | selectionRangeRef.current = { | 1613 | selectionRangeRef.current = { |
| ... | @@ -1579,11 +1618,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1579,11 +1618,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1579 | snapshotRef.current.value, | 1618 | snapshotRef.current.value, |
| 1580 | boundedCursor, | 1619 | boundedCursor, |
| 1581 | snapshotRef.current.expandedCursor, | 1620 | snapshotRef.current.expandedCursor, |
| 1582 | false, | 1621 | literalText, |
| 1583 | snapshotRef.current.contextIds, | 1622 | snapshotRef.current.contextIds, |
| 1584 | ); | 1623 | ); |
| 1585 | }, | 1624 | }, |
| 1586 | [editor], | 1625 | [clampEditorCursor, editor, expandEditorCursor, literalText], |
| 1587 | ); | 1626 | ); |
| 1588 | 1627 | ||
| 1589 | useImperativeHandle( | 1628 | useImperativeHandle( |
| ... | @@ -1595,10 +1634,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1595,10 +1634,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1595 | focusAt, | 1634 | focusAt, |
| 1596 | focusAtEnd: () => { | 1635 | focusAtEnd: () => { |
| 1597 | focusAt( | 1636 | focusAt( |
| 1598 | collapseExpandedComposerCursor( | 1637 | literalText |
| 1599 | snapshotRef.current.value, | 1638 | ? snapshotRef.current.value.length |
| 1600 | snapshotRef.current.value.length, | 1639 | : collapseExpandedComposerCursor( |
| 1601 | ), | 1640 | snapshotRef.current.value, |
| 1641 | snapshotRef.current.value.length, | ||
| 1642 | ), | ||
| 1602 | ); | 1643 | ); |
| 1603 | }, | 1644 | }, |
| 1604 | readSelectionRange: () => { | 1645 | readSelectionRange: () => { |
| ... | @@ -1660,7 +1701,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1660,7 +1701,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1660 | : edgeRect.bottom - caretRect.bottom < threshold; | 1701 | : edgeRect.bottom - caretRect.bottom < threshold; |
| 1661 | }, | 1702 | }, |
| 1662 | }), | 1703 | }), |
| 1663 | [editor, focusAt, readSnapshot], | 1704 | [editor, focusAt, literalText, readSnapshot], |
| 1664 | ); | 1705 | ); |
| 1665 | 1706 | ||
| 1666 | const handleCopyCut = useCallback( | 1707 | const handleCopyCut = useCallback( |
| ... | @@ -1671,7 +1712,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1671,7 +1712,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1671 | const { from, to } = editor.state.selection; | 1712 | const { from, to } = editor.state.selection; |
| 1672 | if (from === to) return; | 1713 | if (from === to) return; |
| 1673 | const text = serializeSelection(editor.state.doc, from, to); | 1714 | const text = serializeSelection(editor.state.doc, from, to); |
| 1674 | const contextIds = Array.from(new Set(collectInlineContextIds(text))); | 1715 | const contextIds = literalText ? [] : Array.from(new Set(collectInlineContextIds(text))); |
| 1675 | const fragment = contextIds.length > 0 ? build?.(contextIds) : null; | 1716 | const fragment = contextIds.length > 0 ? build?.(contextIds) : null; |
| 1676 | event.preventDefault(); | 1717 | event.preventDefault(); |
| 1677 | clipboardData.setData("text/plain", text); | 1718 | clipboardData.setData("text/plain", text); |
| ... | @@ -1691,7 +1732,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1691,7 +1732,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1691 | .run(); | 1732 | .run(); |
| 1692 | } | 1733 | } |
| 1693 | }, | 1734 | }, |
| 1694 | [editor], | 1735 | [editor, literalText], |
| 1695 | ); | 1736 | ); |
| 1696 | 1737 | ||
| 1697 | return ( | 1738 | return ( |
| ... | @@ -1774,7 +1815,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { | ... | @@ -1774,7 +1815,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { |
| 1774 | function insertMarkdownParagraphs( | 1815 | function insertMarkdownParagraphs( |
| 1775 | value: string, | 1816 | value: string, |
| 1776 | skillLabelFor: (name: string) => SkillMeta, | 1817 | skillLabelFor: (name: string) => SkillMeta, |
| 1777 | options: { styling: boolean; blocks?: boolean }, | 1818 | options: { styling: boolean; blocks?: boolean; literalText?: boolean }, |
| 1778 | insertContent: (content: JSONContent[] | JSONContent) => void, | 1819 | insertContent: (content: JSONContent[] | JSONContent) => void, |
| 1779 | ): void { | 1820 | ): void { |
| 1780 | const blocks = buildTiptapContent(value, skillLabelFor, options); | 1821 | const blocks = buildTiptapContent(value, skillLabelFor, options); |
apps/web/src/components/chat/ChatComposer.tsx+64-24| ... | @@ -1577,6 +1577,7 @@ export interface ChatComposerProps { | ... | @@ -1577,6 +1577,7 @@ export interface ChatComposerProps { |
| 1577 | customAnswer: string; | 1577 | customAnswer: string; |
| 1578 | activeQuestion: { | 1578 | activeQuestion: { |
| 1579 | id: string; | 1579 | id: string; |
| 1580 | initialAnswer?: string | undefined; | ||
| 1580 | multiSelect?: boolean | undefined; | 1581 | multiSelect?: boolean | undefined; |
| 1581 | allowCustomAnswer?: boolean | undefined; | 1582 | allowCustomAnswer?: boolean | undefined; |
| 1582 | } | null; | 1583 | } | null; |
| ... | @@ -1807,6 +1808,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1807,6 +1808,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1807 | editingQueuedAttachments, | 1808 | editingQueuedAttachments, |
| 1808 | onRemoveEditingQueuedAttachment, | 1809 | onRemoveEditingQueuedAttachment, |
| 1809 | } = props; | 1810 | } = props; |
| 1811 | const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined; | ||
| 1810 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 1812 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 1811 | const composerDraftTargetKey = composerTargetKey(composerDraftTarget); | 1813 | const composerDraftTargetKey = composerTargetKey(composerDraftTarget); |
| 1812 | // Opening a running thread resyncs for a few frames. Show the sync row, and | 1814 | // Opening a running thread resyncs for a few frames. Show the sync row, and |
| ... | @@ -2392,16 +2394,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -2392,16 +2394,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2392 | // ------------------------------------------------------------------ | 2394 | // ------------------------------------------------------------------ |
| 2393 | // Composer-local state | 2395 | // Composer-local state |
| 2394 | // ------------------------------------------------------------------ | 2396 | // ------------------------------------------------------------------ |
| 2395 | const [composerCursor, setComposerCursor] = useState(() => | 2397 | const [composerCursor, setComposerCursor] = useState(() => { |
| 2396 | collapseExpandedComposerCursor(prompt, prompt.length), | 2398 | const value = isLiteralPendingAnswer ? (activePendingProgress?.customAnswer ?? prompt) : prompt; |
| 2397 | ); | 2399 | return collapseExpandedComposerCursor(value, value.length, isLiteralPendingAnswer); |
| 2400 | }); | ||
| 2398 | const { | 2401 | const { |
| 2399 | trigger: composerTrigger, | 2402 | trigger: composerTrigger, |
| 2400 | setTrigger: setComposerTrigger, | 2403 | setTrigger: setComposerTrigger, |
| 2401 | resolveTrigger: resolveComposerTrigger, | 2404 | resolveTrigger: resolveComposerTrigger, |
| 2402 | dismissTrigger: dismissComposerTrigger, | 2405 | dismissTrigger: dismissComposerTrigger, |
| 2403 | resetTrigger: resetComposerTrigger, | 2406 | resetTrigger: resetComposerTrigger, |
| 2404 | } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); | 2407 | } = useComposerTriggerState(() => |
| 2408 | isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length), | ||
| 2409 | ); | ||
| 2405 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 2410 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| 2406 | const composerSuggestionId = useId(); | 2411 | const composerSuggestionId = useId(); |
| 2407 | const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; | 2412 | const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; |
| ... | @@ -3395,9 +3400,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3395,9 +3400,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3395 | // Sync refs back to parent | 3400 | // Sync refs back to parent |
| 3396 | // ------------------------------------------------------------------ | 3401 | // ------------------------------------------------------------------ |
| 3397 | useEffect(() => { | 3402 | useEffect(() => { |
| 3403 | if (isLiteralPendingAnswer) return; | ||
| 3398 | promptRef.current = prompt; | 3404 | promptRef.current = prompt; |
| 3399 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); | 3405 | setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); |
| 3400 | }, [prompt, promptRef]); | 3406 | }, [isLiteralPendingAnswer, prompt, promptRef]); |
| 3401 | 3407 | ||
| 3402 | useEffect(() => { | 3408 | useEffect(() => { |
| 3403 | if (composerSubmissionError === null) return; | 3409 | if (composerSubmissionError === null) return; |
| ... | @@ -3498,13 +3504,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3498,13 +3504,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3498 | } | 3504 | } |
| 3499 | 3505 | ||
| 3500 | promptRef.current = nextCustomAnswer; | 3506 | promptRef.current = nextCustomAnswer; |
| 3501 | const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); | 3507 | const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer, isLiteralPendingAnswer); |
| 3502 | setComposerCursor(cursor); | 3508 | setComposerCursor(cursor); |
| 3503 | resetComposerTrigger(trigger); | 3509 | resetComposerTrigger(trigger); |
| 3504 | setComposerHighlightedItemId(null); | 3510 | setComposerHighlightedItemId(null); |
| 3505 | }, [ | 3511 | }, [ |
| 3506 | activePendingProgress?.customAnswer, | 3512 | activePendingProgress?.customAnswer, |
| 3507 | activePendingProgress?.activeQuestion?.id, | 3513 | activePendingProgress?.activeQuestion?.id, |
| 3514 | isLiteralPendingAnswer, | ||
| 3508 | activePendingUserInput?.requestId, | 3515 | activePendingUserInput?.requestId, |
| 3509 | prompt, | 3516 | prompt, |
| 3510 | promptRef, | 3517 | promptRef, |
| ... | @@ -3519,11 +3526,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3519,11 +3526,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3519 | setComposerHighlightedSearchKey(null); | 3526 | setComposerHighlightedSearchKey(null); |
| 3520 | setComposerSubmissionError(null); | 3527 | setComposerSubmissionError(null); |
| 3521 | setProviderInputSubmissionError(null); | 3528 | setProviderInputSubmissionError(null); |
| 3522 | setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); | 3529 | const { cursor, trigger } = composerStateAtPromptEnd(promptRef.current, isLiteralPendingAnswer); |
| 3523 | resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); | 3530 | setComposerCursor(cursor); |
| 3531 | resetComposerTrigger(trigger); | ||
| 3524 | setIsDragOverComposer(false); | 3532 | setIsDragOverComposer(false); |
| 3525 | setIsComposerScrollCollapsed(false); | 3533 | setIsComposerScrollCollapsed(false); |
| 3526 | }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]); | 3534 | }, [ |
| 3535 | draftId, | ||
| 3536 | activeThreadId, | ||
| 3537 | isLiteralPendingAnswer, | ||
| 3538 | promptRef, | ||
| 3539 | resetComposerTrigger, | ||
| 3540 | setIsComposerScrollCollapsed, | ||
| 3541 | ]); | ||
| 3527 | 3542 | ||
| 3528 | // ------------------------------------------------------------------ | 3543 | // ------------------------------------------------------------------ |
| 3529 | // Footer compact layout observation | 3544 | // Footer compact layout observation |
| ... | @@ -3866,8 +3881,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3866,8 +3881,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3866 | const nextCursor = collapseExpandedComposerCursor( | 3881 | const nextCursor = collapseExpandedComposerCursor( |
| 3867 | next.text, | 3882 | next.text, |
| 3868 | options?.expandedCursorAfterReplace ?? next.cursor, | 3883 | options?.expandedCursorAfterReplace ?? next.cursor, |
| 3884 | isLiteralPendingAnswer, | ||
| 3885 | ); | ||
| 3886 | const nextExpandedCursor = expandCollapsedComposerCursor( | ||
| 3887 | next.text, | ||
| 3888 | nextCursor, | ||
| 3889 | isLiteralPendingAnswer, | ||
| 3869 | ); | 3890 | ); |
| 3870 | const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); | ||
| 3871 | if (options?.citationComment) { | 3891 | if (options?.citationComment) { |
| 3872 | composerEditorRef.current?.requestCitationComment({ | 3892 | composerEditorRef.current?.requestCitationComment({ |
| 3873 | previousValue: currentText, | 3893 | previousValue: currentText, |
| ... | @@ -3885,13 +3905,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3885,13 +3905,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3885 | next.text, | 3905 | next.text, |
| 3886 | nextCursor, | 3906 | nextCursor, |
| 3887 | nextExpandedCursor, | 3907 | nextExpandedCursor, |
| 3888 | false, | 3908 | isLiteralPendingAnswer, |
| 3889 | ); | 3909 | ); |
| 3890 | } else { | 3910 | } else { |
| 3891 | setPrompt(next.text); | 3911 | setPrompt(next.text); |
| 3892 | } | 3912 | } |
| 3893 | setComposerCursor(nextCursor); | 3913 | setComposerCursor(nextCursor); |
| 3894 | setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); | 3914 | setComposerTrigger( |
| 3915 | isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor), | ||
| 3916 | ); | ||
| 3895 | if (options?.focusEditorAfterReplace !== false) { | 3917 | if (options?.focusEditorAfterReplace !== false) { |
| 3896 | window.requestAnimationFrame(() => { | 3918 | window.requestAnimationFrame(() => { |
| 3897 | // Type-to-focus routes only the first key through here; once the | 3919 | // Type-to-focus routes only the first key through here; once the |
| ... | @@ -3907,6 +3929,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3907,6 +3929,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3907 | [ | 3929 | [ |
| 3908 | activePendingProgress?.activeQuestion, | 3930 | activePendingProgress?.activeQuestion, |
| 3909 | activePendingUserInput, | 3931 | activePendingUserInput, |
| 3932 | isLiteralPendingAnswer, | ||
| 3910 | onChangeActivePendingUserInputCustomAnswer, | 3933 | onChangeActivePendingUserInputCustomAnswer, |
| 3911 | promptRef, | 3934 | promptRef, |
| 3912 | setPrompt, | 3935 | setPrompt, |
| ... | @@ -3927,10 +3950,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3927,10 +3950,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3927 | return { | 3950 | return { |
| 3928 | value: promptRef.current, | 3951 | value: promptRef.current, |
| 3929 | cursor: composerCursor, | 3952 | cursor: composerCursor, |
| 3930 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | 3953 | expandedCursor: expandCollapsedComposerCursor( |
| 3931 | contextIds: collectInlineContextIds(promptRef.current), | 3954 | promptRef.current, |
| 3955 | composerCursor, | ||
| 3956 | isLiteralPendingAnswer, | ||
| 3957 | ), | ||
| 3958 | contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(promptRef.current), | ||
| 3932 | }; | 3959 | }; |
| 3933 | }, [composerCursor, promptRef]); | 3960 | }, [composerCursor, isLiteralPendingAnswer, promptRef]); |
| 3934 | 3961 | ||
| 3935 | const resolveActiveComposerTrigger = useCallback((): { | 3962 | const resolveActiveComposerTrigger = useCallback((): { |
| 3936 | snapshot: { value: string; cursor: number; expandedCursor: number }; | 3963 | snapshot: { value: string; cursor: number; expandedCursor: number }; |
| ... | @@ -3939,11 +3966,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -3939,11 +3966,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3939 | const snapshot = readComposerSnapshot(); | 3966 | const snapshot = readComposerSnapshot(); |
| 3940 | return { | 3967 | return { |
| 3941 | snapshot, | 3968 | snapshot, |
| 3942 | trigger: resolveComposerTrigger( | 3969 | trigger: isLiteralPendingAnswer |
| 3943 | detectComposerTrigger(snapshot.value, snapshot.expandedCursor), | 3970 | ? null |
| 3944 | ), | 3971 | : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), |
| 3945 | }; | 3972 | }; |
| 3946 | }, [readComposerSnapshot, resolveComposerTrigger]); | 3973 | }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]); |
| 3947 | 3974 | ||
| 3948 | const { onUsageLimitsCommand } = props; | 3975 | const { onUsageLimitsCommand } = props; |
| 3949 | const onSelectComposerItem = useCallback( | 3976 | const onSelectComposerItem = useCallback( |
| ... | @@ -4434,7 +4461,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4434,7 +4461,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4434 | return true; | 4461 | return true; |
| 4435 | } | 4462 | } |
| 4436 | const { trigger } = resolveActiveComposerTrigger(); | 4463 | const { trigger } = resolveActiveComposerTrigger(); |
| 4437 | const menuIsActive = composerMenuOpenRef.current || trigger !== null; | 4464 | const menuIsActive = |
| 4465 | !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); | ||
| 4438 | if (key === "Escape") { | 4466 | if (key === "Escape") { |
| 4439 | if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; | 4467 | if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; |
| 4440 | dismissComposerTrigger(trigger); | 4468 | dismissComposerTrigger(trigger); |
| ... | @@ -4474,7 +4502,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -4474,7 +4502,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4474 | } | 4502 | } |
| 4475 | // Native task splitting preserves marks and chips on both sides of the caret. | 4503 | // Native task splitting preserves marks and chips on both sides of the caret. |
| 4476 | if (key === "Enter" && isTaskItem) return false; | 4504 | if (key === "Enter" && isTaskItem) return false; |
| 4477 | if (!event.isComposing && (key === "Enter" || (key === "Tab" && !event.shiftKey))) { | 4505 | if ( |
| 4506 | !isLiteralPendingAnswer && | ||
| 4507 | !event.isComposing && | ||
| 4508 | (key === "Enter" || (key === "Tab" && !event.shiftKey)) | ||
| 4509 | ) { | ||
| 4478 | const selection = composerEditorRef.current?.readSelectionRange(); | 4510 | const selection = composerEditorRef.current?.readSelectionRange(); |
| 4479 | const snapshot = readComposerSnapshot(); | 4511 | const snapshot = readComposerSnapshot(); |
| 4480 | if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { | 4512 | if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { |
| ... | @@ -5990,6 +6022,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5990,6 +6022,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5990 | bypassAutoAttachment: boolean, | 6022 | bypassAutoAttachment: boolean, |
| 5991 | selectionOverride?: { start: number; end: number }, | 6023 | selectionOverride?: { start: number; end: number }, |
| 5992 | ): boolean => { | 6024 | ): boolean => { |
| 6025 | // Editor answers keep pasted text in the answer, regardless of paste size. | ||
| 6026 | if (isLiteralPendingAnswer) return false; | ||
| 5993 | const questionCanAttach = | 6027 | const questionCanAttach = |
| 5994 | pendingUserInputs.length === 0 || | 6028 | pendingUserInputs.length === 0 || |
| 5995 | (supportsQuestionAttachments && | 6029 | (supportsQuestionAttachments && |
| ... | @@ -6436,11 +6470,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -6436,11 +6470,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6436 | detectTrigger?: boolean; | 6470 | detectTrigger?: boolean; |
| 6437 | }) => { | 6471 | }) => { |
| 6438 | const promptForState = options?.prompt ?? promptRef.current; | 6472 | const promptForState = options?.prompt ?? promptRef.current; |
| 6439 | const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); | 6473 | const cursor = clampCollapsedComposerCursor( |
| 6474 | promptForState, | ||
| 6475 | options?.cursor ?? 0, | ||
| 6476 | isLiteralPendingAnswer, | ||
| 6477 | ); | ||
| 6440 | setComposerHighlightedItemId(null); | 6478 | setComposerHighlightedItemId(null); |
| 6441 | setComposerCursor(cursor); | 6479 | setComposerCursor(cursor); |
| 6442 | resetComposerTrigger( | 6480 | resetComposerTrigger( |
| 6443 | options?.detectTrigger | 6481 | options?.detectTrigger && !isLiteralPendingAnswer |
| 6444 | ? detectComposerTrigger( | 6482 | ? detectComposerTrigger( |
| 6445 | promptForState, | 6483 | promptForState, |
| 6446 | expandCollapsedComposerCursor(promptForState, cursor), | 6484 | expandCollapsedComposerCursor(promptForState, cursor), |
| ... | @@ -6449,7 +6487,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -6449,7 +6487,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6449 | ); | 6487 | ); |
| 6450 | }, | 6488 | }, |
| 6451 | addTerminalContext: (selection: TerminalContextSelection) => { | 6489 | addTerminalContext: (selection: TerminalContextSelection) => { |
| 6452 | if (!activeThread || isChoiceOnlyPendingQuestion) return; | 6490 | if (!activeThread || isChoiceOnlyPendingQuestion || isLiteralPendingAnswer) return; |
| 6453 | const snapshot = readComposerSnapshot(); | 6491 | const snapshot = readComposerSnapshot(); |
| 6454 | const context = { | 6492 | const context = { |
| 6455 | id: randomUUID(), | 6493 | id: randomUUID(), |
| ... | @@ -6543,6 +6581,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -6543,6 +6581,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 6543 | isConnecting, | 6581 | isConnecting, |
| 6544 | isComposerApprovalState, | 6582 | isComposerApprovalState, |
| 6545 | isChoiceOnlyPendingQuestion, | 6583 | isChoiceOnlyPendingQuestion, |
| 6584 | isLiteralPendingAnswer, | ||
| 6546 | pendingUserInputs.length, | 6585 | pendingUserInputs.length, |
| 6547 | projectSelectionRequired, | 6586 | projectSelectionRequired, |
| 6548 | applyPromptReplacement, | 6587 | applyPromptReplacement, |
| ... | @@ -7400,6 +7439,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -7400,6 +7439,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 7400 | } | 7439 | } |
| 7401 | editorRef={composerEditorRef} | 7440 | editorRef={composerEditorRef} |
| 7402 | richTextEnabled={settings.composerRichTextEnabled} | 7441 | richTextEnabled={settings.composerRichTextEnabled} |
| 7442 | literalText={isLiteralPendingAnswer} | ||
| 7403 | value={ | 7443 | value={ |
| 7404 | isComposerApprovalState | 7444 | isComposerApprovalState |
| 7405 | ? "" | 7445 | ? "" |
apps/web/src/composer-logic.test.ts+30| ... | @@ -817,3 +817,33 @@ describe("parseStandaloneComposerSlashCommand", () => { | ... | @@ -817,3 +817,33 @@ describe("parseStandaloneComposerSlashCommand", () => { |
| 817 | expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); | 817 | expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); |
| 818 | }); | 818 | }); |
| 819 | }); | 819 | }); |
| 820 | |||
| 821 | describe("literal editor answer cursor state", () => { | ||
| 822 | const prefill = "- [X] done $my-skill "; | ||
| 823 | |||
| 824 | it("keeps character positions after token-like text for inserted lines and replacements", () => { | ||
| 825 | for (const replacement of ["\n", "\t", "edited\n "]) { | ||
| 826 | const next = replaceTextRange(prefill, prefill.length, prefill.length, replacement); | ||
| 827 | const cursor = collapseExpandedComposerCursor(next.text, next.cursor, true); | ||
| 828 | expect(next.text).toBe(prefill + replacement); | ||
| 829 | expect(cursor).toBe(next.text.length); | ||
| 830 | expect(expandCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor); | ||
| 831 | expect(clampCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor); | ||
| 832 | } | ||
| 833 | const replaced = replaceTextRange(prefill, 11, 20, "$another-skill"); | ||
| 834 | const cursor = collapseExpandedComposerCursor(replaced.text, replaced.cursor, true); | ||
| 835 | expect(replaced.text).toBe("- [X] done $another-skill "); | ||
| 836 | expect(cursor).toBe(25); | ||
| 837 | expect(expandCollapsedComposerCursor(replaced.text, cursor, true)).toBe(25); | ||
| 838 | }); | ||
| 839 | |||
| 840 | it("does not open a skill or path menu when restoring an editor answer", () => { | ||
| 841 | for (const value of ["- [X] done $my-skill", "Use @README", ""]) { | ||
| 842 | expect(composerStateAtPromptEnd(value, true)).toEqual({ | ||
| 843 | cursor: value.length, | ||
| 844 | trigger: null, | ||
| 845 | }); | ||
| 846 | } | ||
| 847 | expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill"); | ||
| 848 | }); | ||
| 849 | }); |
apps/web/src/composer-logic.ts+26-6| ... | @@ -89,7 +89,12 @@ function tokenStartForCursor(text: string, cursor: number): number { | ... | @@ -89,7 +89,12 @@ function tokenStartForCursor(text: string, cursor: number): number { |
| 89 | return index + 1; | 89 | return index + 1; |
| 90 | } | 90 | } |
| 91 | 91 | ||
| 92 | export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { | 92 | export function expandCollapsedComposerCursor( |
| 93 | text: string, | ||
| 94 | cursorInput: number, | ||
| 95 | literalText = false, | ||
| 96 | ): number { | ||
| 97 | if (literalText) return clampCursor(text, cursorInput); | ||
| 93 | const collapsedCursor = clampCursor(text, cursorInput); | 98 | const collapsedCursor = clampCursor(text, cursorInput); |
| 94 | const segments = splitPromptIntoComposerSegments(text); | 99 | const segments = splitPromptIntoComposerSegments(text); |
| 95 | if (segments.length === 0) { | 100 | if (segments.length === 0) { |
| ... | @@ -155,14 +160,24 @@ function clampCollapsedComposerCursorForSegments( | ... | @@ -155,14 +160,24 @@ function clampCollapsedComposerCursorForSegments( |
| 155 | return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); | 160 | return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); |
| 156 | } | 161 | } |
| 157 | 162 | ||
| 158 | export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { | 163 | export function clampCollapsedComposerCursor( |
| 164 | text: string, | ||
| 165 | cursorInput: number, | ||
| 166 | literalText = false, | ||
| 167 | ): number { | ||
| 168 | if (literalText) return clampCursor(text, cursorInput); | ||
| 159 | return clampCollapsedComposerCursorForSegments( | 169 | return clampCollapsedComposerCursorForSegments( |
| 160 | splitPromptIntoComposerSegments(text), | 170 | splitPromptIntoComposerSegments(text), |
| 161 | cursorInput, | 171 | cursorInput, |
| 162 | ); | 172 | ); |
| 163 | } | 173 | } |
| 164 | 174 | ||
| 165 | export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { | 175 | export function collapseExpandedComposerCursor( |
| 176 | text: string, | ||
| 177 | cursorInput: number, | ||
| 178 | literalText = false, | ||
| 179 | ): number { | ||
| 180 | if (literalText) return clampCursor(text, cursorInput); | ||
| 166 | const expandedCursor = clampCursor(text, cursorInput); | 181 | const expandedCursor = clampCursor(text, cursorInput); |
| 167 | const segments = splitPromptIntoComposerSegments(text); | 182 | const segments = splitPromptIntoComposerSegments(text); |
| 168 | if (segments.length === 0) { | 183 | if (segments.length === 0) { |
| ... | @@ -292,14 +307,19 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos | ... | @@ -292,14 +307,19 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos |
| 292 | } | 307 | } |
| 293 | 308 | ||
| 294 | /** Caret and trigger after replacing composer text and continuing at the end. */ | 309 | /** Caret and trigger after replacing composer text and continuing at the end. */ |
| 295 | export function composerStateAtPromptEnd(text: string): { | 310 | export function composerStateAtPromptEnd( |
| 311 | text: string, | ||
| 312 | literalText = false, | ||
| 313 | ): { | ||
| 296 | cursor: number; | 314 | cursor: number; |
| 297 | trigger: ComposerTrigger | null; | 315 | trigger: ComposerTrigger | null; |
| 298 | } { | 316 | } { |
| 299 | const cursor = collapseExpandedComposerCursor(text, text.length); | 317 | const cursor = collapseExpandedComposerCursor(text, text.length, literalText); |
| 300 | return { | 318 | return { |
| 301 | cursor, | 319 | cursor, |
| 302 | trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), | 320 | trigger: literalText |
| 321 | ? null | ||
| 322 | : detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), | ||
| 303 | }; | 323 | }; |
| 304 | } | 324 | } |
| 305 | 325 |
apps/web/src/composer-rich-text-doc.test.ts+48| ... | @@ -3,6 +3,7 @@ import StarterKit from "@tiptap/starter-kit"; | ... | @@ -3,6 +3,7 @@ import StarterKit from "@tiptap/starter-kit"; |
| 3 | import { TaskList } from "@tiptap/extension-task-list"; | 3 | import { TaskList } from "@tiptap/extension-task-list"; |
| 4 | import { Node as ProseMirrorNode } from "@tiptap/pm/model"; | 4 | import { Node as ProseMirrorNode } from "@tiptap/pm/model"; |
| 5 | import { EditorState, TextSelection } from "@tiptap/pm/state"; | 5 | import { EditorState, TextSelection } from "@tiptap/pm/state"; |
| 6 | import { splitBlockKeepMarks } from "@tiptap/pm/commands"; | ||
| 6 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 8 | ||
| 8 | import { collapseExpandedComposerCursor } from "./composer-logic"; | 9 | import { collapseExpandedComposerCursor } from "./composer-logic"; |
| ... | @@ -135,6 +136,53 @@ function roundTripPlain(value: string) { | ... | @@ -135,6 +136,53 @@ function roundTripPlain(value: string) { |
| 135 | return serializeEditorDoc(doc); | 136 | return serializeEditorDoc(doc); |
| 136 | } | 137 | } |
| 137 | 138 | ||
| 139 | describe("literal editor answers", () => { | ||
| 140 | const makeDoc = (value: string) => | ||
| 141 | ProseMirrorNode.fromJSON( | ||
| 142 | plainSchema, | ||
| 143 | buildDocJson(value, (name) => ({ label: name, description: null }), { | ||
| 144 | literalText: true, | ||
| 145 | }), | ||
| 146 | ); | ||
| 147 | |||
| 148 | it.each([ | ||
| 149 | "", | ||
| 150 | "\n\n", | ||
| 151 | " - [X] done\n\n**keep** €my-skill @README.md \n", | ||
| 152 | "line one\r\nline two\r\n", | ||
| 153 | `- [X] done\n${" detail €my-skill @README.md\n".repeat(100)}`, | ||
| 154 | ])("preserves the complete answer through selection and serialization", (value) => { | ||
| 155 | const doc = makeDoc(value); | ||
| 156 | doc.check(); | ||
| 157 | const serialized = serializeEditorDoc(doc); | ||
| 158 | expect(serialized.value).toBe(value); | ||
| 159 | expect(serialized.contextIds).toEqual([]); | ||
| 160 | expect(serializeSelection(doc, 1, doc.content.size - 1)).toBe(value); | ||
| 161 | for (let offset = 0; offset <= value.length; offset += 1) { | ||
| 162 | expect(flatToCollapsed(serialized, offset)).toBe(offset); | ||
| 163 | } | ||
| 164 | }); | ||
| 165 | |||
| 166 | it("edits literal Markdown, inserts a newline, and preserves an explicitly cleared answer", () => { | ||
| 167 | const value = " - [X] done €my-skill @README.md "; | ||
| 168 | const doc = makeDoc(value); | ||
| 169 | let state = EditorState.create({ | ||
| 170 | doc, | ||
| 171 | selection: TextSelection.create(doc, doc.content.size - 1), | ||
| 172 | }); | ||
| 173 | state = state.apply(state.tr.insertText(" edited")); | ||
| 174 | expect( | ||
| 175 | splitBlockKeepMarks(state, (transaction) => { | ||
| 176 | state = state.apply(transaction); | ||
| 177 | }), | ||
| 178 | ).toBe(true); | ||
| 179 | state = state.apply(state.tr.insertText("second line ")); | ||
| 180 | expect(serializeEditorDoc(state.doc).value).toBe(`${value} edited\nsecond line `); | ||
| 181 | state = state.apply(state.tr.delete(1, state.doc.content.size - 1)); | ||
| 182 | expect(serializeEditorDoc(state.doc).value).toBe(""); | ||
| 183 | }); | ||
| 184 | }); | ||
| 185 | |||
| 138 | describe("composer rich text document model", () => { | 186 | describe("composer rich text document model", () => { |
| 139 | it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])( | 187 | it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])( |
| 140 | "canonicalizes %s skill aliases while preserving amounts", | 188 | "canonicalizes %s skill aliases while preserving amounts", |
apps/web/src/composer-rich-text-doc.ts+9-2| ... | @@ -537,8 +537,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un | ... | @@ -537,8 +537,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un |
| 537 | export function buildTiptapContent( | 537 | export function buildTiptapContent( |
| 538 | value: string, | 538 | value: string, |
| 539 | skillLabelFor: (name: string) => SkillMeta, | 539 | skillLabelFor: (name: string) => SkillMeta, |
| 540 | options?: { styling?: boolean; blocks?: boolean }, | 540 | options?: { styling?: boolean; blocks?: boolean; literalText?: boolean }, |
| 541 | ): Record<string, unknown>[] { | 541 | ): Record<string, unknown>[] { |
| 542 | // Editor answers are verbatim text, including Markdown and context-token sources. | ||
| 543 | if (options?.literalText) { | ||
| 544 | return value.split("\n").map((line) => ({ | ||
| 545 | type: "paragraph", | ||
| 546 | ...(line ? { content: [{ type: "text", text: line }] } : {}), | ||
| 547 | })); | ||
| 548 | } | ||
| 542 | const styling = options?.styling ?? true; | 549 | const styling = options?.styling ?? true; |
| 543 | // Inline marks without block structure: text pasted into a list item or | 550 | // Inline marks without block structure: text pasted into a list item or |
| 544 | // quote, which has no line to write a nested block into. | 551 | // quote, which has no line to write a nested block into. |
| ... | @@ -759,7 +766,7 @@ export function buildTiptapContent( | ... | @@ -759,7 +766,7 @@ export function buildTiptapContent( |
| 759 | export function buildDocJson( | 766 | export function buildDocJson( |
| 760 | value: string, | 767 | value: string, |
| 761 | skillLabelFor: (name: string) => SkillMeta, | 768 | skillLabelFor: (name: string) => SkillMeta, |
| 762 | options?: { styling?: boolean }, | 769 | options?: { styling?: boolean; literalText?: boolean }, |
| 763 | ) { | 770 | ) { |
| 764 | const content = buildTiptapContent(value, skillLabelFor, options); | 771 | const content = buildTiptapContent(value, skillLabelFor, options); |
| 765 | // The empty line the rule extension keeps after a final rule. | 772 | // The empty line the rule extension keeps after a final rule. |
apps/web/src/pendingUserInput.test.ts+28| ... | @@ -54,6 +54,34 @@ const nativeChoiceQuestion = { | ... | @@ -54,6 +54,34 @@ const nativeChoiceQuestion = { |
| 54 | } as const; | 54 | } as const; |
| 55 | 55 | ||
| 56 | describe("resolvePendingUserInputAnswer", () => { | 56 | describe("resolvePendingUserInputAnswer", () => { |
| 57 | it("preserves exact editor text, including a deliberately cleared answer", () => { | ||
| 58 | const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; | ||
| 59 | expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( | ||
| 60 | question.initialAnswer, | ||
| 61 | ); | ||
| 62 | expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( | ||
| 63 | " Edited message\n\n", | ||
| 64 | ); | ||
| 65 | expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); | ||
| 66 | expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); | ||
| 67 | expect( | ||
| 68 | setPendingUserInputCustomAnswer( | ||
| 69 | { selectedOptionValues: ["Orchestration-first"] }, | ||
| 70 | "", | ||
| 71 | question, | ||
| 72 | ), | ||
| 73 | ).toEqual({ customAnswer: "" }); | ||
| 74 | const questions = [question, { ...singleSelectQuestion, id: "follow-up" }]; | ||
| 75 | const drafts = { scope: { customAnswer: "" } }; | ||
| 76 | expect(findFirstUnansweredPendingUserInputQuestionIndex(questions, drafts)).toBe(1); | ||
| 77 | expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ | ||
| 78 | customAnswer: "", | ||
| 79 | resolvedAnswer: "", | ||
| 80 | canAdvance: true, | ||
| 81 | isComplete: true, | ||
| 82 | }); | ||
| 83 | }); | ||
| 84 | |||
| 57 | it("prefers a custom answer over selected options", () => { | 85 | it("prefers a custom answer over selected options", () => { |
| 58 | expect( | 86 | expect( |
| 59 | resolvePendingUserInputAnswer(singleSelectQuestion, { | 87 | resolvePendingUserInputAnswer(singleSelectQuestion, { |
apps/web/src/pendingUserInput.ts+14-8| ... | @@ -45,8 +45,12 @@ export function resolvePendingUserInputAnswer( | ... | @@ -45,8 +45,12 @@ export function resolvePendingUserInputAnswer( |
| 45 | ): string | string[] | null { | 45 | ): string | string[] | null { |
| 46 | if (draft?.attachmentsBlocked) return null; | 46 | if (draft?.attachmentsBlocked) return null; |
| 47 | const customAnswer = | 47 | const customAnswer = |
| 48 | question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); | 48 | question.allowCustomAnswer === false |
| 49 | if (customAnswer) { | 49 | ? null |
| 50 | : question.initialAnswer !== undefined | ||
| 51 | ? (draft?.customAnswer ?? null) | ||
| 52 | : normalizeDraftAnswer(draft?.customAnswer); | ||
| 53 | if (customAnswer !== null && customAnswer.length > 0) { | ||
| 50 | return customAnswer; | 54 | return customAnswer; |
| 51 | } | 55 | } |
| 52 | 56 | ||
| ... | @@ -56,13 +60,13 @@ export function resolvePendingUserInputAnswer( | ... | @@ -56,13 +60,13 @@ export function resolvePendingUserInputAnswer( |
| 56 | if (question.multiSelect) { | 60 | if (question.multiSelect) { |
| 57 | return selectedOptionValues.length > 0 | 61 | return selectedOptionValues.length > 0 |
| 58 | ? selectedOptionValues | 62 | ? selectedOptionValues |
| 59 | : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 | 63 | : (customAnswer ?? |
| 60 | ? "" | 64 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); |
| 61 | : null; | ||
| 62 | } | 65 | } |
| 63 | 66 | ||
| 64 | return ( | 67 | return ( |
| 65 | selectedOptionValues[0] ?? | 68 | selectedOptionValues[0] ?? |
| 69 | customAnswer ?? | ||
| 66 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) | 70 | (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) |
| 67 | ); | 71 | ); |
| 68 | } | 72 | } |
| ... | @@ -70,9 +74,10 @@ export function resolvePendingUserInputAnswer( | ... | @@ -70,9 +74,10 @@ export function resolvePendingUserInputAnswer( |
| 70 | export function setPendingUserInputCustomAnswer( | 74 | export function setPendingUserInputCustomAnswer( |
| 71 | draft: PendingUserInputDraftAnswer | undefined, | 75 | draft: PendingUserInputDraftAnswer | undefined, |
| 72 | customAnswer: string, | 76 | customAnswer: string, |
| 77 | question?: Pick<UserInputQuestion, "initialAnswer">, | ||
| 73 | ): PendingUserInputDraftAnswer { | 78 | ): PendingUserInputDraftAnswer { |
| 74 | const selectedOptionValues = | 79 | const selectedOptionValues = |
| 75 | customAnswer.trim().length > 0 | 80 | question?.initialAnswer !== undefined || customAnswer.trim().length > 0 |
| 76 | ? undefined | 81 | ? undefined |
| 77 | : normalizeSelectedOptionValues(draft?.selectedOptionValues); | 82 | : normalizeSelectedOptionValues(draft?.selectedOptionValues); |
| 78 | 83 | ||
| ... | @@ -162,7 +167,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex( | ... | @@ -162,7 +167,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex( |
| 162 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, | 167 | draftAnswers: Record<string, PendingUserInputDraftAnswer>, |
| 163 | ): number { | 168 | ): number { |
| 164 | const unansweredIndex = questions.findIndex( | 169 | const unansweredIndex = questions.findIndex( |
| 165 | (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), | 170 | (question) => resolvePendingUserInputAnswer(question, draftAnswers[question.id]) === null, |
| 166 | ); | 171 | ); |
| 167 | 172 | ||
| 168 | return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; | 173 | return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; |
| ... | @@ -193,7 +198,8 @@ export function derivePendingUserInputProgress( | ... | @@ -193,7 +198,8 @@ export function derivePendingUserInputProgress( |
| 193 | selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), | 198 | selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), |
| 194 | customAnswer, | 199 | customAnswer, |
| 195 | resolvedAnswer, | 200 | resolvedAnswer, |
| 196 | usingCustomAnswer: customAnswer.trim().length > 0, | 201 | usingCustomAnswer: |
| 202 | (activeQuestion?.initialAnswer !== undefined ? customAnswer : customAnswer.trim()).length > 0, | ||
| 197 | answeredQuestionCount, | 203 | answeredQuestionCount, |
| 198 | isLastQuestion, | 204 | isLastQuestion, |
| 199 | isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, | 205 | isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, |
packages/client-runtime/src/state/threadRequests.test.ts+34-1| ... | @@ -7,10 +7,43 @@ import { | ... | @@ -7,10 +7,43 @@ import { |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { describe, expect, it } from "vite-plus/test"; | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 9 | import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts"; | 9 | import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts"; |
| 10 | import { createQuestionHistoryProjector, derivePendingThreadRequests } from "./threadRequests.ts"; | 10 | import { |
| 11 | createQuestionHistoryProjector, | ||
| 12 | derivePendingThreadRequests, | ||
| 13 | seedUserInputDraftAnswers, | ||
| 14 | } from "./threadRequests.ts"; | ||
| 11 | 15 | ||
| 12 | const requestId = RuntimeRequestId.make("async-question"); | 16 | const requestId = RuntimeRequestId.make("async-question"); |
| 13 | const nodeId = NodeId.make("async-question-node"); | 17 | const nodeId = NodeId.make("async-question-node"); |
| 18 | |||
| 19 | describe("initial user input drafts", () => { | ||
| 20 | const questions = [ | ||
| 21 | { id: "editor", initialAnswer: " Proposed commit\n\n" }, | ||
| 22 | { id: "empty", initialAnswer: "" }, | ||
| 23 | { id: "ordinary" }, | ||
| 24 | { id: "choices", initialAnswer: "Ignore this", allowCustomAnswer: false }, | ||
| 25 | ]; | ||
| 26 | |||
| 27 | it("seeds editable answers and preserves edits and selections on later snapshots", () => { | ||
| 28 | const seeded = seedUserInputDraftAnswers(questions, {}); | ||
| 29 | expect(seeded).toEqual({ | ||
| 30 | editor: { customAnswer: " Proposed commit\n\n" }, | ||
| 31 | empty: { customAnswer: "" }, | ||
| 32 | }); | ||
| 33 | const edits = { | ||
| 34 | ...seeded, | ||
| 35 | editor: { customAnswer: "" }, | ||
| 36 | empty: { selectedOptionValues: ["empty"], customAnswer: "" }, | ||
| 37 | }; | ||
| 38 | expect(seedUserInputDraftAnswers(questions, edits)).toEqual(edits); | ||
| 39 | expect( | ||
| 40 | seedUserInputDraftAnswers(questions, { editor: { customAnswer: "Edited commit" } }).editor, | ||
| 41 | ).toEqual({ customAnswer: "Edited commit" }); | ||
| 42 | expect(seedUserInputDraftAnswers(questions, {}).editor).toEqual({ | ||
| 43 | customAnswer: " Proposed commit\n\n", | ||
| 44 | }); | ||
| 45 | }); | ||
| 46 | }); | ||
| 14 | const projection: OrchestrationV2ThreadProjection = { | 47 | const projection: OrchestrationV2ThreadProjection = { |
| 15 | ...v2Projection, | 48 | ...v2Projection, |
| 16 | runtimeRequests: [ | 49 | runtimeRequests: [ |
packages/client-runtime/src/state/threadRequests.ts+21| ... | @@ -41,6 +41,27 @@ export interface PendingThreadRequests { | ... | @@ -41,6 +41,27 @@ export interface PendingThreadRequests { |
| 41 | readonly userInputs: ReadonlyArray<ThreadPendingUserInput>; | 41 | readonly userInputs: ReadonlyArray<ThreadPendingUserInput>; |
| 42 | } | 42 | } |
| 43 | 43 | ||
| 44 | /** Seed each question once. Existing edits, cleared answers, and option selections win. */ | ||
| 45 | export function seedUserInputDraftAnswers<Draft extends { readonly customAnswer?: string }>( | ||
| 46 | questions: ReadonlyArray< | ||
| 47 | Pick<OrchestrationV2UserInputQuestion, "id" | "allowCustomAnswer" | "initialAnswer"> | ||
| 48 | >, | ||
| 49 | drafts: Record<string, Draft>, | ||
| 50 | ): Record<string, Draft | { customAnswer: string }> { | ||
| 51 | let seeded: Record<string, Draft | { customAnswer: string }> = drafts; | ||
| 52 | for (const question of questions) { | ||
| 53 | if ( | ||
| 54 | question.initialAnswer === undefined || | ||
| 55 | question.allowCustomAnswer === false || | ||
| 56 | seeded[question.id] !== undefined | ||
| 57 | ) | ||
| 58 | continue; | ||
| 59 | if (seeded === drafts) seeded = { ...drafts }; | ||
| 60 | seeded[question.id] = { customAnswer: question.initialAnswer }; | ||
| 61 | } | ||
| 62 | return seeded; | ||
| 63 | } | ||
| 64 | |||
| 44 | /** Joins pending request entities to the request items that carry display data. */ | 65 | /** Joins pending request entities to the request items that carry display data. */ |
| 45 | export function derivePendingThreadRequests( | 66 | export function derivePendingThreadRequests( |
| 46 | projection: Pick<OrchestrationV2ThreadProjection, "runtimeRequests" | "turnItems">, | 67 | projection: Pick<OrchestrationV2ThreadProjection, "runtimeRequests" | "turnItems">, |
packages/contracts/src/orchestrationV2.ts+2| ... | @@ -1161,6 +1161,8 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({ | ... | @@ -1161,6 +1161,8 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({ |
| 1161 | ), | 1161 | ), |
| 1162 | multiSelect: Schema.optional(Schema.Boolean), | 1162 | multiSelect: Schema.optional(Schema.Boolean), |
| 1163 | allowCustomAnswer: Schema.optional(Schema.Boolean), | 1163 | allowCustomAnswer: Schema.optional(Schema.Boolean), |
| 1164 | /** Editable initial text. Answers preserve whitespace and allow an empty string when present. */ | ||
| 1165 | initialAnswer: Schema.optional(Schema.String), | ||
| 1164 | required: Schema.optional(Schema.Boolean), | 1166 | required: Schema.optional(Schema.Boolean), |
| 1165 | }); | 1167 | }); |
| 1166 | export type OrchestrationV2UserInputQuestion = typeof OrchestrationV2UserInputQuestion.Type; | 1168 | export type OrchestrationV2UserInputQuestion = typeof OrchestrationV2UserInputQuestion.Type; |
packages/contracts/src/providerRuntime.ts+2| ... | @@ -491,6 +491,8 @@ export const UserInputQuestion = Schema.Struct({ | ... | @@ -491,6 +491,8 @@ export const UserInputQuestion = Schema.Struct({ |
| 491 | question: TrimmedNonEmptyStringSchema, | 491 | question: TrimmedNonEmptyStringSchema, |
| 492 | options: Schema.Array(UserInputQuestionOption), | 492 | options: Schema.Array(UserInputQuestionOption), |
| 493 | allowCustomAnswer: Schema.optional(Schema.Boolean), | 493 | allowCustomAnswer: Schema.optional(Schema.Boolean), |
| 494 | /** Editable initial text. Answers preserve whitespace and allow an empty string when present. */ | ||
| 495 | initialAnswer: Schema.optional(Schema.String), | ||
| 494 | multiSelect: Schema.optional(Schema.Boolean).pipe( | 496 | multiSelect: Schema.optional(Schema.Boolean).pipe( |
| 495 | Schema.withConstructorDefault(Effect.succeed(false)), | 497 | Schema.withConstructorDefault(Effect.succeed(false)), |
| 496 | ), | 498 | ), |
packages/contracts/src/userInputQuestion.test.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { OrchestrationV2UserInputQuestion } from "./orchestrationV2.ts"; | ||
| 5 | import { UserInputQuestion } from "./providerRuntime.ts"; | ||
| 6 | |||
| 7 | const question = { id: "editor", header: "Message", question: "Edit message", options: [] }; | ||
| 8 | |||
| 9 | describe("initial user input answers", () => { | ||
| 10 | it("keeps existing question payloads compatible and preserves exact initial text", () => { | ||
| 11 | for (const schema of [UserInputQuestion, OrchestrationV2UserInputQuestion]) { | ||
| 12 | const decode = Schema.decodeUnknownSync(schema); | ||
| 13 | expect(decode(question).initialAnswer).toBeUndefined(); | ||
| 14 | for (const initialAnswer of [ | ||
| 15 | "", | ||
| 16 | " fix(server): keep whitespace\n\n", | ||
| 17 | "detail\n".repeat(500), | ||
| 18 | ]) { | ||
| 19 | expect(decode({ ...question, initialAnswer }).initialAnswer).toBe(initialAnswer); | ||
| 20 | } | ||
| 21 | } | ||
| 22 | }); | ||
| 23 | }); | ||