From e8f5850b824572bab76bf810696f875eb648eff9 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:41:08 +0200 Subject: [PATCH] fix(server): Pi editor dialogs prefill the answer composer (#17206) --- .../features/threads/QuestionAttachments.tsx | 2 + apps/mobile/src/lib/threadActivity.test.ts | 23 ++ apps/mobile/src/lib/threadActivity.ts | 16 +- .../src/state/use-selected-thread-requests.ts | 14 ++ apps/server/src/mcp/toolkits/thread/tools.ts | 1 + .../Adapters/PiAdapterV2.test.ts | 59 +++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 26 +- apps/web/src/components/ChatView.tsx | 15 ++ .../src/components/ComposerPromptEditor.tsx | 4 +- .../components/ComposerPromptEditorTiptap.tsx | 223 +++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 88 +++++-- apps/web/src/composer-logic.test.ts | 30 +++ apps/web/src/composer-logic.ts | 32 ++- apps/web/src/composer-rich-text-doc.test.ts | 48 ++++ apps/web/src/composer-rich-text-doc.ts | 11 +- apps/web/src/pendingUserInput.test.ts | 28 +++ apps/web/src/pendingUserInput.ts | 22 +- .../src/state/threadRequests.test.ts | 35 ++- .../src/state/threadRequests.ts | 21 ++ packages/contracts/src/orchestrationV2.ts | 2 + packages/contracts/src/providerRuntime.ts | 2 + .../contracts/src/userInputQuestion.test.ts | 23 ++ 22 files changed, 570 insertions(+), 155 deletions(-) create mode 100644 packages/contracts/src/userInputQuestion.test.ts diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index 0214b2440fd8d713c09277891e603239fafb0ef5..803de4e3a9aefe06aa5b008304cd33aeef1595dc 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -189,6 +189,8 @@ export function QuestionAttachments(props: { props.onInputFocusChange?.(true)} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index d88e840ba40faf9d4a8948d031c9b2f1c84ddf7b..e5ddde4993b629a5da1305b8cd487728ebfbed5c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2011,6 +2011,29 @@ const multiSelectQuestion = { } as const; describe("pending user input answers", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect( + buildPendingUserInputAnswers([question], { + runtime: { customAnswer: question.initialAnswer }, + }), + ).toEqual({ runtime: question.initialAnswer }); + expect( + buildPendingUserInputAnswers([question], { + runtime: { customAnswer: " Edited message\n\n" }, + }), + ).toEqual({ runtime: " Edited message\n\n" }); + expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({ + runtime: "", + }); + expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({ + runtime: " \n", + }); + expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual( + { customAnswer: "" }, + ); + }); + it("replaces single-select options and toggles multi-select options", () => { expect( togglePendingUserInputOptionSelection( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 2dc513e40f0238a1cf22b98afdf5304e29d756e2..d06af828ee11fcc7eecb3975552737577c6ce888 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -399,8 +399,12 @@ function resolvePendingUserInputAnswer( ): string | ReadonlyArray | null { if (draft?.attachmentsBlocked) return null; const customAnswer = - question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer) { + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { return customAnswer; } @@ -408,12 +412,12 @@ function resolvePendingUserInputAnswer( if (question.multiSelect) { return selectedOptionValues.length > 0 ? selectedOptionValues - : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 - ? "" - : null; + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); } return ( selectedOptionValues[0] ?? + customAnswer ?? (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) ); } @@ -1633,7 +1637,7 @@ export function setPendingUserInputCustomAnswer( } const selectedOptionValues = - customAnswer.trim().length > 0 + question.initialAnswer !== undefined || customAnswer.trim().length > 0 ? undefined : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); return { diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 0c32c6a37c147819a50f988771412b3d8d3c9af9..eeb5a017f0674bfc32351c62ba9636ef62bb7452 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -18,6 +18,7 @@ import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/c import { type PendingThreadRequests, type ThreadUserInputQuestion, + seedUserInputDraftAnswers, } from "@t3tools/client-runtime/state/thread-requests"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { Atom } from "effect/reactivity"; @@ -104,6 +105,19 @@ export function useSelectedThreadRequests() { const activePendingApproval = activePendingApprovals[0] ?? null; const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs; const activePendingUserInput = activePendingUserInputs[0] ?? null; + useEffect(() => { + if (!activePendingUserInput || !selectedThreadShell) return; + const requestKey = scopedRequestKey( + selectedThreadShell.environmentId, + activePendingUserInput.requestId, + ); + const existing = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); + const drafts = existing[requestKey] ?? {}; + const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); + if (seeded !== drafts) { + appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...existing, [requestKey]: seeded }); + } + }, [activePendingUserInput, selectedThreadShell]); const questionServerConfigs = useServerConfigs(); const attachmentDrafts = useAtomValue(composerDraftsAtom); const preparationCounts = useAtomValue(questionAttachmentPreparationAtom); diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 24cd6af444ba82edd735cfc6b1687af1c966b39f..b5fb7d6be3a68f1e8c1dcfbfa8499de064adb902 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -141,6 +141,7 @@ const question = Schema.Struct({ ), multiSelect: Schema.optional(Schema.Boolean), allowCustomAnswer: Schema.optional(Schema.Boolean), + initialAnswer: Schema.optional(Schema.String), required: Schema.optional(Schema.Boolean), }); const pendingRequest = Schema.Struct({ diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index 6e385f1a1489113c98c52f992cda8199f0a98ceb..4db9305cd2e6f56b7009ddade32d3559b0ee0766 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -2400,6 +2400,65 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("keeps the complete editor prefill editable and returns exact edited text", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const prefill = ` fix(server): retain full editor content\n\n${"detail\n".repeat(500)} `; + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-editor", + method: "editor", + title: "Commit message", + prefill, + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") + return; + assert.equal(event.turnItem.questions[0]?.initialAnswer, prefill); + assert.deepEqual(event.turnItem.questions[0]?.options, []); + assert.equal(event.turnItem.questions[0]?.question, "Commit message"); + const edited = " fix(server): edited commit message\n\n"; + yield* runtime.respondToRuntimeRequest({ + requestId: event.turnItem.requestId, + answers: { "ui-editor": edited }, + }); + const response = yield* fake.takeRequest("extension_ui_response"); + assert.equal(response["value"], edited); + assert.isUndefined(response["cancelled"]); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-editor-cleared", + method: "editor", + title: "Commit message", + prefill, + }); + const cleared = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "user_input_request" && + event.turnItem.questions.some((question) => question.id === "ui-editor-cleared"), + ); + if (cleared.type !== "turn_item.updated" || cleared.turnItem.type !== "user_input_request") + return; + yield* runtime.respondToRuntimeRequest({ + requestId: cleared.turnItem.requestId, + answers: { "ui-editor-cleared": "" }, + }); + const emptyResponse = yield* fake.takeRequest("extension_ui_response"); + assert.equal(emptyResponse["value"], ""); + assert.isUndefined(emptyResponse["cancelled"]); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("raises bridge edit confirmations as file-change approvals", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index a319b4b2f0b7e8681809aec6d6ed0d50fe2c1108..b05454aefc126a8d2709d35adf30f32ca4ead05b 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -3160,25 +3160,21 @@ function piQuestion( ? event["options"] .filter((option): option is string => typeof option === "string") .map((option) => ({ label: option || "Empty value", description: option, value: option })) - : [ - { - label: "Submit empty value", - description: "Send an empty string to the extension.", - value: "", - }, - ]; - // The user-input contract has no prefill field, so an editor dialog's - // prefill is surfaced inside the question text; without it the user would - // edit blind against content they cannot see. - const prefill = method === "editor" ? recordString(event, "prefill") : undefined; + : method === "editor" + ? [] + : [ + { + label: "Submit empty value", + description: "Send an empty string to the extension.", + value: "", + }, + ]; const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title; return { id: questionId, header: title, - question: - prefill === undefined || prefill.length === 0 - ? question - : `${question}\n\nCurrent value:\n${prefill.slice(0, 2_000)}`, + question, + ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}), options, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b70ffb17576908e6b0c61972da3f3ea6362f075..d6baf94b5ff4f284dafc898349846739746a27a5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -217,6 +217,7 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; +import { seedUserInputDraftAnswers } from "@t3tools/client-runtime/state/thread-requests"; import { useUiStateStore } from "../uiStateStore"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { @@ -3312,6 +3313,19 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activePendingUserInput?.requestId, ]); + const activePendingAnswerDrafts = + pendingUserInputAnswersByRequestId[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; + if ( + activePendingUserInput && + seedUserInputDraftAnswers(activePendingUserInput.questions, activePendingAnswerDrafts) !== + activePendingAnswerDrafts + ) { + setPendingUserInputAnswersByRequestId((existing) => { + const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS; + const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts); + return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded }; + }); + } const pendingQuestionDraftKeys = useMemo( () => activeThreadId @@ -10294,6 +10308,7 @@ export default function ChatView(props: ChatViewProps) { [questionId]: setPendingUserInputCustomAnswer( existing[activePendingRequestKey]?.[questionId], value, + question, ), }, })); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 7e699b6e0304d48cefbf7b81a5950083af62619f..cb4810e6f17e7778e3413c34a30daae5ba2e9aaa 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -9,8 +9,8 @@ export type { /** * The composer editor. Tiptap in both modes: the `richTextEnabled` setting - * toggles Markdown styling, never the engine. Plain mode renders every - * marker as a literal character and serializes byte-identically. + * toggles Markdown styling, never the engine. Plain mode keeps Markdown + * markers literal; `literalText` also preserves context-token sources as text. */ export function ComposerPromptEditor(props: ComposerPromptEditorProps) { return ; diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index babdfc4a1f41e96ed7f22f5952bf7af4b676db26..22002431097fa58708bbecbe17c6f217b1c97095 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -140,6 +140,8 @@ export interface ComposerPromptEditorProps { * literal character. */ richTextEnabled?: boolean; + /** Preserve editor answers verbatim, without Markdown or context chips. */ + literalText?: boolean; /** Draft records behind the prompt's context references, keyed by context id. */ contextRecords: ComposerDraftContextRecords; /** Structured clipboard payload for the given referenced ids, or null to skip. */ @@ -773,11 +775,13 @@ const ComposerMarkersExtension = Extension.create({ type TiptapEditor = NonNullable>; export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) { - // Extensions are creation-time: flipping the setting remounts the editor. - // Both halves initialize from the controlled Markdown value, so the draft - // survives the flip. + // Extensions are creation-time, so changing text mode remounts the editor. + // Every mode initializes from the controlled value, preserving the draft. return ( - + ); } @@ -806,6 +810,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { value, cursor, richTextEnabled, + literalText = false, contextRecords, buildContextClipboardFragment, importContextFragment, @@ -830,7 +835,22 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } = props; // The setting toggles styling, not the engine: both modes are Tiptap. // Plain mode disables the mark extensions, so markers stay literal text. - const richText = richTextEnabled ?? false; + const richText = !literalText && (richTextEnabled ?? false); + const clampEditorCursor = useCallback( + (text: string, position: number) => + literalText + ? Math.max( + 0, + Math.min(text.length, Number.isFinite(position) ? Math.floor(position) : text.length), + ) + : clampCollapsedComposerCursor(text, position), + [literalText], + ); + const expandEditorCursor = useCallback( + (text: string, position: number) => + literalText ? position : expandCollapsedComposerCursor(text, position), + [literalText], + ); const onChangeRef = useRef(onChange); const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); @@ -878,13 +898,13 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { }; }, []); - const initialCursor = clampCollapsedComposerCursor(value, cursor); - const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); + const initialCursor = clampEditorCursor(value, cursor); + const initialExpandedCursor = expandEditorCursor(value, initialCursor); const snapshotRef = useRef({ value, cursor: initialCursor, expandedCursor: initialExpandedCursor, - contextIds: collectInlineContextIds(value), + contextIds: literalText ? [] : collectInlineContextIds(value), }); const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); const isApplyingControlledUpdateRef = useRef(false); @@ -910,62 +930,68 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { [onCitationSubmitAndSend, openCitation], ); - const handleEditorChange = useCallback((updated: TiptapEditor) => { - const map = serializeEditorDoc(updated.state.doc); - const { from, to } = updated.state.selection; - const fromFlat = pmToFlat(map, from); - const toFlat = pmToFlat(map, to); - const nextValue = map.value; - const nextCursor = clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)); - const nextExpandedCursor = Math.max( - 0, - Math.min(map.value.length, flatToMarkdown(map, fromFlat)), - ); - const nextSelectionRange = { - start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), - end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), - }; - const previousSelectionRange = selectionRangeRef.current; - selectionRangeRef.current = nextSelectionRange; - setIsEmpty(nextValue.length === 0); - const previousSnapshot = snapshotRef.current; - const snapshotChanged = !( - previousSnapshot.value === nextValue && - previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.contextIds.length === map.contextIds.length && - previousSnapshot.contextIds.every((id, index) => id === map.contextIds[index]) - ); - if (isApplyingControlledUpdateRef.current) return; - if (!snapshotChanged) { - if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { - onVisibleSelectionChangeRef.current?.(); + const handleEditorChange = useCallback( + (updated: TiptapEditor) => { + const map = serializeEditorDoc(updated.state.doc); + // Literal answers hold no chips, so reference-shaped text is not a context. + const contextIds = literalText ? [] : map.contextIds; + const { from, to } = updated.state.selection; + const fromFlat = pmToFlat(map, from); + const toFlat = pmToFlat(map, to); + const nextValue = map.value; + const nextCursor = clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)); + const nextExpandedCursor = Math.max( + 0, + Math.min(map.value.length, flatToMarkdown(map, fromFlat)), + ); + const nextSelectionRange = { + start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)), + end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)), + }; + const previousSelectionRange = selectionRangeRef.current; + selectionRangeRef.current = nextSelectionRange; + setIsEmpty(nextValue.length === 0); + const previousSnapshot = snapshotRef.current; + const snapshotChanged = !( + previousSnapshot.value === nextValue && + previousSnapshot.cursor === nextCursor && + previousSnapshot.expandedCursor === nextExpandedCursor && + previousSnapshot.contextIds.length === contextIds.length && + previousSnapshot.contextIds.every((id, index) => id === contextIds[index]) + ); + if (isApplyingControlledUpdateRef.current) return; + if (!snapshotChanged) { + if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { + onVisibleSelectionChangeRef.current?.(); + } + return; } - return; - } - // A selection-only update while a newer prompt waits to be applied (a chip - // was just inserted through the store) would report stale text and clobber - // the prompt. Let the controlled rewrite land instead. - if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { - return; - } - snapshotRef.current = { - value: nextValue, - cursor: nextCursor, - expandedCursor: nextExpandedCursor, - contextIds: map.contextIds, - }; - // A fence holds no chips, so nothing in it should summon the mention or - // command menu: `@` in code is a decorator, not a file. Suppressing the - // trigger here also keeps the store from inserting a link the block can - // only show as literal text, which the store would then count as a chip. - const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; - const suppressTrigger = - inCodeBlock || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds); - }, []); + // A selection-only update while a newer prompt waits to be applied (a chip + // was just inserted through the store) would report stale text and clobber + // the prompt. Let the controlled rewrite land instead. + if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) { + return; + } + snapshotRef.current = { + value: nextValue, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + contextIds, + }; + // A fence holds no chips, so nothing in it should summon the mention or + // command menu: `@` in code is a decorator, not a file. Suppressing the + // trigger here also keeps the store from inserting a link the block can + // only show as literal text, which the store would then count as a chip. + const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; + const suppressTrigger = + literalText || + inCodeBlock || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds); + }, + [clampEditorCursor, literalText], + ); const editorAttributes = useMemo( () => ({ @@ -1122,7 +1148,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { description: shortDescription || found.description?.trim() || null, }; }, - { styling: richText }, + { styling: richText, literalText }, ), editable: !disabled, editorProps: { @@ -1319,7 +1345,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return handled; }, handleTextInput: (view, from, to, text) => { - if (text.length !== 1) return false; + if (literalText || text.length !== 1) return false; const closer = SURROUND_CLOSE[text]; if (!closer || from === to) return false; // Never wrap chips or other atoms, and never wrap styled text: the @@ -1365,11 +1391,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; } const importFragment = importFragmentRef.current; - let text = importFragment - ? importPastedComposerText(clipboardData, importFragment) - : pastedText; + let text = + !literalText && importFragment + ? importPastedComposerText(clipboardData, importFragment) + : pastedText; // Complete chips at paste boundaries just as autocomplete does. - const tokens = collectComposerPromptInlineTokens(`${text}\n`); + const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`); const lastToken = tokens.at(-1); if ( (lastToken?.type === "mention" || lastToken?.type === "skill") && @@ -1397,7 +1424,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { insertMarkdownParagraphs( text, skillLabelFor, - { styling: richText, blocks: !nested }, + { styling: richText, blocks: !nested, literalText }, (content) => { // Tagged on the same transaction insertContent builds, so the // paste is one undo step of its own. @@ -1467,9 +1494,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const fromFlat = pmToFlat(map, from); const next: typeof snapshot = { value: map.value, - cursor: clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)), + cursor: clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)), expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))), - contextIds: map.contextIds, + contextIds: literalText ? [] : map.contextIds, }; const toFlat = pmToFlat(map, to); selectionRangeRef.current = { @@ -1478,14 +1505,14 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { }; snapshotRef.current = next; return next; - }, [editor]); + }, [clampEditorCursor, editor, literalText]); // Controlled value/cursor from the store (history recall, chip insertion…). useLayoutEffect(() => { if (!editor) return; const initialSelection = !hasAppliedControlledSelectionRef.current; hasAppliedControlledSelectionRef.current = true; - const normalizedCursor = clampCollapsedComposerCursor(value, cursor); + const normalizedCursor = clampEditorCursor(value, cursor); const previousSnapshot = snapshotRef.current; if ( !initialSelection && @@ -1494,7 +1521,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { return; } - const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); + const normalizedExpandedCursor = expandEditorCursor(value, normalizedCursor); snapshotRef.current = { value, cursor: normalizedCursor, @@ -1514,9 +1541,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const pendingCitation = citationRequestRef.current?.value === value ? citationRequestRef.current : null; if (previousSnapshot.value !== value) { - editor.commands.setContent(buildDocJson(value, skillLabelFor, { styling: richText }), { - emitUpdate: false, - }); + editor.commands.setContent( + buildDocJson(value, skillLabelFor, { styling: richText, literalText }), + { + emitUpdate: false, + }, + ); } const map = serializeEditorDoc(editor.state.doc); const flat = collapsedToFlat(map, normalizedCursor); @@ -1545,7 +1575,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, richText, skillLabelFor, value]); + }, [ + clampEditorCursor, + cursor, + editor, + expandEditorCursor, + literalText, + richText, + skillLabelFor, + value, + ]); const focusAt = useCallback( (nextCursor: number) => { @@ -1559,7 +1598,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { // through the store). Reporting the editor's stale text now would // overwrite that prompt; the pending rewrite places the caret instead. if (snapshotRef.current.value !== latestValueRef.current) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor); const map = serializeEditorDoc(editor.state.doc); const flat = collapsedToFlat(map, boundedCursor); editor.commands.setTextSelection(flatToPm(map, flat)); @@ -1568,7 +1607,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + expandedCursor: expandEditorCursor(snapshotRef.current.value, boundedCursor), contextIds: snapshotRef.current.contextIds, }; selectionRangeRef.current = { @@ -1579,11 +1618,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, - false, + literalText, snapshotRef.current.contextIds, ); }, - [editor], + [clampEditorCursor, editor, expandEditorCursor, literalText], ); useImperativeHandle( @@ -1595,10 +1634,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { focusAt, focusAtEnd: () => { focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), + literalText + ? snapshotRef.current.value.length + : collapseExpandedComposerCursor( + snapshotRef.current.value, + snapshotRef.current.value.length, + ), ); }, readSelectionRange: () => { @@ -1660,7 +1701,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { : edgeRect.bottom - caretRect.bottom < threshold; }, }), - [editor, focusAt, readSnapshot], + [editor, focusAt, literalText, readSnapshot], ); const handleCopyCut = useCallback( @@ -1671,7 +1712,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const { from, to } = editor.state.selection; if (from === to) return; const text = serializeSelection(editor.state.doc, from, to); - const contextIds = Array.from(new Set(collectInlineContextIds(text))); + const contextIds = literalText ? [] : Array.from(new Set(collectInlineContextIds(text))); const fragment = contextIds.length > 0 ? build?.(contextIds) : null; event.preventDefault(); clipboardData.setData("text/plain", text); @@ -1691,7 +1732,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { .run(); } }, - [editor], + [editor, literalText], ); return ( @@ -1774,7 +1815,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { function insertMarkdownParagraphs( value: string, skillLabelFor: (name: string) => SkillMeta, - options: { styling: boolean; blocks?: boolean }, + options: { styling: boolean; blocks?: boolean; literalText?: boolean }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { const blocks = buildTiptapContent(value, skillLabelFor, options); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 858f9a77850ad1af1c35b14d26f952a32b2f63d8..cfdb03d0f05cf358238ccd33fbc5647a04d1e2f9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1577,6 +1577,7 @@ export interface ChatComposerProps { customAnswer: string; activeQuestion: { id: string; + initialAnswer?: string | undefined; multiSelect?: boolean | undefined; allowCustomAnswer?: boolean | undefined; } | null; @@ -1807,6 +1808,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) editingQueuedAttachments, onRemoveEditingQueuedAttachment, } = props; + const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined; const primaryEnvironmentId = usePrimaryEnvironmentId(); const composerDraftTargetKey = composerTargetKey(composerDraftTarget); // 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) // ------------------------------------------------------------------ // Composer-local state // ------------------------------------------------------------------ - const [composerCursor, setComposerCursor] = useState(() => - collapseExpandedComposerCursor(prompt, prompt.length), - ); + const [composerCursor, setComposerCursor] = useState(() => { + const value = isLiteralPendingAnswer ? (activePendingProgress?.customAnswer ?? prompt) : prompt; + return collapseExpandedComposerCursor(value, value.length, isLiteralPendingAnswer); + }); const { trigger: composerTrigger, setTrigger: setComposerTrigger, resolveTrigger: resolveComposerTrigger, dismissTrigger: dismissComposerTrigger, resetTrigger: resetComposerTrigger, - } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); + } = useComposerTriggerState(() => + isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length), + ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const composerSuggestionId = useId(); const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; @@ -3395,9 +3400,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Sync refs back to parent // ------------------------------------------------------------------ useEffect(() => { + if (isLiteralPendingAnswer) return; promptRef.current = prompt; setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); - }, [prompt, promptRef]); + }, [isLiteralPendingAnswer, prompt, promptRef]); useEffect(() => { if (composerSubmissionError === null) return; @@ -3498,13 +3504,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextCustomAnswer; - const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer); + const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer, isLiteralPendingAnswer); setComposerCursor(cursor); resetComposerTrigger(trigger); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, + isLiteralPendingAnswer, activePendingUserInput?.requestId, prompt, promptRef, @@ -3519,11 +3526,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedSearchKey(null); setComposerSubmissionError(null); setProviderInputSubmissionError(null); - setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); - resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + const { cursor, trigger } = composerStateAtPromptEnd(promptRef.current, isLiteralPendingAnswer); + setComposerCursor(cursor); + resetComposerTrigger(trigger); setIsDragOverComposer(false); setIsComposerScrollCollapsed(false); - }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]); + }, [ + draftId, + activeThreadId, + isLiteralPendingAnswer, + promptRef, + resetComposerTrigger, + setIsComposerScrollCollapsed, + ]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -3866,8 +3881,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const nextCursor = collapseExpandedComposerCursor( next.text, options?.expandedCursorAfterReplace ?? next.cursor, + isLiteralPendingAnswer, + ); + const nextExpandedCursor = expandCollapsedComposerCursor( + next.text, + nextCursor, + isLiteralPendingAnswer, ); - const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); if (options?.citationComment) { composerEditorRef.current?.requestCitationComment({ previousValue: currentText, @@ -3885,13 +3905,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) next.text, nextCursor, nextExpandedCursor, - false, + isLiteralPendingAnswer, ); } else { setPrompt(next.text); } setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); + setComposerTrigger( + isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor), + ); if (options?.focusEditorAfterReplace !== false) { window.requestAnimationFrame(() => { // Type-to-focus routes only the first key through here; once the @@ -3907,6 +3929,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activePendingProgress?.activeQuestion, activePendingUserInput, + isLiteralPendingAnswer, onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, @@ -3927,10 +3950,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return { value: promptRef.current, cursor: composerCursor, - expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), - contextIds: collectInlineContextIds(promptRef.current), + expandedCursor: expandCollapsedComposerCursor( + promptRef.current, + composerCursor, + isLiteralPendingAnswer, + ), + contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(promptRef.current), }; - }, [composerCursor, promptRef]); + }, [composerCursor, isLiteralPendingAnswer, promptRef]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; @@ -3939,11 +3966,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const snapshot = readComposerSnapshot(); return { snapshot, - trigger: resolveComposerTrigger( - detectComposerTrigger(snapshot.value, snapshot.expandedCursor), - ), + trigger: isLiteralPendingAnswer + ? null + : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), }; - }, [readComposerSnapshot, resolveComposerTrigger]); + }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]); const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( @@ -4434,7 +4461,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } const { trigger } = resolveActiveComposerTrigger(); - const menuIsActive = composerMenuOpenRef.current || trigger !== null; + const menuIsActive = + !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null); if (key === "Escape") { if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; dismissComposerTrigger(trigger); @@ -4474,7 +4502,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } // Native task splitting preserves marks and chips on both sides of the caret. if (key === "Enter" && isTaskItem) return false; - if (!event.isComposing && (key === "Enter" || (key === "Tab" && !event.shiftKey))) { + if ( + !isLiteralPendingAnswer && + !event.isComposing && + (key === "Enter" || (key === "Tab" && !event.shiftKey)) + ) { const selection = composerEditorRef.current?.readSelectionRange(); const snapshot = readComposerSnapshot(); if (selection && selection.start === selection.end && snapshot.value === promptRef.current) { @@ -5990,6 +6022,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) bypassAutoAttachment: boolean, selectionOverride?: { start: number; end: number }, ): boolean => { + // Editor answers keep pasted text in the answer, regardless of paste size. + if (isLiteralPendingAnswer) return false; const questionCanAttach = pendingUserInputs.length === 0 || (supportsQuestionAttachments && @@ -6436,11 +6470,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) detectTrigger?: boolean; }) => { const promptForState = options?.prompt ?? promptRef.current; - const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); + const cursor = clampCollapsedComposerCursor( + promptForState, + options?.cursor ?? 0, + isLiteralPendingAnswer, + ); setComposerHighlightedItemId(null); setComposerCursor(cursor); resetComposerTrigger( - options?.detectTrigger + options?.detectTrigger && !isLiteralPendingAnswer ? detectComposerTrigger( promptForState, expandCollapsedComposerCursor(promptForState, cursor), @@ -6449,7 +6487,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); }, addTerminalContext: (selection: TerminalContextSelection) => { - if (!activeThread || isChoiceOnlyPendingQuestion) return; + if (!activeThread || isChoiceOnlyPendingQuestion || isLiteralPendingAnswer) return; const snapshot = readComposerSnapshot(); const context = { id: randomUUID(), @@ -6543,6 +6581,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isComposerApprovalState, isChoiceOnlyPendingQuestion, + isLiteralPendingAnswer, pendingUserInputs.length, projectSelectionRequired, applyPromptReplacement, @@ -7400,6 +7439,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } editorRef={composerEditorRef} richTextEnabled={settings.composerRichTextEnabled} + literalText={isLiteralPendingAnswer} value={ isComposerApprovalState ? "" diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index f931f9ebc37887d22f03f7308c211e3510ff920a..df3e54b4ddfc7b1eab6e260fcf5a420327c2c4af 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -817,3 +817,33 @@ describe("parseStandaloneComposerSlashCommand", () => { expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull(); }); }); + +describe("literal editor answer cursor state", () => { + const prefill = "- [X] done $my-skill "; + + it("keeps character positions after token-like text for inserted lines and replacements", () => { + for (const replacement of ["\n", "\t", "edited\n "]) { + const next = replaceTextRange(prefill, prefill.length, prefill.length, replacement); + const cursor = collapseExpandedComposerCursor(next.text, next.cursor, true); + expect(next.text).toBe(prefill + replacement); + expect(cursor).toBe(next.text.length); + expect(expandCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor); + expect(clampCollapsedComposerCursor(next.text, cursor, true)).toBe(next.cursor); + } + const replaced = replaceTextRange(prefill, 11, 20, "$another-skill"); + const cursor = collapseExpandedComposerCursor(replaced.text, replaced.cursor, true); + expect(replaced.text).toBe("- [X] done $another-skill "); + expect(cursor).toBe(25); + expect(expandCollapsedComposerCursor(replaced.text, cursor, true)).toBe(25); + }); + + it("does not open a skill or path menu when restoring an editor answer", () => { + for (const value of ["- [X] done $my-skill", "Use @README", ""]) { + expect(composerStateAtPromptEnd(value, true)).toEqual({ + cursor: value.length, + trigger: null, + }); + } + expect(composerStateAtPromptEnd("Use $my").trigger?.kind).toBe("skill"); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 09c5e714aeb38b43efc06b10baaf6be0056cce77..c26996f494e54458666c28cda34aa059352957c2 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -89,7 +89,12 @@ function tokenStartForCursor(text: string, cursor: number): number { return index + 1; } -export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { +export function expandCollapsedComposerCursor( + text: string, + cursorInput: number, + literalText = false, +): number { + if (literalText) return clampCursor(text, cursorInput); const collapsedCursor = clampCursor(text, cursorInput); const segments = splitPromptIntoComposerSegments(text); if (segments.length === 0) { @@ -155,14 +160,24 @@ function clampCollapsedComposerCursorForSegments( return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); } -export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { +export function clampCollapsedComposerCursor( + text: string, + cursorInput: number, + literalText = false, +): number { + if (literalText) return clampCursor(text, cursorInput); return clampCollapsedComposerCursorForSegments( splitPromptIntoComposerSegments(text), cursorInput, ); } -export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { +export function collapseExpandedComposerCursor( + text: string, + cursorInput: number, + literalText = false, +): number { + if (literalText) return clampCursor(text, cursorInput); const expandedCursor = clampCursor(text, cursorInput); const segments = splitPromptIntoComposerSegments(text); if (segments.length === 0) { @@ -292,14 +307,19 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos } /** Caret and trigger after replacing composer text and continuing at the end. */ -export function composerStateAtPromptEnd(text: string): { +export function composerStateAtPromptEnd( + text: string, + literalText = false, +): { cursor: number; trigger: ComposerTrigger | null; } { - const cursor = collapseExpandedComposerCursor(text, text.length); + const cursor = collapseExpandedComposerCursor(text, text.length, literalText); return { cursor, - trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), + trigger: literalText + ? null + : detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)), }; } diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 5b26b74cacfd726999827d3ed7d4a9451c886ebc..1693cdbae9acdc659f3c6c2d21887a5123d70f13 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -3,6 +3,7 @@ import StarterKit from "@tiptap/starter-kit"; import { TaskList } from "@tiptap/extension-task-list"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { splitBlockKeepMarks } from "@tiptap/pm/commands"; import { describe, expect, it } from "vite-plus/test"; import { collapseExpandedComposerCursor } from "./composer-logic"; @@ -135,6 +136,53 @@ function roundTripPlain(value: string) { return serializeEditorDoc(doc); } +describe("literal editor answers", () => { + const makeDoc = (value: string) => + ProseMirrorNode.fromJSON( + plainSchema, + buildDocJson(value, (name) => ({ label: name, description: null }), { + literalText: true, + }), + ); + + it.each([ + "", + "\n\n", + " - [X] done\n\n**keep** €my-skill @README.md \n", + "line one\r\nline two\r\n", + `- [X] done\n${" detail €my-skill @README.md\n".repeat(100)}`, + ])("preserves the complete answer through selection and serialization", (value) => { + const doc = makeDoc(value); + doc.check(); + const serialized = serializeEditorDoc(doc); + expect(serialized.value).toBe(value); + expect(serialized.contextIds).toEqual([]); + expect(serializeSelection(doc, 1, doc.content.size - 1)).toBe(value); + for (let offset = 0; offset <= value.length; offset += 1) { + expect(flatToCollapsed(serialized, offset)).toBe(offset); + } + }); + + it("edits literal Markdown, inserts a newline, and preserves an explicitly cleared answer", () => { + const value = " - [X] done €my-skill @README.md "; + const doc = makeDoc(value); + let state = EditorState.create({ + doc, + selection: TextSelection.create(doc, doc.content.size - 1), + }); + state = state.apply(state.tr.insertText(" edited")); + expect( + splitBlockKeepMarks(state, (transaction) => { + state = state.apply(transaction); + }), + ).toBe(true); + state = state.apply(state.tr.insertText("second line ")); + expect(serializeEditorDoc(state.doc).value).toBe(`${value} edited\nsecond line `); + state = state.apply(state.tr.delete(1, state.doc.content.size - 1)); + expect(serializeEditorDoc(state.doc).value).toBe(""); + }); +}); + describe("composer rich text document model", () => { it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])( "canonicalizes %s skill aliases while preserving amounts", diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index efdfb145dc1d75c19c7868b305d76a1f760241c4..dc3525e1249aee2c7399a1dc4557f54bb6e8ad40 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -537,8 +537,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record SkillMeta, - options?: { styling?: boolean; blocks?: boolean }, + options?: { styling?: boolean; blocks?: boolean; literalText?: boolean }, ): Record[] { + // Editor answers are verbatim text, including Markdown and context-token sources. + if (options?.literalText) { + return value.split("\n").map((line) => ({ + type: "paragraph", + ...(line ? { content: [{ type: "text", text: line }] } : {}), + })); + } const styling = options?.styling ?? true; // Inline marks without block structure: text pasted into a list item or // quote, which has no line to write a nested block into. @@ -759,7 +766,7 @@ export function buildTiptapContent( export function buildDocJson( value: string, skillLabelFor: (name: string) => SkillMeta, - options?: { styling?: boolean }, + options?: { styling?: boolean; literalText?: boolean }, ) { const content = buildTiptapContent(value, skillLabelFor, options); // The empty line the rule extension keeps after a final rule. diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 24f1be6fa6021833de38ef93340522501cd8a03a..c536e0c377ae755d4bc112b2e6265b7dda7dc13f 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -54,6 +54,34 @@ const nativeChoiceQuestion = { } as const; describe("resolvePendingUserInputAnswer", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( + question.initialAnswer, + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( + " Edited message\n\n", + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); + expect( + setPendingUserInputCustomAnswer( + { selectedOptionValues: ["Orchestration-first"] }, + "", + question, + ), + ).toEqual({ customAnswer: "" }); + const questions = [question, { ...singleSelectQuestion, id: "follow-up" }]; + const drafts = { scope: { customAnswer: "" } }; + expect(findFirstUnansweredPendingUserInputQuestionIndex(questions, drafts)).toBe(1); + expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ + customAnswer: "", + resolvedAnswer: "", + canAdvance: true, + isComplete: true, + }); + }); + it("prefers a custom answer over selected options", () => { expect( resolvePendingUserInputAnswer(singleSelectQuestion, { diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 538909d13d693aff9e2d63ad87c60e5b6af3a3a8..1f3e4d9ea0480b5873e1ffa8299716966c552ec5 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -45,8 +45,12 @@ export function resolvePendingUserInputAnswer( ): string | string[] | null { if (draft?.attachmentsBlocked) return null; const customAnswer = - question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer) { + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { return customAnswer; } @@ -56,13 +60,13 @@ export function resolvePendingUserInputAnswer( if (question.multiSelect) { return selectedOptionValues.length > 0 ? selectedOptionValues - : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 - ? "" - : null; + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); } return ( selectedOptionValues[0] ?? + customAnswer ?? (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) ); } @@ -70,9 +74,10 @@ export function resolvePendingUserInputAnswer( export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, + question?: Pick, ): PendingUserInputDraftAnswer { const selectedOptionValues = - customAnswer.trim().length > 0 + question?.initialAnswer !== undefined || customAnswer.trim().length > 0 ? undefined : normalizeSelectedOptionValues(draft?.selectedOptionValues); @@ -162,7 +167,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex( draftAnswers: Record, ): number { const unansweredIndex = questions.findIndex( - (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]), + (question) => resolvePendingUserInputAnswer(question, draftAnswers[question.id]) === null, ); return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex; @@ -193,7 +198,8 @@ export function derivePendingUserInputProgress( selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), customAnswer, resolvedAnswer, - usingCustomAnswer: customAnswer.trim().length > 0, + usingCustomAnswer: + (activeQuestion?.initialAnswer !== undefined ? customAnswer : customAnswer.trim()).length > 0, answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index 4245b3f88498191b4c8d63ce97462736e91022f0..b238d9498bbe32ed5f384fed6a928f79c88305ff 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -7,10 +7,43 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts"; -import { createQuestionHistoryProjector, derivePendingThreadRequests } from "./threadRequests.ts"; +import { + createQuestionHistoryProjector, + derivePendingThreadRequests, + seedUserInputDraftAnswers, +} from "./threadRequests.ts"; const requestId = RuntimeRequestId.make("async-question"); const nodeId = NodeId.make("async-question-node"); + +describe("initial user input drafts", () => { + const questions = [ + { id: "editor", initialAnswer: " Proposed commit\n\n" }, + { id: "empty", initialAnswer: "" }, + { id: "ordinary" }, + { id: "choices", initialAnswer: "Ignore this", allowCustomAnswer: false }, + ]; + + it("seeds editable answers and preserves edits and selections on later snapshots", () => { + const seeded = seedUserInputDraftAnswers(questions, {}); + expect(seeded).toEqual({ + editor: { customAnswer: " Proposed commit\n\n" }, + empty: { customAnswer: "" }, + }); + const edits = { + ...seeded, + editor: { customAnswer: "" }, + empty: { selectedOptionValues: ["empty"], customAnswer: "" }, + }; + expect(seedUserInputDraftAnswers(questions, edits)).toEqual(edits); + expect( + seedUserInputDraftAnswers(questions, { editor: { customAnswer: "Edited commit" } }).editor, + ).toEqual({ customAnswer: "Edited commit" }); + expect(seedUserInputDraftAnswers(questions, {}).editor).toEqual({ + customAnswer: " Proposed commit\n\n", + }); + }); +}); const projection: OrchestrationV2ThreadProjection = { ...v2Projection, runtimeRequests: [ diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 9532c8f32282cab4ca3abc93598b306dbd0dba09..031cbf50c48fbc8ee73222a8c99ac645e178cead 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -41,6 +41,27 @@ export interface PendingThreadRequests { readonly userInputs: ReadonlyArray; } +/** Seed each question once. Existing edits, cleared answers, and option selections win. */ +export function seedUserInputDraftAnswers( + questions: ReadonlyArray< + Pick + >, + drafts: Record, +): Record { + let seeded: Record = drafts; + for (const question of questions) { + if ( + question.initialAnswer === undefined || + question.allowCustomAnswer === false || + seeded[question.id] !== undefined + ) + continue; + if (seeded === drafts) seeded = { ...drafts }; + seeded[question.id] = { customAnswer: question.initialAnswer }; + } + return seeded; +} + /** Joins pending request entities to the request items that carry display data. */ export function derivePendingThreadRequests( projection: Pick, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b1c56457ef082be9a513667c8c06d8e6497775f2..7a1e56b6d5e32fe980634cd38f1e813bd11fd920 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1161,6 +1161,8 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({ ), multiSelect: Schema.optional(Schema.Boolean), allowCustomAnswer: Schema.optional(Schema.Boolean), + /** Editable initial text. Answers preserve whitespace and allow an empty string when present. */ + initialAnswer: Schema.optional(Schema.String), required: Schema.optional(Schema.Boolean), }); export type OrchestrationV2UserInputQuestion = typeof OrchestrationV2UserInputQuestion.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5645d230a47afa7737ee7275a671d6fe541032f9..2f8de3dea24b06ea2a143c181903053fdc4d3f08 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -491,6 +491,8 @@ export const UserInputQuestion = Schema.Struct({ question: TrimmedNonEmptyStringSchema, options: Schema.Array(UserInputQuestionOption), allowCustomAnswer: Schema.optional(Schema.Boolean), + /** Editable initial text. Answers preserve whitespace and allow an empty string when present. */ + initialAnswer: Schema.optional(Schema.String), multiSelect: Schema.optional(Schema.Boolean).pipe( Schema.withConstructorDefault(Effect.succeed(false)), ), diff --git a/packages/contracts/src/userInputQuestion.test.ts b/packages/contracts/src/userInputQuestion.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..13c316d7cba49163b4567fddd47e25fa5b21e9f9 --- /dev/null +++ b/packages/contracts/src/userInputQuestion.test.ts @@ -0,0 +1,23 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { OrchestrationV2UserInputQuestion } from "./orchestrationV2.ts"; +import { UserInputQuestion } from "./providerRuntime.ts"; + +const question = { id: "editor", header: "Message", question: "Edit message", options: [] }; + +describe("initial user input answers", () => { + it("keeps existing question payloads compatible and preserves exact initial text", () => { + for (const schema of [UserInputQuestion, OrchestrationV2UserInputQuestion]) { + const decode = Schema.decodeUnknownSync(schema); + expect(decode(question).initialAnswer).toBeUndefined(); + for (const initialAnswer of [ + "", + " fix(server): keep whitespace\n\n", + "detail\n".repeat(500), + ]) { + expect(decode({ ...question, initialAnswer }).initialAnswer).toBe(initialAnswer); + } + } + }); +}); -- 2.54.0