feat(agents): contact search insert in file editor for predefined agents

Add inline contact search above textarea in file editor (predefined
agents only). Search contacts and click to insert formatted snippet
at cursor position. Also make USER.md clickable in sidebar.
This commit is contained in:
viettranx committed 2026-03-10 18:46:44 +07:00
1 parent bf970c4ff2
commit fffdc0b082
5 files changed
+134 -8

No files matched your search

@@ -160,6 +160,7 @@ export function AgentFilesTab({
canEdit={canEdit}
onSave={handleSave}
headerActions={aiActions || undefined}
contactSearchEnabled={isPredefined}
/>
</div>
@@ -0,0 +1,89 @@
import { useState, useEffect, useRef } from "react";
import { Search, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { useContactSearch } from "../../hooks/use-contact-search";
import type { ChannelContact } from "@/types/contact";
/** Format a contact into a human-readable snippet for insertion into context files. */
function formatContactSnippet(c: ChannelContact): string {
const parts: string[] = [];
if (c.display_name) parts.push(c.display_name);
if (c.username) parts.push(`@${c.username}`);
parts.push(`${c.channel_type}:${c.sender_id}`);
return `- ${parts.join(" — ")}`;
}
interface ContactInsertSearchProps {
onInsert: (text: string) => void;
}
export function ContactInsertSearch({ onInsert }: ContactInsertSearchProps) {
const { t } = useTranslation("agents");
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const { contacts } = useContactSearch(search);
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleSelect = (c: ChannelContact) => {
onInsert(formatContactSnippet(c));
setSearch("");
setOpen(false);
};
return (
<div ref={containerRef} className="relative">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onFocus={() => search.length >= 2 && setOpen(true)}
placeholder={t("files.insertContact")}
className="h-8 w-full max-w-sm rounded-md border bg-transparent pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{open && search.length >= 2 && contacts.length > 0 && (
<div className="absolute left-0 z-50 mt-1 max-h-48 w-full max-w-sm overflow-y-auto rounded-md border bg-popover p-1 shadow-md">
{contacts.map((c) => (
<button
key={c.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(c)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
>
<UserPlus className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{c.display_name || c.sender_id}
</div>
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
{c.username && <span>@{c.username}</span>}
<Badge variant="outline" className="text-[9px] px-1 py-0">{c.channel_type}</Badge>
</div>
</div>
</button>
))}
</div>
)}
{open && search.length >= 2 && contacts.length === 0 && (
<div className="absolute left-0 z-50 mt-1 w-full max-w-sm rounded-md border bg-popover p-3 text-center text-xs text-muted-foreground shadow-md">
{t("instances.noContactsFound")}
</div>
)}
</div>
);
}
@@ -1,9 +1,10 @@
import type { ReactNode } from "react";
import { type ReactNode, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { FILE_DESCRIPTIONS } from "./file-utils";
import { ContactInsertSearch } from "./contact-insert-search";
interface FileEditorProps {
fileName: string | null;
@@ -15,6 +16,8 @@ interface FileEditorProps {
canEdit: boolean;
onSave: () => void;
headerActions?: ReactNode;
/** Show contact search box for inserting contact snippets into the editor. */
contactSearchEnabled?: boolean;
}
export function FileEditor({
@@ -27,8 +30,37 @@ export function FileEditor({
canEdit,
onSave,
headerActions,
contactSearchEnabled,
}: FileEditorProps) {
const { t } = useTranslation("agents");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleInsertText = useCallback(
(text: string) => {
const ta = textareaRef.current;
if (!ta) {
// Fallback: append at end
onChange(content + (content.endsWith("\n") ? "" : "\n") + text);
return;
}
const start = ta.selectionStart;
const end = ta.selectionEnd;
const before = content.slice(0, start);
const after = content.slice(end);
// Add newline prefix if cursor isn't at line start
const needNewline = before.length > 0 && !before.endsWith("\n");
const inserted = (needNewline ? "\n" : "") + text + "\n";
onChange(before + inserted + after);
// Restore cursor after inserted text
requestAnimationFrame(() => {
const pos = start + inserted.length;
ta.selectionStart = pos;
ta.selectionEnd = pos;
ta.focus();
});
},
[content, onChange],
);
if (!fileName) {
return (
@@ -64,12 +96,18 @@ export function FileEditor({
)}
</div>
</div>
{contactSearchEnabled && canEdit && (
<div className="mb-2">
<ContactInsertSearch onInsert={handleInsertText} />
</div>
)}
{loading && !content ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{t("files.loading")}
</div>
) : (
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => {
if (!canEdit) return;
@@ -54,14 +54,11 @@ export function FileSidebar({
<button
key={file.name}
type="button"
onClick={() => !userScoped && onSelect(file.name)}
disabled={userScoped}
onClick={() => onSelect(file.name)}
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors ${
userScoped
? "cursor-not-allowed opacity-50"
: active
? "bg-background text-foreground shadow-sm cursor-pointer"
: "text-foreground hover:bg-background/60 cursor-pointer"
active
? "bg-background text-foreground shadow-sm cursor-pointer"
: "text-foreground hover:bg-background/60 cursor-pointer"
}`}
>
<FileText className="mt-0.5 h-3.5 w-3.5 shrink-0 self-start" />
@@ -2,4 +2,5 @@ export { FileSidebar } from "./file-sidebar";
export { FileEditor } from "./file-editor";
export { RegenerateDialog } from "./regenerate-dialog";
export { OpenAgentEmptyState } from "./open-agent-empty-state";
export { ContactInsertSearch } from "./contact-insert-search";
export { FILE_DESCRIPTIONS } from "./file-utils";