Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
collapseExpandedComposerCursor,
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
pastedPathQueryLength,
} from "~/composer-logic";
import {
collectComposerPromptInlineTokens,
Expand Down Expand Up @@ -160,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;
Expand Down Expand Up @@ -820,6 +823,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
ariaLabel,
suggestionListId,
activeSuggestionId,
isPathQueryActive,
containerClassName,
className,
placeholderClassName,
Expand Down Expand Up @@ -855,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);
Expand All @@ -872,6 +877,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
useEffect(() => {
onCommandKeyDownRef.current = onCommandKeyDown;
}, [onCommandKeyDown]);
useEffect(() => {
isPathQueryActiveRef.current = isPathQueryActive;
}, [isPathQueryActive]);
useEffect(() => {
buildFragmentRef.current = buildContextClipboardFragment;
}, [buildContextClipboardFragment]);
Expand Down Expand Up @@ -1412,6 +1420,11 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from));
if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`;
}
const { $from } = view.state.selection;
const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC");
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
Expand All @@ -1424,7 +1437,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.
Expand Down Expand Up @@ -1815,7 +1828,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);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7479,6 +7479,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
editorRef={composerEditorRef}
richTextEnabled={settings.composerRichTextEnabled}
literalText={isLiteralPendingAnswer}
isPathQueryActive={() =>
resolveActiveComposerTrigger().trigger?.kind === "path"
}
value={
isComposerApprovalState
? ""
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,22 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
};
}

/**
* 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(
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;
}

/** Caret and trigger after replacing composer text and continuing at the end. */
export function composerStateAtPromptEnd(
text: string,
Expand Down
90 changes: 89 additions & 1 deletion apps/web/src/composer-rich-text-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -942,6 +942,94 @@ 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, 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, 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);
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("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);
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("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,
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(
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,10 +534,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un
return json;
}

/**
* Document content for a prompt. `literalLength` keeps that many leading
* characters as plain text, for a paste that continues a path query: markers
* inside a path such as `src/__init__.py` must reach file search unstyled.
*/
export function buildTiptapContent(
value: string,
skillLabelFor: (name: string) => SkillMeta,
options?: { styling?: boolean; blocks?: boolean; literalText?: boolean },
options?: { styling?: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
): Record<string, unknown>[] {
// Editor answers are verbatim text, including Markdown and context-token sources.
if (options?.literalText) {
Expand All @@ -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));
Expand Down Expand Up @@ -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
Expand Down
Loading