diff --git a/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx index f9c454fe..5762c82e 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-files-tab.tsx @@ -160,6 +160,7 @@ export function AgentFilesTab({ canEdit={canEdit} onSave={handleSave} headerActions={aiActions || undefined} + contactSearchEnabled={isPredefined} /> diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx new file mode 100644 index 00000000..f01963cd --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx @@ -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(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 ( +
+
+ + { 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" + /> +
+ {open && search.length >= 2 && contacts.length > 0 && ( +
+ {contacts.map((c) => ( + + ))} +
+ )} + {open && search.length >= 2 && contacts.length === 0 && ( +
+ {t("instances.noContactsFound")} +
+ )} +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx index 390953b3..50395091 100644 --- a/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx +++ b/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx @@ -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(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({ )} + {contactSearchEnabled && canEdit && ( +
+ +
+ )} {loading && !content ? (
{t("files.loading")}
) : (