From 4f76816de887591b8caae6cec61b57b18aa86240 Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Sat, 30 May 2026 15:08:27 +0800 Subject: [PATCH] feat: add mention-textarea-overlay and repo-mention-popover components --- .../ai-elements/mention-textarea-overlay.tsx | 103 ++++++++ .../workplan/chat/repo-mention-popover.tsx | 226 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 src/components/ai-elements/mention-textarea-overlay.tsx create mode 100644 src/page/workspace/workplan/chat/repo-mention-popover.tsx diff --git a/src/components/ai-elements/mention-textarea-overlay.tsx b/src/components/ai-elements/mention-textarea-overlay.tsx new file mode 100644 index 0000000..b72c330 --- /dev/null +++ b/src/components/ai-elements/mention-textarea-overlay.tsx @@ -0,0 +1,103 @@ +import { memo, useEffect, useRef } from "react"; +import { parseMentions } from "@/lib/ir/parser"; +import { MentionChip } from "@/lib/ir/mention-chip"; + +interface Props { + /** Raw text content (may contain @[type:id:label] mentions). */ + content: string; + /** Ref to the underlying textarea for scroll sync. */ + textareaRef: React.RefObject; +} + +/** + * Absolutely-positioned overlay that mirrors the textarea's text layout + * but renders @[mention] tokens as inline chips. + * + * Must be a sibling of the textarea inside a `relative` container. + * Uses `pointer-events: none` so clicks pass through to the textarea. + */ +export const MentionTextareaOverlay = memo(function MentionTextareaOverlay({ + content, + textareaRef, +}: Props) { + const overlayRef = useRef(null); + + // Sync scroll position from textarea → overlay. + useEffect(() => { + const ta = textareaRef.current; + const ov = overlayRef.current; + if (!ta || !ov) return; + + const sync = () => { + ov.scrollTop = ta.scrollTop; + ov.scrollLeft = ta.scrollLeft; + }; + + ta.addEventListener("scroll", sync, { passive: true }); + // Also sync on resize (auto-grow). + const ro = new ResizeObserver(sync); + ro.observe(ta); + + return () => { + ta.removeEventListener("scroll", sync); + ro.disconnect(); + }; + }, [textareaRef]); + + const segments = parseMentions(content); + + return ( +
+ {segments.map((seg, i) => { + if (seg.type === "mention" && seg.mention) { + return ( + + ); + } + // Render text as-is; the textarea underneath handles the same wrapping. + return {seg.content}; + })} +
+ ); +}); + +// --------------------------------------------------------------------------- +// Mention-aware backspace helper +// --------------------------------------------------------------------------- + +/** + * If `selectionStart` sits at the end of a complete `@[type:id:label]` token, + * return the { start, end } byte-offsets of that token so the caller can + * delete it as a whole. Otherwise returns `null`. + */ +export function mentionAtCursor( + text: string, + selectionStart: number, +): { start: number; end: number } | null { + if (selectionStart <= 0) return null; + + const before = text.slice(0, selectionStart); + + // Match the *last* complete mention that ends exactly at the cursor. + const re = /@\[([a-z_]+):([^:\]]+):([^\]]+)\]/g; + let last: { start: number; end: number } | null = null; + let m: RegExpExecArray | null; + + while ((m = re.exec(before)) !== null) { + const end = m.index + m[0].length; + if (end === selectionStart) { + last = { start: m.index, end }; + } + } + + return last; +} diff --git a/src/page/workspace/workplan/chat/repo-mention-popover.tsx b/src/page/workspace/workplan/chat/repo-mention-popover.tsx new file mode 100644 index 0000000..bfc2063 --- /dev/null +++ b/src/page/workspace/workplan/chat/repo-mention-popover.tsx @@ -0,0 +1,226 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { client } from "@/client"; +import { formatMention } from "@/lib/ir/parser"; +import { GitBranch, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { RepoResponse } from "@/client/models/repoResponse"; + +interface MentionState { + /** Position in text where the @ that started this mention is */ + startIndex: number; + /** The query text after @ (what user is typing to search) */ + query: string; +} + +interface Props { + workspaceName: string; + textValue: string; + onInsertMention: ( + mentionText: string, + replaceFrom: number, + replaceTo: number, + ) => void; +} + +/** + * Inline repo mention popover triggered by typing @ in the chat input. + * Searches repos in the current workspace and inserts IR mention syntax. + */ +export function RepoMentionPopover({ + workspaceName, + textValue, + onInsertMention, +}: Props) { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const searchAbortRef = useRef(null); + + // Detect @ mention trigger from text value. + const mentionState: MentionState | null = useMemo(() => { + if (!textValue) return null; + + // Find the last @ that could start a mention. + // Exclude @ that are part of existing @[type:id:label] syntax. + const atIndex = textValue.lastIndexOf("@"); + if (atIndex === -1) return null; + + const afterAt = textValue.slice(atIndex); + + // If it looks like a completed @[type:id:label], don't trigger. + if (/^@\[[a-z_]+:[^:\]]+:[^\]]+\]/.test(afterAt)) return null; + + // If there's already a complete @[...] before this one, check if + // this @ is inside or right after it. + const mentionRegex = /@\[[a-z_]+:[^:\]]+:[^\]]+\]/g; + let match; + while ((match = mentionRegex.exec(textValue)) !== null) { + const end = match.index + match[0].length; + if (atIndex < end && atIndex >= match.index) { + // This @ is inside an existing mention, skip. + return null; + } + } + + const query = textValue.slice(atIndex + 1); + + // Don't trigger if query contains space (mention is done). + if (query.includes(" ")) return null; + + // Don't trigger on empty query after @ (wait for user to type). + // But we show the popover to give immediate feedback. + return { startIndex: atIndex, query }; + }, [textValue]); + + // Fetch repos when query changes. + useEffect(() => { + if (!mentionState || !workspaceName) { + setRepos([]); + return; + } + + // Cancel previous search. + searchAbortRef.current?.abort(); + const controller = new AbortController(); + searchAbortRef.current = controller; + + setLoading(true); + + client + .gitListRepos(workspaceName, { + search: mentionState.query || undefined, + limit: 8, + }) + .then(({ data }) => { + if (!controller.signal.aborted) { + setRepos(data); + setSelectedIndex(0); + } + }) + .catch((err: unknown) => { + if (!controller.signal.aborted) { + console.error("Failed to search repos for mention:", err); + setRepos([]); + } + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [mentionState?.query, workspaceName]); + + const open = mentionState !== null; + + const handleSelect = useCallback( + (repo: RepoResponse) => { + if (!mentionState) return; + const mentionText = formatMention("repo", repo.name, repo.name) + " "; + const replaceTo = mentionState.startIndex + mentionState.query.length + 1; + onInsertMention(mentionText, mentionState.startIndex, replaceTo); + }, + [mentionState, onInsertMention], + ); + + // Keyboard navigation when popover is open. + // Use capture phase so we intercept before the textarea's own Enter handler + // (which would otherwise submit the form). + useEffect(() => { + if (!open || repos.length === 0) return; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + e.stopPropagation(); + setSelectedIndex((i) => Math.min(i + 1, repos.length - 1)); + break; + case "ArrowUp": + e.preventDefault(); + e.stopPropagation(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + if (repos[selectedIndex]) { + e.preventDefault(); + e.stopPropagation(); + handleSelect(repos[selectedIndex]); + } + break; + case "Escape": + // Let the user naturally dismiss by continuing to type. + break; + } + }; + + // Capture phase: fire before the textarea's own keydown handler. + document.addEventListener("keydown", handleKeyDown, true); + return () => document.removeEventListener("keydown", handleKeyDown, true); + }, [open, repos, selectedIndex, handleSelect]); + + if (!open) return null; + + return ( +
+
+ {/* Header */} +
+ + Repositories in{" "} + + {workspaceName} + + +
+ + {/* Results */} +
+ {loading && ( +
+ +
+ )} + + {!loading && repos.length === 0 && ( +
+ {mentionState.query + ? `No repos matching "${mentionState.query}"` + : "No repositories found"} +
+ )} + + {!loading && + repos.map((repo, i) => ( + + ))} +
+
+
+ ); +}