mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 02:16:56 +00:00
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:
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";
|
||||
Reference in new issue
Block a user