From d6db011c13fb3db88f02ccdce83506ae1e939f50 Mon Sep 17 00:00:00 2001 From: Derek Trimm <275381468+derektrimm@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:42:27 -0500 Subject: [PATCH 1/4] fix(web): paths pasted after @ keep their underscores Pasting src/__test__.tsx after typing @ ran the path through inline markdown parsing: __test__ became bold and the prompt text turned into @src/**test**.tsx. The part of a paste that continues an @ path query is now inserted as plain text, and the rest of its line cannot open a block; the rest of the paste is still parsed as markdown. A mention chip serializes to the same @path text, so the query has to be plain text directly before the caret. --- .../components/ComposerPromptEditorTiptap.tsx | 14 +++- apps/web/src/composer-logic.ts | 16 ++++ apps/web/src/composer-rich-text-doc.test.ts | 79 ++++++++++++++++++- apps/web/src/composer-rich-text-doc.ts | 18 ++++- 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 22002431097f..9c9e537fe68b 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -45,6 +45,7 @@ import { collapseExpandedComposerCursor, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, + pastedPathQueryLength, } from "~/composer-logic"; import { collectComposerPromptInlineTokens, @@ -1404,14 +1405,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { text += " "; } + const map = serializeEditorDoc(view.state.doc); + const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); if ( (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") && tokens[0].start === 0 ) { - const map = serializeEditorDoc(view.state.doc); - const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; } + const typed = view.state.selection.$from.nodeBefore; + const typedLength = typed?.isText ? typed.text!.length : 0; + const literalLength = literalText + ? 0 + : pastedPathQueryLength(map.value, offset, text, typedLength); const editorInstance = editorHolder.current; if (editorInstance) { // Inside a list item or quote, pasted block markup has nowhere to @@ -1424,7 +1430,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { insertMarkdownParagraphs( text, skillLabelFor, - { styling: richText, blocks: !nested, literalText }, + { styling: richText, blocks: !nested, literalText, literalLength }, (content) => { // Tagged on the same transaction insertContent builds, so the // paste is one undo step of its own. @@ -1815,7 +1821,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { function insertMarkdownParagraphs( value: string, skillLabelFor: (name: string) => SkillMeta, - options: { styling: boolean; blocks?: boolean; literalText?: boolean }, + options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { const blocks = buildTiptapContent(value, skillLabelFor, options); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index c26996f494e5..9a79f3587d0a 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -306,6 +306,22 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +/** + * Length of the leading part of `pasted` that continues an `@` path query at + * `cursor`. `typedLength` is the plain text directly before the cursor: a + * query is typed text, while a mention chip serializes to the same `@path`. + */ +export function pastedPathQueryLength( + text: string, + cursor: number, + pasted: string, + typedLength: number, +): number { + const trigger = detectComposerTrigger(text, cursor); + if (trigger?.kind !== "path" || cursor - trigger.rangeStart > typedLength) return 0; + return /^\S*/.exec(pasted)![0].length; +} + /** Caret and trigger after replacing composer text and continuing at the end. */ export function composerStateAtPromptEnd( text: string, diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 1693cdbae9ac..0a450ef299b2 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -6,7 +6,7 @@ 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"; +import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic"; import { buildDocJson, @@ -942,6 +942,83 @@ describe("pasting into a list item or quote", () => { }); }); +describe("pasting into a path query", () => { + const skillLabelFor = (name: string) => ({ label: name, description: null }); + + // Inserts `pasted` at the end of a one-line prompt the way the composer's + // paste handler does. `prompt` may be a document, to place the caret after a chip. + function paste(prompt: string | ProseMirrorNode, pasted: string) { + const promptDoc = + typeof prompt === "string" + ? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor)) + : prompt; + const value = serializeEditorDoc(promptDoc).value; + const typed = promptDoc.lastChild!.lastChild; + const typedLength = typed?.isText ? typed.text!.length : 0; + const literalLength = pastedPathQueryLength(value, value.length, pasted, typedLength); + const blocks = buildTiptapContent(pasted, skillLabelFor, { literalLength }); + const [first, ...rest] = blocks.map((block) => ProseMirrorNode.fromJSON(schema, block)); + const head = promptDoc.lastChild!.content.append(first!.content); + const doc = schema.node("doc", null, [first!.copy(head), ...rest]); + doc.check(); + return doc; + } + + function boldText(doc: ProseMirrorNode) { + const bold: string[] = []; + doc.descendants((node) => { + if (node.isText && node.marks.some((mark) => mark.type.name === "bold")) { + bold.push(node.text!); + } + }); + return bold; + } + + it.each(["src/__test__.tsx", "src/__tests__/a.ts", "pkg/__init__.py", "a/**b**/c"])( + "keeps %s literal after @ so file search can match it", + (path) => { + const doc = paste("Open @", path); + expect(boldText(doc)).toEqual([]); + expect(serializeEditorDoc(doc).value).toBe(`Open @${path}`); + }, + ); + + it("still styles the text after the path", () => { + const doc = paste("Open @", "src/__test__.tsx and **this**\n- [ ] then __that__"); + expect(boldText(doc)).toEqual(["this", "that"]); + expect(doc.lastChild!.type.name).toBe("taskList"); + expect(serializeEditorDoc(doc).value).toBe( + "Open @src/__test__.tsx and **this**\n- [ ] then **that**", + ); + }); + + it("keeps the rest of the path's line on that line", () => { + const doc = paste("Open @", "src/a.ts - not a list item"); + expect(doc.childCount).toBe(1); + expect(serializeEditorDoc(doc).value).toBe("Open @src/a.ts - not a list item"); + }); + + it("parses markdown when the paste does not continue a path query", () => { + expect(boldText(paste("Open ", "src/__test__.tsx"))).toEqual(["test"]); + expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]); + expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]); + }); + + it("parses markdown pasted right after a mention chip", () => { + const withSpace = ProseMirrorNode.fromJSON( + schema, + buildDocJson("Open @README.md ", skillLabelFor), + ); + const paragraph = withSpace.firstChild!; + expect(paragraph.lastChild!.text).toBe(" "); + const chipLast = schema.node("doc", null, [ + paragraph.copy(paragraph.content.cut(0, paragraph.content.size - 1)), + ]); + expect(chipLast.firstChild!.lastChild!.type.name).toBe("composer-mention"); + expect(boldText(paste(chipLast, "__b__"))).toEqual(["b"]); + }); +}); + describe("caret stops at styled edges", () => { function stateAt(value: string, pmPos: number) { const doc = ProseMirrorNode.fromJSON( diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index dc3525e1249a..8c376fb5d93f 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -534,10 +534,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record SkillMeta, - options?: { styling?: boolean; blocks?: boolean; literalText?: boolean }, + options?: { styling?: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number }, ): Record[] { // Editor answers are verbatim text, including Markdown and context-token sources. if (options?.literalText) { @@ -560,7 +565,8 @@ export function buildTiptapContent( // Code fences hold no chips, so their lines put the original source back in // place of the sentinel rather than building an atom for it. const atomSources: string[] = []; - const text = splitPromptIntoComposerSegments(value) + const literal = value.slice(0, options?.literalLength ?? 0); + const text = splitPromptIntoComposerSegments(value.slice(literal.length)) .map((segment) => { if (segment.type === "text") return segment.text; atoms.push(atomJsonForSegment(segment, skillLabelFor)); @@ -608,6 +614,14 @@ export function buildTiptapContent( for (let index = 0; index < sourceLines.length; index += 1) { const line = sourceLines[index]!; + // The rest of the literal's line continues the text before the caret, so + // it cannot open a block. + if (index === 0 && literal) { + entries.push({ + line: { list: null, inline: [textJsonForSpan(literal, []), ...buildInline(line)] }, + }); + continue; + } const opening = blockSyntax ? parseOpeningFence(line) : null; if (!opening) { // A thematic break outranks a list: `- - -` and `* * *` are rules, and From e33bf503056c43dced46d5107bb74aebb6d1251a Mon Sep 17 00:00:00 2001 From: Derek Trimm <275381468+derektrimm@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:05:58 -0500 Subject: [PATCH 2/4] fix(web): detect the pasted-into path query from the line's plain text The guard compared a serialized Markdown offset with a plain-text length, so a partly styled query such as @**src/** was not recognised and the paste was parsed as Markdown. The query is now read from the line's plain text before the caret, where a chip is a single non-space character and so never passes for a typed @path. --- .../components/ComposerPromptEditorTiptap.tsx | 12 +++++------- apps/web/src/composer-logic.ts | 17 ++++++----------- apps/web/src/composer-rich-text-doc.test.ts | 13 +++++++++---- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 9c9e537fe68b..ed0bb21dbacd 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1405,19 +1405,17 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ) { text += " "; } - const map = serializeEditorDoc(view.state.doc); - const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); if ( (tokens[0]?.type === "mention" || tokens[0]?.type === "skill") && tokens[0].start === 0 ) { + const map = serializeEditorDoc(view.state.doc); + const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from)); if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`; } - const typed = view.state.selection.$from.nodeBefore; - const typedLength = typed?.isText ? typed.text!.length : 0; - const literalLength = literalText - ? 0 - : pastedPathQueryLength(map.value, offset, text, typedLength); + const { $from } = view.state.selection; + const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); + const literalLength = literalText ? 0 : pastedPathQueryLength(lineBefore, text); const editorInstance = editorHolder.current; if (editorInstance) { // Inside a list item or quote, pasted block markup has nowhere to diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 9a79f3587d0a..bca67b42f4ae 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -307,18 +307,13 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos } /** - * Length of the leading part of `pasted` that continues an `@` path query at - * `cursor`. `typedLength` is the plain text directly before the cursor: a - * query is typed text, while a mention chip serializes to the same `@path`. + * Length of the leading part of `pasted` that continues an `@` path query + * typed at the end of `lineBefore`, the line's plain text up to the caret. A + * chip must read there as one non-space character, so it never passes for a + * typed `@path`. */ -export function pastedPathQueryLength( - text: string, - cursor: number, - pasted: string, - typedLength: number, -): number { - const trigger = detectComposerTrigger(text, cursor); - if (trigger?.kind !== "path" || cursor - trigger.rangeStart > typedLength) return 0; +export function pastedPathQueryLength(lineBefore: string, pasted: string): number { + if (detectComposerTrigger(lineBefore, lineBefore.length)?.kind !== "path") return 0; return /^\S*/.exec(pasted)![0].length; } diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 0a450ef299b2..ba97232f7817 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -952,10 +952,9 @@ describe("pasting into a path query", () => { typeof prompt === "string" ? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor)) : prompt; - const value = serializeEditorDoc(promptDoc).value; - const typed = promptDoc.lastChild!.lastChild; - const typedLength = typed?.isText ? typed.text!.length : 0; - const literalLength = pastedPathQueryLength(value, value.length, pasted, typedLength); + const line = promptDoc.lastChild!; + const lineBefore = line.textBetween(0, line.content.size, undefined, "\uFFFC"); + const literalLength = pastedPathQueryLength(lineBefore, pasted); const blocks = buildTiptapContent(pasted, skillLabelFor, { literalLength }); const [first, ...rest] = blocks.map((block) => ProseMirrorNode.fromJSON(schema, block)); const head = promptDoc.lastChild!.content.append(first!.content); @@ -1004,6 +1003,12 @@ describe("pasting into a path query", () => { expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]); }); + it("keeps a path literal after a partly styled query", () => { + const doc = paste("Open @**src/**", "__test__.tsx"); + expect(boldText(doc)).toEqual(["src/"]); + expect(serializeEditorDoc(doc).value).toBe("Open @**src/**__test__.tsx"); + }); + it("parses markdown pasted right after a mention chip", () => { const withSpace = ProseMirrorNode.fromJSON( schema, From bebf5b7b42772d860a9ae25d2d0b7e8633b5ab72 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 06:52:15 -0500 Subject: [PATCH 3/4] fix(web): restore markdown paste after dismissing path suggestions --- .../web/src/components/ComposerPromptEditorTiptap.tsx | 11 ++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 3 +++ apps/web/src/composer-logic.ts | 7 ++++++- apps/web/src/composer-rich-text-doc.test.ts | 10 ++++++++-- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index ed0bb21dbacd..5d5c87f53881 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -161,6 +161,8 @@ export interface ComposerPromptEditorProps { suggestionListId?: string | undefined; /** References the highlighted option only while its list is rendered. */ activeSuggestionId?: string | undefined; + /** Reads suggestion state at paste time, including Escape dismissal. */ + isPathQueryActive?: () => boolean; containerClassName?: string; className?: string; placeholderClassName?: string; @@ -821,6 +823,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ariaLabel, suggestionListId, activeSuggestionId, + isPathQueryActive, containerClassName, className, placeholderClassName, @@ -856,6 +859,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const onChangeRef = useRef(onChange); const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); const onCommandKeyDownRef = useRef(onCommandKeyDown); + const isPathQueryActiveRef = useRef(isPathQueryActive); const buildFragmentRef = useRef(buildContextClipboardFragment); const importFragmentRef = useRef(importContextFragment); const skillsRef = useRef(skills); @@ -873,6 +877,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { useEffect(() => { onCommandKeyDownRef.current = onCommandKeyDown; }, [onCommandKeyDown]); + useEffect(() => { + isPathQueryActiveRef.current = isPathQueryActive; + }, [isPathQueryActive]); useEffect(() => { buildFragmentRef.current = buildContextClipboardFragment; }, [buildContextClipboardFragment]); @@ -1415,7 +1422,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } const { $from } = view.state.selection; const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC"); - const literalLength = literalText ? 0 : pastedPathQueryLength(lineBefore, text); + const literalLength = literalText + ? 0 + : pastedPathQueryLength(lineBefore, text, isPathQueryActiveRef.current?.()); const editorInstance = editorHolder.current; if (editorInstance) { // Inside a list item or quote, pasted block markup has nowhere to diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 82a34a840c2f..5f089d92ab04 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -7448,6 +7448,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) editorRef={composerEditorRef} richTextEnabled={settings.composerRichTextEnabled} literalText={isLiteralPendingAnswer} + isPathQueryActive={() => + resolveActiveComposerTrigger().trigger?.kind === "path" + } value={ isComposerApprovalState ? "" diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index bca67b42f4ae..a44b999f2ba6 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -312,7 +312,12 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos * chip must read there as one non-space character, so it never passes for a * typed `@path`. */ -export function pastedPathQueryLength(lineBefore: string, pasted: string): number { +export function pastedPathQueryLength( + lineBefore: string, + pasted: string, + pathQueryActive = true, +): number { + if (!pathQueryActive) return 0; if (detectComposerTrigger(lineBefore, lineBefore.length)?.kind !== "path") return 0; return /^\S*/.exec(pasted)![0].length; } diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index ba97232f7817..19c6e0299f2e 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -947,14 +947,14 @@ describe("pasting into a path query", () => { // Inserts `pasted` at the end of a one-line prompt the way the composer's // paste handler does. `prompt` may be a document, to place the caret after a chip. - function paste(prompt: string | ProseMirrorNode, pasted: string) { + function paste(prompt: string | ProseMirrorNode, pasted: string, pathQueryActive = true) { const promptDoc = typeof prompt === "string" ? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor)) : prompt; const line = promptDoc.lastChild!; const lineBefore = line.textBetween(0, line.content.size, undefined, "\uFFFC"); - const literalLength = pastedPathQueryLength(lineBefore, pasted); + const literalLength = pastedPathQueryLength(lineBefore, pasted, pathQueryActive); const blocks = buildTiptapContent(pasted, skillLabelFor, { literalLength }); const [first, ...rest] = blocks.map((block) => ProseMirrorNode.fromJSON(schema, block)); const head = promptDoc.lastChild!.content.append(first!.content); @@ -991,6 +991,12 @@ describe("pasting into a path query", () => { ); }); + it("parses markdown after Escape dismisses the path query", () => { + const doc = paste("Open @", "__hello__ world", false); + expect(boldText(doc)).toEqual(["hello"]); + expect(serializeEditorDoc(doc).value).toBe("Open @**hello** world"); + }); + it("keeps the rest of the path's line on that line", () => { const doc = paste("Open @", "src/a.ts - not a list item"); expect(doc.childCount).toBe(1); From 0b9ff2f118d2261c61528d50303b7ae28ab56cb7 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 10 Oct 2026 06:57:35 -0500 Subject: [PATCH 4/4] fix(web): use panel store for link-open maximize requests --- apps/web/src/components/ChatView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2620f363adc8..d2ec0af4ed20 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6056,9 +6056,9 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!canMaximizeRightPanel) return; if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) { - setMaximizedRightPanelThreadKey(routeThreadKey); + useRightPanelStore.getState().setMaximized(routeThreadRef, true); } - }, [canMaximizeRightPanel, routeThreadKey, routeThreadRef]); + }, [canMaximizeRightPanel, routeThreadRef]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel || !activeThreadRef) return; useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized);