feat: add mention-textarea-overlay and repo-mention-popover components

This commit is contained in:
zhenyi 2026-05-30 15:08:27 +08:00
parent dbcdf04817
commit 4f76816de8
2 changed files with 329 additions and 0 deletions

View File

@ -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<HTMLTextAreaElement | null>;
}
/**
* 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<HTMLDivElement>(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 (
<div
ref={overlayRef}
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-0 overflow-hidden whitespace-pre-wrap break-words px-2.5 py-2 text-sm leading-relaxed [&_span]:align-baseline"
>
{segments.map((seg, i) => {
if (seg.type === "mention" && seg.mention) {
return (
<MentionChip
key={`${seg.mention.entityId}-${i}`}
entityType={seg.mention.entityType}
entityId={seg.mention.entityId}
entityLabel={seg.mention.entityLabel}
/>
);
}
// Render text as-is; the textarea underneath handles the same wrapping.
return <span key={i}>{seg.content}</span>;
})}
</div>
);
});
// ---------------------------------------------------------------------------
// 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;
}

View File

@ -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<RepoResponse[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const searchAbortRef = useRef<AbortController | null>(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 (
<div className="absolute bottom-full left-0 mb-2 w-80">
<div className="overflow-hidden rounded-lg border border-border/60 bg-popover shadow-lg ring-1 ring-foreground/5">
{/* Header */}
<div className="border-b border-border/50 px-3 py-2">
<span className="text-[11px] text-muted-foreground">
Repositories in{" "}
<span className="font-medium text-foreground/80">
{workspaceName}
</span>
</span>
</div>
{/* Results */}
<div className="max-h-56 overflow-y-auto p-1">
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-4 animate-spin text-muted-foreground/60" />
</div>
)}
{!loading && repos.length === 0 && (
<div className="py-6 text-center text-xs text-muted-foreground/60">
{mentionState.query
? `No repos matching "${mentionState.query}"`
: "No repositories found"}
</div>
)}
{!loading &&
repos.map((repo, i) => (
<button
key={repo.id}
type="button"
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors",
i === selectedIndex
? "bg-accent text-accent-foreground"
: "hover:bg-muted/50",
)}
onClick={() => handleSelect(repo)}
onMouseEnter={() => setSelectedIndex(i)}
>
<GitBranch className="size-4 shrink-0 text-emerald-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{repo.name}</p>
{repo.description && (
<p className="truncate text-[11px] text-muted-foreground">
{repo.description}
</p>
)}
</div>
{repo.is_archived && (
<span className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
archived
</span>
)}
</button>
))}
</div>
</div>
</div>
);
}