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) {
60566056 useEffect(() => {
60576057 if (!canMaximizeRightPanel) return;
60586058 if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) {
6059 setMaximizedRightPanelThreadKey(routeThreadKey);
6059 useRightPanelStore.getState().setMaximized(routeThreadRef, true);
60606060 }
6061 }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]);
6061 }, [canMaximizeRightPanel, routeThreadRef]);
60626062 const toggleRightPanelMaximized = useCallback(() => {
60636063 if (!canMaximizeRightPanel || !activeThreadRef) return;
60646064 useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized);
apps/web/src/components/ComposerPromptEditorTiptap.tsx+15-2
......@@ -45,6 +45,7 @@ import {
4545 collapseExpandedComposerCursor,
4646 expandCollapsedComposerCursor,
4747 isCollapsedCursorAdjacentToInlineToken,
48 pastedPathQueryLength,
4849} from "~/composer-logic";
4950import {
5051 collectComposerPromptInlineTokens,
......@@ -160,6 +161,8 @@ export interface ComposerPromptEditorProps {
160161 suggestionListId?: string | undefined;
161162 /** References the highlighted option only while its list is rendered. */
162163 activeSuggestionId?: string | undefined;
164 /** Reads suggestion state at paste time, including Escape dismissal. */
165 isPathQueryActive?: () => boolean;
163166 containerClassName?: string;
164167 className?: string;
165168 placeholderClassName?: string;
......@@ -820,6 +823,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
820823 ariaLabel,
821824 suggestionListId,
822825 activeSuggestionId,
826 isPathQueryActive,
823827 containerClassName,
824828 className,
825829 placeholderClassName,
......@@ -855,6 +859,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
855859 const onChangeRef = useRef(onChange);
856860 const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange);
857861 const onCommandKeyDownRef = useRef(onCommandKeyDown);
862 const isPathQueryActiveRef = useRef(isPathQueryActive);
858863 const buildFragmentRef = useRef(buildContextClipboardFragment);
859864 const importFragmentRef = useRef(importContextFragment);
860865 const skillsRef = useRef(skills);
......@@ -872,6 +877,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
872877 useEffect(() => {
873878 onCommandKeyDownRef.current = onCommandKeyDown;
874879 }, [onCommandKeyDown]);
880 useEffect(() => {
881 isPathQueryActiveRef.current = isPathQueryActive;
882 }, [isPathQueryActive]);
875883 useEffect(() => {
876884 buildFragmentRef.current = buildContextClipboardFragment;
877885 }, [buildContextClipboardFragment]);
......@@ -1412,6 +1420,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
14121420 const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from));
14131421 if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`;
14141422 }
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?.());
14151428 const editorInstance = editorHolder.current;
14161429 if (editorInstance) {
14171430 // Inside a list item or quote, pasted block markup has nowhere to
......@@ -1424,7 +1437,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
14241437 insertMarkdownParagraphs(
14251438 text,
14261439 skillLabelFor,
1427 { styling: richText, blocks: !nested, literalText },
1440 { styling: richText, blocks: !nested, literalText, literalLength },
14281441 (content) => {
14291442 // Tagged on the same transaction insertContent builds, so the
14301443 // paste is one undo step of its own.
......@@ -1815,7 +1828,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
18151828function insertMarkdownParagraphs(
18161829 value: string,
18171830 skillLabelFor: (name: string) => SkillMeta,
1818 options: { styling: boolean; blocks?: boolean; literalText?: boolean },
1831 options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
18191832 insertContent: (content: JSONContent[] | JSONContent) => void,
18201833): void {
18211834 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)
74797479 editorRef={composerEditorRef}
74807480 richTextEnabled={settings.composerRichTextEnabled}
74817481 literalText={isLiteralPendingAnswer}
7482 isPathQueryActive={() =>
7483 resolveActiveComposerTrigger().trigger?.kind === "path"
7484 }
74827485 value={
74837486 isComposerApprovalState
74847487 ? ""
apps/web/src/composer-logic.ts+16
......@@ -306,6 +306,22 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
306306 };
307307}
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
309325/** Caret and trigger after replacing composer text and continuing at the end. */
310326export function composerStateAtPromptEnd(
311327 text: string,
apps/web/src/composer-rich-text-doc.test.ts+89-1
......@@ -6,7 +6,7 @@ import { EditorState, TextSelection } from "@tiptap/pm/state";
66import { splitBlockKeepMarks } from "@tiptap/pm/commands";
77import { describe, expect, it } from "vite-plus/test";
88
9import { collapseExpandedComposerCursor } from "./composer-logic";
9import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic";
1010
1111import {
1212 buildDocJson,
......@@ -942,6 +942,94 @@ describe("pasting into a list item or quote", () => {
942942 });
943943});
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
9451033describe("caret stops at styled edges", () => {
9461034 function stateAt(value: string, pmPos: number) {
9471035 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
534534 return json;
535535}
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 */
537542export function buildTiptapContent(
538543 value: string,
539544 skillLabelFor: (name: string) => SkillMeta,
540 options?: { styling?: boolean; blocks?: boolean; literalText?: boolean },
545 options?: { styling?: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
541546): Record<string, unknown>[] {
542547 // Editor answers are verbatim text, including Markdown and context-token sources.
543548 if (options?.literalText) {
......@@ -560,7 +565,8 @@ export function buildTiptapContent(
560565 // Code fences hold no chips, so their lines put the original source back in
561566 // place of the sentinel rather than building an atom for it.
562567 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))
564570 .map((segment) => {
565571 if (segment.type === "text") return segment.text;
566572 atoms.push(atomJsonForSegment(segment, skillLabelFor));
......@@ -608,6 +614,14 @@ export function buildTiptapContent(
608614
609615 for (let index = 0; index < sourceLines.length; index += 1) {
610616 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 }
611625 const opening = blockSyntax ? parseOpeningFence(line) : null;
612626 if (!opening) {
613627 // A thematic break outranks a list: `- - -` and `* * *` are rules, and