From fffdc0b0827e270ddb0257dd97e1e160d9f724c5 Mon Sep 17 00:00:00 2001 From: viettranx Date: Tue, 10 Mar 2026 18:46:26 +0700 Subject: [PATCH] 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. --- .../agents/agent-detail/agent-files-tab.tsx | 1 + .../file-sections/contact-insert-search.tsx | 89 +++++++++++++++++++ .../file-sections/file-editor.tsx | 40 ++++++++- .../file-sections/file-sidebar.tsx | 11 +-- .../agent-detail/file-sections/index.ts | 1 + 5 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 ui/web/src/pages/agents/agent-detail/file-sections/contact-insert-search.tsx 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")}
) : (