diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 0df20323..a2b18728 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor"; +import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor"; const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, @@ -186,4 +186,31 @@ describe("MarkdownEditor", () => { left: 92, }); }); + + it("keeps a short mention menu on the same line when it fits below the caret", () => { + expect( + computeMentionMenuPosition( + { viewportTop: 160, viewportLeft: 120 }, + { offsetLeft: 0, offsetTop: 0, width: 320, height: 220 }, + { width: 188, height: 42 }, + ), + ).toEqual({ + top: 164, + left: 120, + }); + }); + + it("keeps mention queries active across spaces", () => { + expect(findMentionMatch("Ping @Paperclip App", "Ping @Paperclip App".length)).toEqual({ + trigger: "mention", + marker: "@", + query: "Paperclip App", + atPos: 5, + endPos: "Ping @Paperclip App".length, + }); + }); + + it("still rejects slash commands once spaces are typed", () => { + expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull(); + }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index d528d493..62f4010d 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -108,9 +108,16 @@ interface MentionMenuViewport { height: number; } +interface MentionMenuSize { + width: number; + height: number; +} + const MENTION_MENU_WIDTH = 188; const MENTION_MENU_HEIGHT = 208; const MENTION_MENU_PADDING = 8; +const MENTION_MENU_ROW_HEIGHT = 34; +const MENTION_MENU_CHROME_HEIGHT = 8; const CODE_BLOCK_LANGUAGES: Record = { txt: "Text", @@ -140,19 +147,10 @@ const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = { Editor: CodeMirrorEditor, }; -function detectMention(container: HTMLElement): MentionState | null { - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; - - const range = sel.getRangeAt(0); - const textNode = range.startContainer; - if (textNode.nodeType !== Node.TEXT_NODE) return null; - if (!container.contains(textNode)) return null; - - const text = textNode.textContent ?? ""; - const offset = range.startOffset; - - // Walk backwards from cursor to find an autocomplete trigger. +export function findMentionMatch( + text: string, + offset: number, +): Pick | null { let atPos = -1; let trigger: MentionState["trigger"] | null = null; let marker: MentionState["marker"] | null = null; @@ -166,31 +164,54 @@ function detectMention(container: HTMLElement): MentionState | null { } break; } - if (/\s/.test(ch)) break; + if (ch === "\n" || ch === "\r") break; } if (atPos === -1) return null; - const query = text.slice(atPos + 1, offset); - - // Get position relative to container - const tempRange = document.createRange(); - tempRange.setStart(textNode, atPos); - tempRange.setEnd(textNode, atPos + 1); - const rect = tempRange.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); + if (trigger === "skill" && /\s/.test(query)) return null; return { trigger: trigger ?? "mention", marker: marker ?? "@", query, + atPos, + endPos: offset, + }; +} + +function detectMention(container: HTMLElement): MentionState | null { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; + + const range = sel.getRangeAt(0); + const textNode = range.startContainer; + if (textNode.nodeType !== Node.TEXT_NODE) return null; + if (!container.contains(textNode)) return null; + + const text = textNode.textContent ?? ""; + const offset = range.startOffset; + const match = findMentionMatch(text, offset); + if (!match) return null; + + // Get position relative to container + const tempRange = document.createRange(); + tempRange.setStart(textNode, match.atPos); + tempRange.setEnd(textNode, match.atPos + 1); + const rect = tempRange.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + return { + trigger: match.trigger, + marker: match.marker, + query: match.query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, viewportTop: rect.bottom, viewportLeft: rect.left, textNode: textNode as Text, - atPos, - endPos: offset, + atPos: match.atPos, + endPos: match.endPos, }; } @@ -216,11 +237,12 @@ function getMentionMenuViewport(): MentionMenuViewport { export function computeMentionMenuPosition( anchor: Pick, viewport: MentionMenuViewport, + menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT }, ) { const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING; - const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH; + const maxLeft = viewport.offsetLeft + viewport.width - menuSize.width; const minTop = viewport.offsetTop + MENTION_MENU_PADDING; - const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT; + const maxTop = viewport.offsetTop + viewport.height - menuSize.height; return { top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)), @@ -228,6 +250,17 @@ export function computeMentionMenuPosition( }; } +function getMentionMenuSize(optionCount: number): MentionMenuSize { + const visibleRows = Math.max(1, Math.min(optionCount, 8)); + return { + width: MENTION_MENU_WIDTH, + height: Math.min( + MENTION_MENU_HEIGHT, + visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT, + ), + }; +} + function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { if (!node || !container.contains(node)) return false; const el = node.nodeType === Node.ELEMENT_NODE @@ -650,7 +683,11 @@ export const MarkdownEditor = forwardRef }, []); const mentionMenuPosition = mentionState - ? computeMentionMenuPosition(mentionState, getMentionMenuViewport()) + ? computeMentionMenuPosition( + mentionState, + getMentionMenuViewport(), + getMentionMenuSize(filteredMentions.length), + ) : null; return ( @@ -673,8 +710,7 @@ export const MarkdownEditor = forwardRef // Mention keyboard handling if (mentionActive) { - // Space dismisses the popup (let the character be typed normally) - if (e.key === " ") { + if (e.key === " " && mentionStateRef.current?.trigger === "skill") { mentionStateRef.current = null; setMentionState(null); return;