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