/** * NewChatDialog Component * ADR-024: Unified chat model — agents are just participants * * Replaces the "New AI Chat" flow with a unified "New Chat" dialog * that allows selecting both human contacts and AI agents as participants. * Creates chat with type=chat (not ai_chat), agent starts responding after first message. */ import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/shared/utils/apiClient'; import { cn } from '@/shared/utils/cn'; import { Search, X, Bot, User, Users, Check, Loader2, MessageSquarePlus, } from 'lucide-react'; import type { AIAgent } from '../types'; // ────────────────────────────────────────────────────────────────────────────── // Types // ────────────────────────────────────────────────────────────────────────────── interface AvailableUser { id: number; name: string; email?: string; avatar_url?: string; managed_by_agent_table_id?: number | null; } export interface SelectedParticipant { /** Participant's user-account ID (works for both humans and agent-users) */ id: number; name: string; type: 'user' | 'agent'; icon?: string; description?: string; avatarUrl?: string; email?: string; } export interface NewChatDialogProps { /** Whether the dialog is visible */ isOpen: boolean; /** Agents from AIChatContext (already loaded) */ agents: AIAgent[]; /** Current space ID for fetching available users */ spaceId?: number; /** Called when the user confirms participant selection and wants to start a chat */ onStartChat: (participants: SelectedParticipant[]) => void; /** Called when the dialog is closed without starting a chat */ onClose: () => void; } // ────────────────────────────────────────────────────────────────────────────── // Component // ────────────────────────────────────────────────────────────────────────────── export function NewChatDialog({ isOpen, agents, spaceId, onStartChat, onClose, }: NewChatDialogProps) { const [search, setSearch] = useState(''); const [selected, setSelected] = useState([]); const searchRef = useRef(null); // Focus search on open useEffect(() => { if (isOpen) { setSearch(''); setSelected([]); const raf = requestAnimationFrame(() => searchRef.current?.focus()); return () => cancelAnimationFrame(raf); } }, [isOpen]); // Close on Escape useEffect(() => { if (!isOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [isOpen, onClose]); // ── Fetch available users (includes agent-users with managed_by_agent_table_id) ── const { data: availableUsers = [], isLoading: isLoadingUsers } = useQuery({ queryKey: ['new-chat-dialog-users', spaceId], queryFn: async () => { if (!spaceId) { // Fallback: fetch all users const response = await apiClient.get<{ success: boolean; data: AvailableUser[] }>('/users'); return response.success ? response.data : []; } const response = await apiClient.get<{ success: boolean; data: { users: AvailableUser[]; source: string; table_id: number | null }; }>(`/access/space/${spaceId}/available-users`); return response.success && response.data?.users ? response.data.users : []; }, enabled: isOpen, staleTime: 30_000, }); // ── Separate human users from agent-users ── const humanUsers = useMemo( () => availableUsers.filter(u => !u.managed_by_agent_table_id), [availableUsers], ); // Build a lookup: agent name → available user (for agent-user entries in the users list) const agentUserByName = useMemo(() => { const map = new Map(); availableUsers .filter(u => u.managed_by_agent_table_id) .forEach(u => map.set(u.name.toLowerCase(), u)); return map; }, [availableUsers]); // ── Filter by search ── const filteredAgents = useMemo(() => { if (!search.trim()) return agents; const q = search.toLowerCase(); return agents.filter( a => a.name.toLowerCase().includes(q) || a.description?.toLowerCase().includes(q), ); }, [agents, search]); const filteredHumans = useMemo(() => { if (!search.trim()) return humanUsers; const q = search.toLowerCase(); return humanUsers.filter( u => u.name.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q), ); }, [humanUsers, search]); // ── Selection helpers ── const isSelected = useCallback( (type: 'user' | 'agent', id: number) => selected.some(p => p.type === type && p.id === id), [selected], ); const toggleAgent = useCallback( (agent: AIAgent) => { // Find the agent's user-account ID from the available users list (by name match) const agentUser = agentUserByName.get(agent.name.toLowerCase()); if (!agentUser) { // If agent has no corresponding user-account, skip (shouldn't happen for properly configured agents) return; } const participant: SelectedParticipant = { id: agentUser.id, name: agent.name, type: 'agent', icon: agent.icon, description: agent.description, }; setSelected(prev => { const alreadySelected = prev.some(p => p.type === 'agent' && p.id === agentUser.id); if (alreadySelected) { return prev.filter(p => !(p.type === 'agent' && p.id === agentUser.id)); } return [...prev, participant]; }); }, [agentUserByName], ); const toggleUser = useCallback((user: AvailableUser) => { const participant: SelectedParticipant = { id: user.id, name: user.name, type: 'user', avatarUrl: user.avatar_url ?? undefined, email: user.email, }; setSelected(prev => { const alreadySelected = prev.some(p => p.type === 'user' && p.id === user.id); if (alreadySelected) { return prev.filter(p => !(p.type === 'user' && p.id === user.id)); } return [...prev, participant]; }); }, []); const removeParticipant = useCallback((type: 'user' | 'agent', id: number) => { setSelected(prev => prev.filter(p => !(p.type === type && p.id === id))); }, []); const handleStartChat = useCallback(() => { if (selected.length === 0) return; onStartChat(selected); onClose(); }, [selected, onStartChat, onClose]); if (!isOpen) return null; const isLoading = isLoadingUsers; const hasAgents = agents.length > 0; const hasHumans = humanUsers.length > 0; return ( <> {/* Backdrop */}