authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-10-08 20:41:08+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 13:41:08-05:00
loge8f5850b824572bab76bf810696f875eb648eff9
treeb5da314d77b5a49497b951252f3d66297dd87757
parenteb1cf7bd0efba7364d53907036f5b7eefb379c39
signature Signed by PGP key B5690EEEBB952194

fix(server): Pi editor dialogs prefill the answer composer (#17206)


22 files changed, 570 insertions(+), 155 deletions(-)

apps/mobile/src/features/threads/QuestionAttachments.tsx+2
......@@ -189,6 +189,8 @@ export function QuestionAttachments(props: {
189189 <TextInputWrapper onPaste={paste}>
190190 <TextInput
191191 value={props.value}
192 multiline={props.question.initialAnswer !== undefined}
193 submitBehavior={props.question.initialAnswer !== undefined ? "newline" : undefined}
192194 editable={!props.disabled}
193195 onChangeText={props.onChangeText}
194196 onFocus={() => props.onInputFocusChange?.(true)}
apps/mobile/src/lib/threadActivity.test.ts+23
......@@ -2011,6 +2011,29 @@ const multiSelectQuestion = {
20112011} as const;
20122012
20132013describe("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
20142037 it("replaces single-select options and toggles multi-select options", () => {
20152038 expect(
20162039 togglePendingUserInputOptionSelection(
apps/mobile/src/lib/threadActivity.ts+10-6
......@@ -399,8 +399,12 @@ function resolvePendingUserInputAnswer(
399399): string | ReadonlyArray<string> | null {
400400 if (draft?.attachmentsBlocked) return null;
401401 const customAnswer =
402 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
403 if (customAnswer) {
402 question.allowCustomAnswer === false
403 ? null
404 : question.initialAnswer !== undefined
405 ? (draft?.customAnswer ?? null)
406 : normalizeDraftAnswer(draft?.customAnswer);
407 if (customAnswer !== null && customAnswer.length > 0) {
404408 return customAnswer;
405409 }
406410
......@@ -408,12 +412,12 @@ function resolvePendingUserInputAnswer(
408412 if (question.multiSelect) {
409413 return selectedOptionValues.length > 0
410414 ? selectedOptionValues
411 : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0
412 ? ""
413 : null;
415 : (customAnswer ??
416 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null));
414417 }
415418 return (
416419 selectedOptionValues[0] ??
420 customAnswer ??
417421 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
418422 );
419423}
......@@ -1633,7 +1637,7 @@ export function setPendingUserInputCustomAnswer(
16331637 }
16341638
16351639 const selectedOptionValues =
1636 customAnswer.trim().length > 0
1640 question.initialAnswer !== undefined || customAnswer.trim().length > 0
16371641 ? undefined
16381642 : normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
16391643 return {
apps/mobile/src/state/use-selected-thread-requests.ts+14
......@@ -18,6 +18,7 @@ import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/c
1818import {
1919 type PendingThreadRequests,
2020 type ThreadUserInputQuestion,
21 seedUserInputDraftAnswers,
2122} from "@t3tools/client-runtime/state/thread-requests";
2223import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
2324import { Atom } from "effect/reactivity";
......@@ -104,6 +105,19 @@ export function useSelectedThreadRequests() {
104105 const activePendingApproval = activePendingApprovals[0] ?? null;
105106 const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs;
106107 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]);
107121 const questionServerConfigs = useServerConfigs();
108122 const attachmentDrafts = useAtomValue(composerDraftsAtom);
109123 const preparationCounts = useAtomValue(questionAttachmentPreparationAtom);
apps/server/src/mcp/toolkits/thread/tools.ts+1
......@@ -141,6 +141,7 @@ const question = Schema.Struct({
141141 ),
142142 multiSelect: Schema.optional(Schema.Boolean),
143143 allowCustomAnswer: Schema.optional(Schema.Boolean),
144 initialAnswer: Schema.optional(Schema.String),
144145 required: Schema.optional(Schema.Boolean),
145146});
146147const pendingRequest = Schema.Struct({
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts+59
......@@ -2400,6 +2400,65 @@ describe("PiAdapterV2", () => {
24002400 }).pipe(Effect.scoped, Effect.provide(layerTest)),
24012401 );
24022402
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
24032462 it.effect("raises bridge edit confirmations as file-change approvals", () =>
24042463 Effect.gen(function* () {
24052464 const fake = yield* makeFakePi;
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts+11-15
......@@ -3160,25 +3160,21 @@ function piQuestion(
31603160 ? event["options"]
31613161 .filter((option): option is string => typeof option === "string")
31623162 .map((option) => ({ label: option || "Empty value", description: option, value: option }))
3163 : [
3164 {
3165 label: "Submit empty value",
3166 description: "Send an empty string to the extension.",
3167 value: "",
3168 },
3169 ];
3170 // The user-input contract has no prefill field, so an editor dialog's
3171 // prefill is surfaced inside the question text; without it the user would
3172 // edit blind against content they cannot see.
3173 const prefill = method === "editor" ? recordString(event, "prefill") : undefined;
3163 : method === "editor"
3164 ? []
3165 : [
3166 {
3167 label: "Submit empty value",
3168 description: "Send an empty string to the extension.",
3169 value: "",
3170 },
3171 ];
31743172 const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title;
31753173 return {
31763174 id: questionId,
31773175 header: title,
3178 question:
3179 prefill === undefined || prefill.length === 0
3180 ? question
3181 : `${question}\n\nCurrent value:\n${prefill.slice(0, 2_000)}`,
3176 question,
3177 ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}),
31823178 options,
31833179 };
31843180}
apps/web/src/components/ChatView.tsx+15
......@@ -217,6 +217,7 @@ import {
217217 togglePendingUserInputOptionSelection,
218218 type PendingUserInputDraftAnswer,
219219} from "../pendingUserInput";
220import { seedUserInputDraftAnswers } from "@t3tools/client-runtime/state/thread-requests";
220221import { useUiStateStore } from "../uiStateStore";
221222import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh";
222223import {
......@@ -3312,6 +3313,19 @@ export default function ChatView(props: ChatViewProps) {
33123313 activeThreadId,
33133314 activePendingUserInput?.requestId,
33143315 ]);
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 }
33153329 const pendingQuestionDraftKeys = useMemo(
33163330 () =>
33173331 activeThreadId
......@@ -10294,6 +10308,7 @@ export default function ChatView(props: ChatViewProps) {
1029410308 [questionId]: setPendingUserInputCustomAnswer(
1029510309 existing[activePendingRequestKey]?.[questionId],
1029610310 value,
10311 question,
1029710312 ),
1029810313 },
1029910314 }));
apps/web/src/components/ComposerPromptEditor.tsx+2-2
......@@ -9,8 +9,8 @@ export type {
99
1010/**
1111 * The composer editor. Tiptap in both modes: the `richTextEnabled` setting
12 * toggles Markdown styling, never the engine. Plain mode renders every
13 * marker as a literal character and serializes byte-identically.
12 * toggles Markdown styling, never the engine. Plain mode keeps Markdown
13 * markers literal; `literalText` also preserves context-token sources as text.
1414 */
1515export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
1616 return <ComposerPromptEditorTiptap {...props} />;
apps/web/src/components/ComposerPromptEditorTiptap.tsx+132-91
......@@ -140,6 +140,8 @@ export interface ComposerPromptEditorProps {
140140 * literal character.
141141 */
142142 richTextEnabled?: boolean;
143 /** Preserve editor answers verbatim, without Markdown or context chips. */
144 literalText?: boolean;
143145 /** Draft records behind the prompt's context references, keyed by context id. */
144146 contextRecords: ComposerDraftContextRecords;
145147 /** Structured clipboard payload for the given referenced ids, or null to skip. */
......@@ -773,11 +775,13 @@ const ComposerMarkersExtension = Extension.create({
773775type TiptapEditor = NonNullable<ReturnType<typeof useEditor>>;
774776
775777export function ComposerPromptEditorTiptap(props: ComposerPromptEditorProps) {
776 // Extensions are creation-time: flipping the setting remounts the editor.
777 // Both halves initialize from the controlled Markdown value, so the draft
778 // survives the flip.
778 // Extensions are creation-time, so changing text mode remounts the editor.
779 // Every mode initializes from the controlled value, preserving the draft.
779780 return (
780 <ComposerPromptEditorTiptapInner key={props.richTextEnabled ? "rich" : "plain"} {...props} />
781 <ComposerPromptEditorTiptapInner
782 key={props.literalText ? "literal" : props.richTextEnabled ? "rich" : "plain"}
783 {...props}
784 />
781785 );
782786}
783787
......@@ -806,6 +810,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
806810 value,
807811 cursor,
808812 richTextEnabled,
813 literalText = false,
809814 contextRecords,
810815 buildContextClipboardFragment,
811816 importContextFragment,
......@@ -830,7 +835,22 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
830835 } = props;
831836 // The setting toggles styling, not the engine: both modes are Tiptap.
832837 // 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 );
834854
835855 const onChangeRef = useRef(onChange);
836856 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);
......@@ -878,13 +898,13 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
878898 };
879899 }, []);
880900
881 const initialCursor = clampCollapsedComposerCursor(value, cursor);
882 const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor);
901 const initialCursor = clampEditorCursor(value, cursor);
902 const initialExpandedCursor = expandEditorCursor(value, initialCursor);
883903 const snapshotRef = useRef({
884904 value,
885905 cursor: initialCursor,
886906 expandedCursor: initialExpandedCursor,
887 contextIds: collectInlineContextIds(value),
907 contextIds: literalText ? [] : collectInlineContextIds(value),
888908 });
889909 const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor });
890910 const isApplyingControlledUpdateRef = useRef(false);
......@@ -910,62 +930,68 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
910930 [onCitationSubmitAndSend, openCitation],
911931 );
912932
913 const handleEditorChange = useCallback((updated: TiptapEditor) => {
914 const map = serializeEditorDoc(updated.state.doc);
915 const { from, to } = updated.state.selection;
916 const fromFlat = pmToFlat(map, from);
917 const toFlat = pmToFlat(map, to);
918 const nextValue = map.value;
919 const nextCursor = clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat));
920 const nextExpandedCursor = Math.max(
921 0,
922 Math.min(map.value.length, flatToMarkdown(map, fromFlat)),
923 );
924 const nextSelectionRange = {
925 start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)),
926 end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)),
927 };
928 const previousSelectionRange = selectionRangeRef.current;
929 selectionRangeRef.current = nextSelectionRange;
930 setIsEmpty(nextValue.length === 0);
931 const previousSnapshot = snapshotRef.current;
932 const snapshotChanged = !(
933 previousSnapshot.value === nextValue &&
934 previousSnapshot.cursor === nextCursor &&
935 previousSnapshot.expandedCursor === nextExpandedCursor &&
936 previousSnapshot.contextIds.length === map.contextIds.length &&
937 previousSnapshot.contextIds.every((id, index) => id === map.contextIds[index])
938 );
939 if (isApplyingControlledUpdateRef.current) return;
940 if (!snapshotChanged) {
941 if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) {
942 onVisibleSelectionChangeRef.current?.();
933 const handleEditorChange = useCallback(
934 (updated: TiptapEditor) => {
935 const map = serializeEditorDoc(updated.state.doc);
936 // Literal answers hold no chips, so reference-shaped text is not a context.
937 const contextIds = literalText ? [] : map.contextIds;
938 const { from, to } = updated.state.selection;
939 const fromFlat = pmToFlat(map, from);
940 const toFlat = pmToFlat(map, to);
941 const nextValue = map.value;
942 const nextCursor = clampEditorCursor(map.value, flatToCollapsed(map, fromFlat));
943 const nextExpandedCursor = Math.max(
944 0,
945 Math.min(map.value.length, flatToMarkdown(map, fromFlat)),
946 );
947 const nextSelectionRange = {
948 start: Math.min(nextExpandedCursor, flatToMarkdown(map, toFlat)),
949 end: Math.max(nextExpandedCursor, flatToMarkdown(map, toFlat)),
950 };
951 const previousSelectionRange = selectionRangeRef.current;
952 selectionRangeRef.current = nextSelectionRange;
953 setIsEmpty(nextValue.length === 0);
954 const previousSnapshot = snapshotRef.current;
955 const snapshotChanged = !(
956 previousSnapshot.value === nextValue &&
957 previousSnapshot.cursor === nextCursor &&
958 previousSnapshot.expandedCursor === nextExpandedCursor &&
959 previousSnapshot.contextIds.length === contextIds.length &&
960 previousSnapshot.contextIds.every((id, index) => id === contextIds[index])
961 );
962 if (isApplyingControlledUpdateRef.current) return;
963 if (!snapshotChanged) {
964 if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) {
965 onVisibleSelectionChangeRef.current?.();
966 }
967 return;
943968 }
944 return;
945 }
946 // A selection-only update while a newer prompt waits to be applied (a chip
947 // was just inserted through the store) would report stale text and clobber
948 // the prompt. Let the controlled rewrite land instead.
949 if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) {
950 return;
951 }
952 snapshotRef.current = {
953 value: nextValue,
954 cursor: nextCursor,
955 expandedCursor: nextExpandedCursor,
956 contextIds: map.contextIds,
957 };
958 // A fence holds no chips, so nothing in it should summon the mention or
959 // command menu: `@` in code is a decorator, not a file. Suppressing the
960 // trigger here also keeps the store from inserting a link the block can
961 // only show as literal text, which the store would then count as a chip.
962 const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true;
963 const suppressTrigger =
964 inCodeBlock ||
965 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||
966 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");
967 onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds);
968 }, []);
969 // A selection-only update while a newer prompt waits to be applied (a chip
970 // was just inserted through the store) would report stale text and clobber
971 // the prompt. Let the controlled rewrite land instead.
972 if (previousSnapshot.value === nextValue && nextValue !== latestValueRef.current) {
973 return;
974 }
975 snapshotRef.current = {
976 value: nextValue,
977 cursor: nextCursor,
978 expandedCursor: nextExpandedCursor,
979 contextIds,
980 };
981 // A fence holds no chips, so nothing in it should summon the mention or
982 // command menu: `@` in code is a decorator, not a file. Suppressing the
983 // trigger here also keeps the store from inserting a link the block can
984 // only show as literal text, which the store would then count as a chip.
985 const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true;
986 const suppressTrigger =
987 literalText ||
988 inCodeBlock ||
989 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") ||
990 isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right");
991 onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, contextIds);
992 },
993 [clampEditorCursor, literalText],
994 );
969995
970996 const editorAttributes = useMemo(
971997 () => ({
......@@ -1122,7 +1148,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
11221148 description: shortDescription || found.description?.trim() || null,
11231149 };
11241150 },
1125 { styling: richText },
1151 { styling: richText, literalText },
11261152 ),
11271153 editable: !disabled,
11281154 editorProps: {
......@@ -1319,7 +1345,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
13191345 return handled;
13201346 },
13211347 handleTextInput: (view, from, to, text) => {
1322 if (text.length !== 1) return false;
1348 if (literalText || text.length !== 1) return false;
13231349 const closer = SURROUND_CLOSE[text];
13241350 if (!closer || from === to) return false;
13251351 // Never wrap chips or other atoms, and never wrap styled text: the
......@@ -1365,11 +1391,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
13651391 return true;
13661392 }
13671393 const importFragment = importFragmentRef.current;
1368 let text = importFragment
1369 ? importPastedComposerText(clipboardData, importFragment)
1370 : pastedText;
1394 let text =
1395 !literalText && importFragment
1396 ? importPastedComposerText(clipboardData, importFragment)
1397 : pastedText;
13711398 // Complete chips at paste boundaries just as autocomplete does.
1372 const tokens = collectComposerPromptInlineTokens(`${text}\n`);
1399 const tokens = literalText ? [] : collectComposerPromptInlineTokens(`${text}\n`);
13731400 const lastToken = tokens.at(-1);
13741401 if (
13751402 (lastToken?.type === "mention" || lastToken?.type === "skill") &&
......@@ -1397,7 +1424,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
13971424 insertMarkdownParagraphs(
13981425 text,
13991426 skillLabelFor,
1400 { styling: richText, blocks: !nested },
1427 { styling: richText, blocks: !nested, literalText },
14011428 (content) => {
14021429 // Tagged on the same transaction insertContent builds, so the
14031430 // paste is one undo step of its own.
......@@ -1467,9 +1494,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
14671494 const fromFlat = pmToFlat(map, from);
14681495 const next: typeof snapshot = {
14691496 value: map.value,
1470 cursor: clampCollapsedComposerCursor(map.value, flatToCollapsed(map, fromFlat)),
1497 cursor: clampEditorCursor(map.value, flatToCollapsed(map, fromFlat)),
14711498 expandedCursor: Math.max(0, Math.min(map.value.length, flatToMarkdown(map, fromFlat))),
1472 contextIds: map.contextIds,
1499 contextIds: literalText ? [] : map.contextIds,
14731500 };
14741501 const toFlat = pmToFlat(map, to);
14751502 selectionRangeRef.current = {
......@@ -1478,14 +1505,14 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
14781505 };
14791506 snapshotRef.current = next;
14801507 return next;
1481 }, [editor]);
1508 }, [clampEditorCursor, editor, literalText]);
14821509
14831510 // Controlled value/cursor from the store (history recall, chip insertion…).
14841511 useLayoutEffect(() => {
14851512 if (!editor) return;
14861513 const initialSelection = !hasAppliedControlledSelectionRef.current;
14871514 hasAppliedControlledSelectionRef.current = true;
1488 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
1515 const normalizedCursor = clampEditorCursor(value, cursor);
14891516 const previousSnapshot = snapshotRef.current;
14901517 if (
14911518 !initialSelection &&
......@@ -1494,7 +1521,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
14941521 ) {
14951522 return;
14961523 }
1497 const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor);
1524 const normalizedExpandedCursor = expandEditorCursor(value, normalizedCursor);
14981525 snapshotRef.current = {
14991526 value,
15001527 cursor: normalizedCursor,
......@@ -1514,9 +1541,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
15141541 const pendingCitation =
15151542 citationRequestRef.current?.value === value ? citationRequestRef.current : null;
15161543 if (previousSnapshot.value !== value) {
1517 editor.commands.setContent(buildDocJson(value, skillLabelFor, { styling: richText }), {
1518 emitUpdate: false,
1519 });
1544 editor.commands.setContent(
1545 buildDocJson(value, skillLabelFor, { styling: richText, literalText }),
1546 {
1547 emitUpdate: false,
1548 },
1549 );
15201550 }
15211551 const map = serializeEditorDoc(editor.state.doc);
15221552 const flat = collapsedToFlat(map, normalizedCursor);
......@@ -1545,7 +1575,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
15451575 queueMicrotask(() => {
15461576 isApplyingControlledUpdateRef.current = false;
15471577 });
1548 }, [cursor, editor, richText, skillLabelFor, value]);
1578 }, [
1579 clampEditorCursor,
1580 cursor,
1581 editor,
1582 expandEditorCursor,
1583 literalText,
1584 richText,
1585 skillLabelFor,
1586 value,
1587 ]);
15491588
15501589 const focusAt = useCallback(
15511590 (nextCursor: number) => {
......@@ -1559,7 +1598,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
15591598 // through the store). Reporting the editor's stale text now would
15601599 // overwrite that prompt; the pending rewrite places the caret instead.
15611600 if (snapshotRef.current.value !== latestValueRef.current) return;
1562 const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor);
1601 const boundedCursor = clampEditorCursor(snapshotRef.current.value, nextCursor);
15631602 const map = serializeEditorDoc(editor.state.doc);
15641603 const flat = collapsedToFlat(map, boundedCursor);
15651604 editor.commands.setTextSelection(flatToPm(map, flat));
......@@ -1568,7 +1607,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
15681607 snapshotRef.current = {
15691608 value: snapshotRef.current.value,
15701609 cursor: boundedCursor,
1571 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
1610 expandedCursor: expandEditorCursor(snapshotRef.current.value, boundedCursor),
15721611 contextIds: snapshotRef.current.contextIds,
15731612 };
15741613 selectionRangeRef.current = {
......@@ -1579,11 +1618,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
15791618 snapshotRef.current.value,
15801619 boundedCursor,
15811620 snapshotRef.current.expandedCursor,
1582 false,
1621 literalText,
15831622 snapshotRef.current.contextIds,
15841623 );
15851624 },
1586 [editor],
1625 [clampEditorCursor, editor, expandEditorCursor, literalText],
15871626 );
15881627
15891628 useImperativeHandle(
......@@ -1595,10 +1634,12 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
15951634 focusAt,
15961635 focusAtEnd: () => {
15971636 focusAt(
1598 collapseExpandedComposerCursor(
1599 snapshotRef.current.value,
1600 snapshotRef.current.value.length,
1601 ),
1637 literalText
1638 ? snapshotRef.current.value.length
1639 : collapseExpandedComposerCursor(
1640 snapshotRef.current.value,
1641 snapshotRef.current.value.length,
1642 ),
16021643 );
16031644 },
16041645 readSelectionRange: () => {
......@@ -1660,7 +1701,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
16601701 : edgeRect.bottom - caretRect.bottom < threshold;
16611702 },
16621703 }),
1663 [editor, focusAt, readSnapshot],
1704 [editor, focusAt, literalText, readSnapshot],
16641705 );
16651706
16661707 const handleCopyCut = useCallback(
......@@ -1671,7 +1712,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
16711712 const { from, to } = editor.state.selection;
16721713 if (from === to) return;
16731714 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)));
16751716 const fragment = contextIds.length > 0 ? build?.(contextIds) : null;
16761717 event.preventDefault();
16771718 clipboardData.setData("text/plain", text);
......@@ -1691,7 +1732,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
16911732 .run();
16921733 }
16931734 },
1694 [editor],
1735 [editor, literalText],
16951736 );
16961737
16971738 return (
......@@ -1774,7 +1815,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
17741815function insertMarkdownParagraphs(
17751816 value: string,
17761817 skillLabelFor: (name: string) => SkillMeta,
1777 options: { styling: boolean; blocks?: boolean },
1818 options: { styling: boolean; blocks?: boolean; literalText?: boolean },
17781819 insertContent: (content: JSONContent[] | JSONContent) => void,
17791820): void {
17801821 const blocks = buildTiptapContent(value, skillLabelFor, options);
apps/web/src/components/chat/ChatComposer.tsx+64-24
......@@ -1577,6 +1577,7 @@ export interface ChatComposerProps {
15771577 customAnswer: string;
15781578 activeQuestion: {
15791579 id: string;
1580 initialAnswer?: string | undefined;
15801581 multiSelect?: boolean | undefined;
15811582 allowCustomAnswer?: boolean | undefined;
15821583 } | null;
......@@ -1807,6 +1808,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18071808 editingQueuedAttachments,
18081809 onRemoveEditingQueuedAttachment,
18091810 } = props;
1811 const isLiteralPendingAnswer = activePendingProgress?.activeQuestion?.initialAnswer !== undefined;
18101812 const primaryEnvironmentId = usePrimaryEnvironmentId();
18111813 const composerDraftTargetKey = composerTargetKey(composerDraftTarget);
18121814 // 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)
23922394 // ------------------------------------------------------------------
23932395 // Composer-local state
23942396 // ------------------------------------------------------------------
2395 const [composerCursor, setComposerCursor] = useState(() =>
2396 collapseExpandedComposerCursor(prompt, prompt.length),
2397 );
2397 const [composerCursor, setComposerCursor] = useState(() => {
2398 const value = isLiteralPendingAnswer ? (activePendingProgress?.customAnswer ?? prompt) : prompt;
2399 return collapseExpandedComposerCursor(value, value.length, isLiteralPendingAnswer);
2400 });
23982401 const {
23992402 trigger: composerTrigger,
24002403 setTrigger: setComposerTrigger,
24012404 resolveTrigger: resolveComposerTrigger,
24022405 dismissTrigger: dismissComposerTrigger,
24032406 resetTrigger: resetComposerTrigger,
2404 } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));
2407 } = useComposerTriggerState(() =>
2408 isLiteralPendingAnswer ? null : detectComposerTrigger(prompt, prompt.length),
2409 );
24052410 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
24062411 const composerSuggestionId = useId();
24072412 const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`;
......@@ -3395,9 +3400,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
33953400 // Sync refs back to parent
33963401 // ------------------------------------------------------------------
33973402 useEffect(() => {
3403 if (isLiteralPendingAnswer) return;
33983404 promptRef.current = prompt;
33993405 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
3400 }, [prompt, promptRef]);
3406 }, [isLiteralPendingAnswer, prompt, promptRef]);
34013407
34023408 useEffect(() => {
34033409 if (composerSubmissionError === null) return;
......@@ -3498,13 +3504,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34983504 }
34993505
35003506 promptRef.current = nextCustomAnswer;
3501 const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer);
3507 const { cursor, trigger } = composerStateAtPromptEnd(nextCustomAnswer, isLiteralPendingAnswer);
35023508 setComposerCursor(cursor);
35033509 resetComposerTrigger(trigger);
35043510 setComposerHighlightedItemId(null);
35053511 }, [
35063512 activePendingProgress?.customAnswer,
35073513 activePendingProgress?.activeQuestion?.id,
3514 isLiteralPendingAnswer,
35083515 activePendingUserInput?.requestId,
35093516 prompt,
35103517 promptRef,
......@@ -3519,11 +3526,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35193526 setComposerHighlightedSearchKey(null);
35203527 setComposerSubmissionError(null);
35213528 setProviderInputSubmissionError(null);
3522 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
3523 resetComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
3529 const { cursor, trigger } = composerStateAtPromptEnd(promptRef.current, isLiteralPendingAnswer);
3530 setComposerCursor(cursor);
3531 resetComposerTrigger(trigger);
35243532 setIsDragOverComposer(false);
35253533 setIsComposerScrollCollapsed(false);
3526 }, [draftId, activeThreadId, promptRef, resetComposerTrigger, setIsComposerScrollCollapsed]);
3534 }, [
3535 draftId,
3536 activeThreadId,
3537 isLiteralPendingAnswer,
3538 promptRef,
3539 resetComposerTrigger,
3540 setIsComposerScrollCollapsed,
3541 ]);
35273542
35283543 // ------------------------------------------------------------------
35293544 // Footer compact layout observation
......@@ -3866,8 +3881,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38663881 const nextCursor = collapseExpandedComposerCursor(
38673882 next.text,
38683883 options?.expandedCursorAfterReplace ?? next.cursor,
3884 isLiteralPendingAnswer,
3885 );
3886 const nextExpandedCursor = expandCollapsedComposerCursor(
3887 next.text,
3888 nextCursor,
3889 isLiteralPendingAnswer,
38693890 );
3870 const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor);
38713891 if (options?.citationComment) {
38723892 composerEditorRef.current?.requestCitationComment({
38733893 previousValue: currentText,
......@@ -3885,13 +3905,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38853905 next.text,
38863906 nextCursor,
38873907 nextExpandedCursor,
3888 false,
3908 isLiteralPendingAnswer,
38893909 );
38903910 } else {
38913911 setPrompt(next.text);
38923912 }
38933913 setComposerCursor(nextCursor);
3894 setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor));
3914 setComposerTrigger(
3915 isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor),
3916 );
38953917 if (options?.focusEditorAfterReplace !== false) {
38963918 window.requestAnimationFrame(() => {
38973919 // Type-to-focus routes only the first key through here; once the
......@@ -3907,6 +3929,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39073929 [
39083930 activePendingProgress?.activeQuestion,
39093931 activePendingUserInput,
3932 isLiteralPendingAnswer,
39103933 onChangeActivePendingUserInputCustomAnswer,
39113934 promptRef,
39123935 setPrompt,
......@@ -3927,10 +3950,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39273950 return {
39283951 value: promptRef.current,
39293952 cursor: composerCursor,
3930 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
3931 contextIds: collectInlineContextIds(promptRef.current),
3953 expandedCursor: expandCollapsedComposerCursor(
3954 promptRef.current,
3955 composerCursor,
3956 isLiteralPendingAnswer,
3957 ),
3958 contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(promptRef.current),
39323959 };
3933 }, [composerCursor, promptRef]);
3960 }, [composerCursor, isLiteralPendingAnswer, promptRef]);
39343961
39353962 const resolveActiveComposerTrigger = useCallback((): {
39363963 snapshot: { value: string; cursor: number; expandedCursor: number };
......@@ -3939,11 +3966,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39393966 const snapshot = readComposerSnapshot();
39403967 return {
39413968 snapshot,
3942 trigger: resolveComposerTrigger(
3943 detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
3944 ),
3969 trigger: isLiteralPendingAnswer
3970 ? null
3971 : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)),
39453972 };
3946 }, [readComposerSnapshot, resolveComposerTrigger]);
3973 }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]);
39473974
39483975 const { onUsageLimitsCommand } = props;
39493976 const onSelectComposerItem = useCallback(
......@@ -4434,7 +4461,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44344461 return true;
44354462 }
44364463 const { trigger } = resolveActiveComposerTrigger();
4437 const menuIsActive = composerMenuOpenRef.current || trigger !== null;
4464 const menuIsActive =
4465 !isLiteralPendingAnswer && (composerMenuOpenRef.current || trigger !== null);
44384466 if (key === "Escape") {
44394467 if (!menuIsActive || event.isComposing || event.keyCode === 229) return false;
44404468 dismissComposerTrigger(trigger);
......@@ -4474,7 +4502,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44744502 }
44754503 // Native task splitting preserves marks and chips on both sides of the caret.
44764504 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 ) {
44784510 const selection = composerEditorRef.current?.readSelectionRange();
44794511 const snapshot = readComposerSnapshot();
44804512 if (selection && selection.start === selection.end && snapshot.value === promptRef.current) {
......@@ -5990,6 +6022,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
59906022 bypassAutoAttachment: boolean,
59916023 selectionOverride?: { start: number; end: number },
59926024 ): boolean => {
6025 // Editor answers keep pasted text in the answer, regardless of paste size.
6026 if (isLiteralPendingAnswer) return false;
59936027 const questionCanAttach =
59946028 pendingUserInputs.length === 0 ||
59956029 (supportsQuestionAttachments &&
......@@ -6436,11 +6470,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
64366470 detectTrigger?: boolean;
64376471 }) => {
64386472 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 );
64406478 setComposerHighlightedItemId(null);
64416479 setComposerCursor(cursor);
64426480 resetComposerTrigger(
6443 options?.detectTrigger
6481 options?.detectTrigger && !isLiteralPendingAnswer
64446482 ? detectComposerTrigger(
64456483 promptForState,
64466484 expandCollapsedComposerCursor(promptForState, cursor),
......@@ -6449,7 +6487,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
64496487 );
64506488 },
64516489 addTerminalContext: (selection: TerminalContextSelection) => {
6452 if (!activeThread || isChoiceOnlyPendingQuestion) return;
6490 if (!activeThread || isChoiceOnlyPendingQuestion || isLiteralPendingAnswer) return;
64536491 const snapshot = readComposerSnapshot();
64546492 const context = {
64556493 id: randomUUID(),
......@@ -6543,6 +6581,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
65436581 isConnecting,
65446582 isComposerApprovalState,
65456583 isChoiceOnlyPendingQuestion,
6584 isLiteralPendingAnswer,
65466585 pendingUserInputs.length,
65476586 projectSelectionRequired,
65486587 applyPromptReplacement,
......@@ -7400,6 +7439,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
74007439 }
74017440 editorRef={composerEditorRef}
74027441 richTextEnabled={settings.composerRichTextEnabled}
7442 literalText={isLiteralPendingAnswer}
74037443 value={
74047444 isComposerApprovalState
74057445 ? ""
apps/web/src/composer-logic.test.ts+30
......@@ -817,3 +817,33 @@ describe("parseStandaloneComposerSlashCommand", () => {
817817 expect(parseStandaloneComposerSlashCommand("/plan explain this")).toBeNull();
818818 });
819819});
820
821describe("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 {
8989 return index + 1;
9090}
9191
92export function expandCollapsedComposerCursor(text: string, cursorInput: number): number {
92export function expandCollapsedComposerCursor(
93 text: string,
94 cursorInput: number,
95 literalText = false,
96): number {
97 if (literalText) return clampCursor(text, cursorInput);
9398 const collapsedCursor = clampCursor(text, cursorInput);
9499 const segments = splitPromptIntoComposerSegments(text);
95100 if (segments.length === 0) {
......@@ -155,14 +160,24 @@ function clampCollapsedComposerCursorForSegments(
155160 return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput)));
156161}
157162
158export function clampCollapsedComposerCursor(text: string, cursorInput: number): number {
163export function clampCollapsedComposerCursor(
164 text: string,
165 cursorInput: number,
166 literalText = false,
167): number {
168 if (literalText) return clampCursor(text, cursorInput);
159169 return clampCollapsedComposerCursorForSegments(
160170 splitPromptIntoComposerSegments(text),
161171 cursorInput,
162172 );
163173}
164174
165export function collapseExpandedComposerCursor(text: string, cursorInput: number): number {
175export function collapseExpandedComposerCursor(
176 text: string,
177 cursorInput: number,
178 literalText = false,
179): number {
180 if (literalText) return clampCursor(text, cursorInput);
166181 const expandedCursor = clampCursor(text, cursorInput);
167182 const segments = splitPromptIntoComposerSegments(text);
168183 if (segments.length === 0) {
......@@ -292,14 +307,19 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
292307}
293308
294309/** Caret and trigger after replacing composer text and continuing at the end. */
295export function composerStateAtPromptEnd(text: string): {
310export function composerStateAtPromptEnd(
311 text: string,
312 literalText = false,
313): {
296314 cursor: number;
297315 trigger: ComposerTrigger | null;
298316} {
299 const cursor = collapseExpandedComposerCursor(text, text.length);
317 const cursor = collapseExpandedComposerCursor(text, text.length, literalText);
300318 return {
301319 cursor,
302 trigger: detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)),
320 trigger: literalText
321 ? null
322 : detectComposerTrigger(text, expandCollapsedComposerCursor(text, cursor)),
303323 };
304324}
305325
apps/web/src/composer-rich-text-doc.test.ts+48
......@@ -3,6 +3,7 @@ import StarterKit from "@tiptap/starter-kit";
33import { TaskList } from "@tiptap/extension-task-list";
44import { Node as ProseMirrorNode } from "@tiptap/pm/model";
55import { EditorState, TextSelection } from "@tiptap/pm/state";
6import { splitBlockKeepMarks } from "@tiptap/pm/commands";
67import { describe, expect, it } from "vite-plus/test";
78
89import { collapseExpandedComposerCursor } from "./composer-logic";
......@@ -135,6 +136,53 @@ function roundTripPlain(value: string) {
135136 return serializeEditorDoc(doc);
136137}
137138
139describe("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
138186describe("composer rich text document model", () => {
139187 it.each(["€", "£", "¥", "₹", "₩", "₿", "𑿝"])(
140188 "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
537537export function buildTiptapContent(
538538 value: string,
539539 skillLabelFor: (name: string) => SkillMeta,
540 options?: { styling?: boolean; blocks?: boolean },
540 options?: { styling?: boolean; blocks?: boolean; literalText?: boolean },
541541): 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 }
542549 const styling = options?.styling ?? true;
543550 // Inline marks without block structure: text pasted into a list item or
544551 // quote, which has no line to write a nested block into.
......@@ -759,7 +766,7 @@ export function buildTiptapContent(
759766export function buildDocJson(
760767 value: string,
761768 skillLabelFor: (name: string) => SkillMeta,
762 options?: { styling?: boolean },
769 options?: { styling?: boolean; literalText?: boolean },
763770) {
764771 const content = buildTiptapContent(value, skillLabelFor, options);
765772 // The empty line the rule extension keeps after a final rule.
apps/web/src/pendingUserInput.test.ts+28
......@@ -54,6 +54,34 @@ const nativeChoiceQuestion = {
5454} as const;
5555
5656describe("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
5785 it("prefers a custom answer over selected options", () => {
5886 expect(
5987 resolvePendingUserInputAnswer(singleSelectQuestion, {
apps/web/src/pendingUserInput.ts+14-8
......@@ -45,8 +45,12 @@ export function resolvePendingUserInputAnswer(
4545): string | string[] | null {
4646 if (draft?.attachmentsBlocked) return null;
4747 const customAnswer =
48 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
49 if (customAnswer) {
48 question.allowCustomAnswer === false
49 ? null
50 : question.initialAnswer !== undefined
51 ? (draft?.customAnswer ?? null)
52 : normalizeDraftAnswer(draft?.customAnswer);
53 if (customAnswer !== null && customAnswer.length > 0) {
5054 return customAnswer;
5155 }
5256
......@@ -56,13 +60,13 @@ export function resolvePendingUserInputAnswer(
5660 if (question.multiSelect) {
5761 return selectedOptionValues.length > 0
5862 ? selectedOptionValues
59 : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0
60 ? ""
61 : null;
63 : (customAnswer ??
64 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null));
6265 }
6366
6467 return (
6568 selectedOptionValues[0] ??
69 customAnswer ??
6670 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
6771 );
6872}
......@@ -70,9 +74,10 @@ export function resolvePendingUserInputAnswer(
7074export function setPendingUserInputCustomAnswer(
7175 draft: PendingUserInputDraftAnswer | undefined,
7276 customAnswer: string,
77 question?: Pick<UserInputQuestion, "initialAnswer">,
7378): PendingUserInputDraftAnswer {
7479 const selectedOptionValues =
75 customAnswer.trim().length > 0
80 question?.initialAnswer !== undefined || customAnswer.trim().length > 0
7681 ? undefined
7782 : normalizeSelectedOptionValues(draft?.selectedOptionValues);
7883
......@@ -162,7 +167,7 @@ export function findFirstUnansweredPendingUserInputQuestionIndex(
162167 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
163168): number {
164169 const unansweredIndex = questions.findIndex(
165 (question) => !resolvePendingUserInputAnswer(question, draftAnswers[question.id]),
170 (question) => resolvePendingUserInputAnswer(question, draftAnswers[question.id]) === null,
166171 );
167172
168173 return unansweredIndex === -1 ? Math.max(questions.length - 1, 0) : unansweredIndex;
......@@ -193,7 +198,8 @@ export function derivePendingUserInputProgress(
193198 selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues),
194199 customAnswer,
195200 resolvedAnswer,
196 usingCustomAnswer: customAnswer.trim().length > 0,
201 usingCustomAnswer:
202 (activeQuestion?.initialAnswer !== undefined ? customAnswer : customAnswer.trim()).length > 0,
197203 answeredQuestionCount,
198204 isLastQuestion,
199205 isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,
packages/client-runtime/src/state/threadRequests.test.ts+34-1
......@@ -7,10 +7,43 @@ import {
77} from "@t3tools/contracts";
88import { describe, expect, it } from "vite-plus/test";
99import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts";
10import { createQuestionHistoryProjector, derivePendingThreadRequests } from "./threadRequests.ts";
10import {
11 createQuestionHistoryProjector,
12 derivePendingThreadRequests,
13 seedUserInputDraftAnswers,
14} from "./threadRequests.ts";
1115
1216const requestId = RuntimeRequestId.make("async-question");
1317const nodeId = NodeId.make("async-question-node");
18
19describe("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});
1447const projection: OrchestrationV2ThreadProjection = {
1548 ...v2Projection,
1649 runtimeRequests: [
packages/client-runtime/src/state/threadRequests.ts+21
......@@ -41,6 +41,27 @@ export interface PendingThreadRequests {
4141 readonly userInputs: ReadonlyArray<ThreadPendingUserInput>;
4242}
4343
44/** Seed each question once. Existing edits, cleared answers, and option selections win. */
45export 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
4465/** Joins pending request entities to the request items that carry display data. */
4566export function derivePendingThreadRequests(
4667 projection: Pick<OrchestrationV2ThreadProjection, "runtimeRequests" | "turnItems">,
packages/contracts/src/orchestrationV2.ts+2
......@@ -1161,6 +1161,8 @@ export const OrchestrationV2UserInputQuestion = Schema.Struct({
11611161 ),
11621162 multiSelect: Schema.optional(Schema.Boolean),
11631163 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),
11641166 required: Schema.optional(Schema.Boolean),
11651167});
11661168export type OrchestrationV2UserInputQuestion = typeof OrchestrationV2UserInputQuestion.Type;
packages/contracts/src/providerRuntime.ts+2
......@@ -491,6 +491,8 @@ export const UserInputQuestion = Schema.Struct({
491491 question: TrimmedNonEmptyStringSchema,
492492 options: Schema.Array(UserInputQuestionOption),
493493 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),
494496 multiSelect: Schema.optional(Schema.Boolean).pipe(
495497 Schema.withConstructorDefault(Effect.succeed(false)),
496498 ),
packages/contracts/src/userInputQuestion.test.ts created+23
......@@ -0,0 +1,23 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import { OrchestrationV2UserInputQuestion } from "./orchestrationV2.ts";
5import { UserInputQuestion } from "./providerRuntime.ts";
6
7const question = { id: "editor", header: "Message", question: "Edit message", options: [] };
8
9describe("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});