authorgravatar for 275381468+derektrimm@users.noreply.github.comDerek Trimm <275381468+derektrimm@users.noreply.github.com> 2026-10-10 07:04:54-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 07:04:54-05:00
logee7924ff7fcabe24d5e8d940ac5e878d36477a0d
treeb6213a2462dba87c66d22f08ed845b5802118e25
parenta1dee8b2b34ad949935b703b85efb16b6fd1b987
signature Signed by PGP key B5690EEEBB952194

fix(web): paths pasted after @ keep their underscores (#16619)

Co-authored-by: Yash Singh <saiansh2525@gmail.com>

6 files changed, 141 insertions(+), 7 deletions(-)

apps/web/src/components/ChatView.tsx+2-2
...@@ -6056,9 +6056,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -6056,9 +6056,9 @@ export default function ChatView(props: ChatViewProps) {
6056 useEffect(() => {6056 useEffect(() => {
6057 if (!canMaximizeRightPanel) return;6057 if (!canMaximizeRightPanel) return;
6058 if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) {6058 if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) {
6059 setMaximizedRightPanelThreadKey(routeThreadKey);6059 useRightPanelStore.getState().setMaximized(routeThreadRef, true);
6060 }6060 }
6061 }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]);6061 }, [canMaximizeRightPanel, routeThreadRef]);
6062 const toggleRightPanelMaximized = useCallback(() => {6062 const toggleRightPanelMaximized = useCallback(() => {
6063 if (!canMaximizeRightPanel || !activeThreadRef) return;6063 if (!canMaximizeRightPanel || !activeThreadRef) return;
6064 useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized);6064 useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized);
apps/web/src/components/ComposerPromptEditorTiptap.tsx+15-2
...@@ -45,6 +45,7 @@ import {...@@ -45,6 +45,7 @@ import {
45 collapseExpandedComposerCursor,45 collapseExpandedComposerCursor,
46 expandCollapsedComposerCursor,46 expandCollapsedComposerCursor,
47 isCollapsedCursorAdjacentToInlineToken,47 isCollapsedCursorAdjacentToInlineToken,
48 pastedPathQueryLength,
48} from "~/composer-logic";49} from "~/composer-logic";
49import {50import {
50 collectComposerPromptInlineTokens,51 collectComposerPromptInlineTokens,
...@@ -160,6 +161,8 @@ export interface ComposerPromptEditorProps {...@@ -160,6 +161,8 @@ export interface ComposerPromptEditorProps {
160 suggestionListId?: string | undefined;161 suggestionListId?: string | undefined;
161 /** References the highlighted option only while its list is rendered. */162 /** References the highlighted option only while its list is rendered. */
162 activeSuggestionId?: string | undefined;163 activeSuggestionId?: string | undefined;
164 /** Reads suggestion state at paste time, including Escape dismissal. */
165 isPathQueryActive?: () => boolean;
163 containerClassName?: string;166 containerClassName?: string;
164 className?: string;167 className?: string;
165 placeholderClassName?: string;168 placeholderClassName?: string;
...@@ -820,6 +823,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -820,6 +823,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
820 ariaLabel,823 ariaLabel,
821 suggestionListId,824 suggestionListId,
822 activeSuggestionId,825 activeSuggestionId,
826 isPathQueryActive,
823 containerClassName,827 containerClassName,
824 className,828 className,
825 placeholderClassName,829 placeholderClassName,
...@@ -855,6 +859,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -855,6 +859,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
855 const onChangeRef = useRef(onChange);859 const onChangeRef = useRef(onChange);
856 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);860 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);
857 const onCommandKeyDownRef = useRef(onCommandKeyDown);861 const onCommandKeyDownRef = useRef(onCommandKeyDown);
862 const isPathQueryActiveRef = useRef(isPathQueryActive);
858 const buildFragmentRef = useRef(buildContextClipboardFragment);863 const buildFragmentRef = useRef(buildContextClipboardFragment);
859 const importFragmentRef = useRef(importContextFragment);864 const importFragmentRef = useRef(importContextFragment);
860 const skillsRef = useRef(skills);865 const skillsRef = useRef(skills);
...@@ -872,6 +877,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -872,6 +877,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
872 useEffect(() => {877 useEffect(() => {
873 onCommandKeyDownRef.current = onCommandKeyDown;878 onCommandKeyDownRef.current = onCommandKeyDown;
874 }, [onCommandKeyDown]);879 }, [onCommandKeyDown]);
880 useEffect(() => {
881 isPathQueryActiveRef.current = isPathQueryActive;
882 }, [isPathQueryActive]);
875 useEffect(() => {883 useEffect(() => {
876 buildFragmentRef.current = buildContextClipboardFragment;884 buildFragmentRef.current = buildContextClipboardFragment;
877 }, [buildContextClipboardFragment]);885 }, [buildContextClipboardFragment]);
...@@ -1412,6 +1420,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1412,6 +1420,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1412 const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from));1420 const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from));
1413 if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`;1421 if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`;
1414 }1422 }
1423 const { $from } = view.state.selection;
1424 const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC");
1425 const literalLength = literalText
1426 ? 0
1427 : pastedPathQueryLength(lineBefore, text, isPathQueryActiveRef.current?.());
1415 const editorInstance = editorHolder.current;1428 const editorInstance = editorHolder.current;
1416 if (editorInstance) {1429 if (editorInstance) {
1417 // Inside a list item or quote, pasted block markup has nowhere to1430 // Inside a list item or quote, pasted block markup has nowhere to
...@@ -1424,7 +1437,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1424,7 +1437,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1424 insertMarkdownParagraphs(1437 insertMarkdownParagraphs(
1425 text,1438 text,
1426 skillLabelFor,1439 skillLabelFor,
1427 { styling: richText, blocks: !nested, literalText },1440 { styling: richText, blocks: !nested, literalText, literalLength },
1428 (content) => {1441 (content) => {
1429 // Tagged on the same transaction insertContent builds, so the1442 // Tagged on the same transaction insertContent builds, so the
1430 // paste is one undo step of its own.1443 // paste is one undo step of its own.
...@@ -1815,7 +1828,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -1815,7 +1828,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
1815function insertMarkdownParagraphs(1828function insertMarkdownParagraphs(
1816 value: string,1829 value: string,
1817 skillLabelFor: (name: string) => SkillMeta,1830 skillLabelFor: (name: string) => SkillMeta,
1818 options: { styling: boolean; blocks?: boolean; literalText?: boolean },1831 options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
1819 insertContent: (content: JSONContent[] | JSONContent) => void,1832 insertContent: (content: JSONContent[] | JSONContent) => void,
1820): void {1833): void {
1821 const blocks = buildTiptapContent(value, skillLabelFor, options);1834 const blocks = buildTiptapContent(value, skillLabelFor, options);
apps/web/src/components/chat/ChatComposer.tsx+3
...@@ -7479,6 +7479,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -7479,6 +7479,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
7479 editorRef={composerEditorRef}7479 editorRef={composerEditorRef}
7480 richTextEnabled={settings.composerRichTextEnabled}7480 richTextEnabled={settings.composerRichTextEnabled}
7481 literalText={isLiteralPendingAnswer}7481 literalText={isLiteralPendingAnswer}
7482 isPathQueryActive={() =>
7483 resolveActiveComposerTrigger().trigger?.kind === "path"
7484 }
7482 value={7485 value={
7483 isComposerApprovalState7486 isComposerApprovalState
7484 ? ""7487 ? ""
apps/web/src/composer-logic.ts+16
...@@ -306,6 +306,22 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos...@@ -306,6 +306,22 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
306 };306 };
307}307}
308308
309/**
310 * Length of the leading part of `pasted` that continues an `@` path query
311 * typed at the end of `lineBefore`, the line's plain text up to the caret. A
312 * chip must read there as one non-space character, so it never passes for a
313 * typed `@path`.
314 */
315export function pastedPathQueryLength(
316 lineBefore: string,
317 pasted: string,
318 pathQueryActive = true,
319): number {
320 if (!pathQueryActive) return 0;
321 if (detectComposerTrigger(lineBefore, lineBefore.length)?.kind !== "path") return 0;
322 return /^\S*/.exec(pasted)![0].length;
323}
324
309/** Caret and trigger after replacing composer text and continuing at the end. */325/** Caret and trigger after replacing composer text and continuing at the end. */
310export function composerStateAtPromptEnd(326export function composerStateAtPromptEnd(
311 text: string,327 text: string,
apps/web/src/composer-rich-text-doc.test.ts+89-1
...@@ -6,7 +6,7 @@ import { EditorState, TextSelection } from "@tiptap/pm/state";...@@ -6,7 +6,7 @@ import { EditorState, TextSelection } from "@tiptap/pm/state";
6import { splitBlockKeepMarks } from "@tiptap/pm/commands";6import { splitBlockKeepMarks } from "@tiptap/pm/commands";
7import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
88
9import { collapseExpandedComposerCursor } from "./composer-logic";9import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic";
1010
11import {11import {
12 buildDocJson,12 buildDocJson,
...@@ -942,6 +942,94 @@ describe("pasting into a list item or quote", () => {...@@ -942,6 +942,94 @@ describe("pasting into a list item or quote", () => {
942 });942 });
943});943});
944944
945describe("pasting into a path query", () => {
946 const skillLabelFor = (name: string) => ({ label: name, description: null });
947
948 // Inserts `pasted` at the end of a one-line prompt the way the composer's
949 // paste handler does. `prompt` may be a document, to place the caret after a chip.
950 function paste(prompt: string | ProseMirrorNode, pasted: string, pathQueryActive = true) {
951 const promptDoc =
952 typeof prompt === "string"
953 ? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor))
954 : prompt;
955 const line = promptDoc.lastChild!;
956 const lineBefore = line.textBetween(0, line.content.size, undefined, "\uFFFC");
957 const literalLength = pastedPathQueryLength(lineBefore, pasted, pathQueryActive);
958 const blocks = buildTiptapContent(pasted, skillLabelFor, { literalLength });
959 const [first, ...rest] = blocks.map((block) => ProseMirrorNode.fromJSON(schema, block));
960 const head = promptDoc.lastChild!.content.append(first!.content);
961 const doc = schema.node("doc", null, [first!.copy(head), ...rest]);
962 doc.check();
963 return doc;
964 }
965
966 function boldText(doc: ProseMirrorNode) {
967 const bold: string[] = [];
968 doc.descendants((node) => {
969 if (node.isText && node.marks.some((mark) => mark.type.name === "bold")) {
970 bold.push(node.text!);
971 }
972 });
973 return bold;
974 }
975
976 it.each(["src/__test__.tsx", "src/__tests__/a.ts", "pkg/__init__.py", "a/**b**/c"])(
977 "keeps %s literal after @ so file search can match it",
978 (path) => {
979 const doc = paste("Open @", path);
980 expect(boldText(doc)).toEqual([]);
981 expect(serializeEditorDoc(doc).value).toBe(`Open @${path}`);
982 },
983 );
984
985 it("still styles the text after the path", () => {
986 const doc = paste("Open @", "src/__test__.tsx and **this**\n- [ ] then __that__");
987 expect(boldText(doc)).toEqual(["this", "that"]);
988 expect(doc.lastChild!.type.name).toBe("taskList");
989 expect(serializeEditorDoc(doc).value).toBe(
990 "Open @src/__test__.tsx and **this**\n- [ ] then **that**",
991 );
992 });
993
994 it("parses markdown after Escape dismisses the path query", () => {
995 const doc = paste("Open @", "__hello__ world", false);
996 expect(boldText(doc)).toEqual(["hello"]);
997 expect(serializeEditorDoc(doc).value).toBe("Open @**hello** world");
998 });
999
1000 it("keeps the rest of the path's line on that line", () => {
1001 const doc = paste("Open @", "src/a.ts - not a list item");
1002 expect(doc.childCount).toBe(1);
1003 expect(serializeEditorDoc(doc).value).toBe("Open @src/a.ts - not a list item");
1004 });
1005
1006 it("parses markdown when the paste does not continue a path query", () => {
1007 expect(boldText(paste("Open ", "src/__test__.tsx"))).toEqual(["test"]);
1008 expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]);
1009 expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]);
1010 });
1011
1012 it("keeps a path literal after a partly styled query", () => {
1013 const doc = paste("Open @**src/**", "__test__.tsx");
1014 expect(boldText(doc)).toEqual(["src/"]);
1015 expect(serializeEditorDoc(doc).value).toBe("Open @**src/**__test__.tsx");
1016 });
1017
1018 it("parses markdown pasted right after a mention chip", () => {
1019 const withSpace = ProseMirrorNode.fromJSON(
1020 schema,
1021 buildDocJson("Open @README.md ", skillLabelFor),
1022 );
1023 const paragraph = withSpace.firstChild!;
1024 expect(paragraph.lastChild!.text).toBe(" ");
1025 const chipLast = schema.node("doc", null, [
1026 paragraph.copy(paragraph.content.cut(0, paragraph.content.size - 1)),
1027 ]);
1028 expect(chipLast.firstChild!.lastChild!.type.name).toBe("composer-mention");
1029 expect(boldText(paste(chipLast, "__b__"))).toEqual(["b"]);
1030 });
1031});
1032
945describe("caret stops at styled edges", () => {1033describe("caret stops at styled edges", () => {
946 function stateAt(value: string, pmPos: number) {1034 function stateAt(value: string, pmPos: number) {
947 const doc = ProseMirrorNode.fromJSON(1035 const doc = ProseMirrorNode.fromJSON(
apps/web/src/composer-rich-text-doc.ts+16-2
...@@ -534,10 +534,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un...@@ -534,10 +534,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un
534 return json;534 return json;
535}535}
536536
537/**
538 * Document content for a prompt. `literalLength` keeps that many leading
539 * characters as plain text, for a paste that continues a path query: markers
540 * inside a path such as `src/__init__.py` must reach file search unstyled.
541 */
537export function buildTiptapContent(542export function buildTiptapContent(
538 value: string,543 value: string,
539 skillLabelFor: (name: string) => SkillMeta,544 skillLabelFor: (name: string) => SkillMeta,
540 options?: { styling?: boolean; blocks?: boolean; literalText?: boolean },545 options?: { styling?: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
541): Record<string, unknown>[] {546): Record<string, unknown>[] {
542 // Editor answers are verbatim text, including Markdown and context-token sources.547 // Editor answers are verbatim text, including Markdown and context-token sources.
543 if (options?.literalText) {548 if (options?.literalText) {
...@@ -560,7 +565,8 @@ export function buildTiptapContent(...@@ -560,7 +565,8 @@ export function buildTiptapContent(
560 // Code fences hold no chips, so their lines put the original source back in565 // Code fences hold no chips, so their lines put the original source back in
561 // place of the sentinel rather than building an atom for it.566 // place of the sentinel rather than building an atom for it.
562 const atomSources: string[] = [];567 const atomSources: string[] = [];
563 const text = splitPromptIntoComposerSegments(value)568 const literal = value.slice(0, options?.literalLength ?? 0);
569 const text = splitPromptIntoComposerSegments(value.slice(literal.length))
564 .map((segment) => {570 .map((segment) => {
565 if (segment.type === "text") return segment.text;571 if (segment.type === "text") return segment.text;
566 atoms.push(atomJsonForSegment(segment, skillLabelFor));572 atoms.push(atomJsonForSegment(segment, skillLabelFor));
...@@ -608,6 +614,14 @@ export function buildTiptapContent(...@@ -608,6 +614,14 @@ export function buildTiptapContent(
608614
609 for (let index = 0; index < sourceLines.length; index += 1) {615 for (let index = 0; index < sourceLines.length; index += 1) {
610 const line = sourceLines[index]!;616 const line = sourceLines[index]!;
617 // The rest of the literal's line continues the text before the caret, so
618 // it cannot open a block.
619 if (index === 0 && literal) {
620 entries.push({
621 line: { list: null, inline: [textJsonForSpan(literal, []), ...buildInline(line)] },
622 });
623 continue;
624 }
611 const opening = blockSyntax ? parseOpeningFence(line) : null;625 const opening = blockSyntax ? parseOpeningFence(line) : null;
612 if (!opening) {626 if (!opening) {
613 // A thematic break outranks a list: `- - -` and `* * *` are rules, and627 // A thematic break outranks a list: `- - -` and `* * *` are rules, and