diff --git a/internal/http/agents_instances.go b/internal/http/agents_instances.go index 77a96899..659893dd 100644 --- a/internal/http/agents_instances.go +++ b/internal/http/agents_instances.go @@ -120,6 +120,12 @@ func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Req return } + // Ensure user profile exists (creates row if needed, e.g. admin adds contact manually). + if err := h.agents.EnsureUserProfile(r.Context(), id, instanceUserID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if err := h.agents.SetUserContextFile(r.Context(), id, instanceUserID, fileName, payload.Content); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 2cca6714..8a53c0c2 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -213,6 +213,7 @@ type AgentStore interface { // User-agent profiles + instances GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (isNew bool, effectiveWorkspace string, err error) + EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]UserInstanceData, error) UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) error diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go index 9baaa3c0..abafc779 100644 --- a/internal/store/pg/agents_context.go +++ b/internal/store/pg/agents_context.go @@ -111,6 +111,17 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid. return isInserted, ws, nil } +// EnsureUserProfile creates a minimal user_agent_profiles row if not exists. +// Used when admin manually adds a contact as an agent instance via the UI. +func (s *PGAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at) + VALUES ($1, $2, NOW(), NOW()) + ON CONFLICT (agent_id, user_id) DO NOTHING + `, agentID, userID) + return err +} + // --- User Instances --- func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) { diff --git a/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx index e6637e8b..0253cd52 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-instances-tab.tsx @@ -1,11 +1,12 @@ -import { useState, useEffect, useMemo } from "react"; -import { Save, Check, AlertCircle, Users, FileText } from "lucide-react"; +import { useState, useEffect, useMemo, useRef } from "react"; +import { Save, Check, AlertCircle, Users, FileText, Search, UserPlus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; import { useContactResolver } from "@/hooks/use-contact-resolver"; import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances"; +import { useContactSearch } from "../hooks/use-contact-search"; interface AgentInstancesTabProps { agentId: string; @@ -60,27 +61,36 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) { const instanceUserIDs = useMemo(() => instances.map((i) => i.user_id), [instances]); const { resolve } = useContactResolver(instanceUserIDs); + // Existing instance user_ids for deduplication + const existingIDs = useMemo(() => new Set(instances.map((i) => i.user_id)), [instances]); + + const handleContactSelect = (senderID: string) => { + setSelected(senderID); + }; + if (loading) { return
{t("instances.loadingInstances")}
; } - if (instances.length === 0) { - return ( -
- -

{t("instances.noInstances")}

-

{t("instances.noInstancesDesc")}

-
- ); - } - return (
{/* Instance list */}
-
- {instances.length} instance{instances.length !== 1 ? "s" : ""} -
+ + {instances.length > 0 && ( +
+ {instances.length} instance{instances.length !== 1 ? "s" : ""} +
+ )} + {instances.length === 0 && ( +
+ +

{t("instances.noInstances")}

+
+ )} {instances.map((inst) => ( ; onSelect: (id: string) => void }) { + const { t } = useTranslation("agents"); + const [search, setSearch] = useState(""); + const [open, setOpen] = useState(false); + const { contacts } = useContactSearch(search); + const containerRef = useRef(null); + + // Filter out contacts already in instances + const filtered = contacts.filter((c) => !existingIDs.has(c.sender_id)); + + // 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]); + + return ( +
+
+ + { setSearch(e.target.value); setOpen(true); }} + onFocus={() => search.length >= 2 && setOpen(true)} + placeholder={t("instances.searchContacts")} + className="h-8 w-full 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 && filtered.length > 0 && ( +
+ {filtered.map((c) => ( + + ))} +
+ )} + {open && search.length >= 2 && filtered.length === 0 && contacts.length === 0 && ( +
+ {t("instances.noContactsFound")} +
+ )} +
+ ); +} + function InstanceRow({ instance, isSelected, onClick, resolve }: { instance: UserInstance; isSelected: boolean; onClick: () => void; resolve: (id: string) => import("@/types/contact").ChannelContact | null }) { const lastSeen = instance.last_seen_at ? formatRelative(instance.last_seen_at) : null; const contact = resolve(instance.user_id); diff --git a/ui/web/src/pages/agents/hooks/use-contact-search.ts b/ui/web/src/pages/agents/hooks/use-contact-search.ts new file mode 100644 index 00000000..7eb5d452 --- /dev/null +++ b/ui/web/src/pages/agents/hooks/use-contact-search.ts @@ -0,0 +1,34 @@ +import { useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { ChannelContact } from "@/types/contact"; + +/** + * Searches contacts by name/username/sender_id with server-side filtering. + * Returns results as ComboboxOption-compatible items. + */ +export function useContactSearch(search: string) { + const http = useHttp(); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + // Simple debounce via timeout + useMemo(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.contacts.list({ search: debouncedSearch, limit: 20 }), + queryFn: async () => { + const params: Record = { limit: "20" }; + if (debouncedSearch) params.search = debouncedSearch; + const res = await http.get<{ contacts: ChannelContact[] }>("/v1/contacts", params); + return res.contacts ?? []; + }, + enabled: debouncedSearch.length >= 2, + staleTime: 30_000, + }); + + return { contacts: data ?? [], loading: isLoading }; +}