feat: add mention-textarea-overlay and repo-mention-popover components
This commit is contained in:
parent
dbcdf04817
commit
4f76816de8
103
src/components/ai-elements/mention-textarea-overlay.tsx
Normal file
103
src/components/ai-elements/mention-textarea-overlay.tsx
Normal 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;
|
||||||
|
}
|
||||||
226
src/page/workspace/workplan/chat/repo-mention-popover.tsx
Normal file
226
src/page/workspace/workplan/chat/repo-mention-popover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user