| author | |
| committer | |
| log | ee7924ff7fcabe24d5e8d940ac5e878d36477a0d |
| tree | b6213a2462dba87c66d22f08ed845b5802118e25 |
| parent | a1dee8b2b34ad949935b703b85efb16b6fd1b987 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 49 | import { | 50 | import { |
| 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 to | 1430 | // 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 the | 1442 | // 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) { |
| 1815 | function insertMarkdownParagraphs( | 1828 | function 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 | isComposerApprovalState | 7486 | 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 | } |
| 308 | 308 | ||
| 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 | */ | ||
| 315 | export 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. */ |
| 310 | export function composerStateAtPromptEnd( | 326 | export 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"; |
| 6 | import { splitBlockKeepMarks } from "@tiptap/pm/commands"; | 6 | import { splitBlockKeepMarks } from "@tiptap/pm/commands"; |
| 7 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | 8 | ||
| 9 | import { collapseExpandedComposerCursor } from "./composer-logic"; | 9 | import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic"; |
| 10 | 10 | ||
| 11 | import { | 11 | import { |
| 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 | }); |
| 944 | 944 | ||
| 945 | describe("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 | |||
| 945 | describe("caret stops at styled edges", () => { | 1033 | describe("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 | } |
| 536 | 536 | ||
| 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 | */ | ||
| 537 | export function buildTiptapContent( | 542 | export 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 in | 565 | // 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( |
| 608 | 614 | ||
| 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, and | 627 | // A thematic break outranks a list: `- - -` and `* * *` are rules, and |