import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { fetchAgents } from "../api"; import type { Agent } from "@fusion/core"; import { AgentAvatar } from "./AgentAvatar"; import "./CreateRoomModal.css"; export interface RoomDraft { /** Slack-style display name without leading "#" (e.g. "engineering"). Lowercase. */ name: string; /** Display form including the leading "#" (e.g. "#engineering"). */ displayName: string; /** Agent IDs selected as initial members. */ memberAgentIds: string[]; } export function validateRoomName(input: string, existingRoomNames: string[] = []): { ok: true; name: string } | { ok: false; error: string } { const raw = input.trim().replace(/^#/, ""); if (!raw) return { ok: false, error: "Room name is required." }; if (/[A-Z]/.test(raw)) return { ok: false, error: "Use lowercase letters only." }; const stripped = raw.toLowerCase(); if (stripped.length > 80) return { ok: false, error: "Room names can be at most 80 characters." }; if (!/^[a-z0-9_-]+$/.test(stripped)) return { ok: false, error: "Use lowercase letters, numbers, hyphens, or underscores only." }; if (/^[-_]|[-_]$/.test(stripped)) return { ok: false, error: "Room names cannot start or end with a hyphen or underscore." }; if (existingRoomNames.some((name) => name.toLowerCase() === stripped)) { return { ok: false, error: "A room with this name already exists." }; } return { ok: true, name: stripped }; } interface CreateRoomModalProps { isOpen: boolean; onClose: () => void; onCreate: (draft: RoomDraft) => void | Promise; projectId?: string; existingRoomNames?: string[]; } export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existingRoomNames = [] }: CreateRoomModalProps) { const [rawName, setRawName] = useState(""); const [agents, setAgents] = useState([]); const [search, setSearch] = useState(""); const [selectedAgentIds, setSelectedAgentIds] = useState([]); const [loadingAgents, setLoadingAgents] = useState(false); const [submitError, setSubmitError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const nameInputRef = useRef(null); const previousFocusRef = useRef(null); useEffect(() => { if (!isOpen) return; previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; setLoadingAgents(true); setSubmitError(null); fetchAgents(undefined, projectId) .then((result) => setAgents(result)) .catch(() => { setAgents([]); setSubmitError("Failed to load agents."); }) .finally(() => setLoadingAgents(false)); }, [isOpen, projectId]); useEffect(() => { if (!isOpen) { setRawName(""); setSearch(""); setSelectedAgentIds([]); setSubmitError(null); setIsSubmitting(false); return; } const frame = window.requestAnimationFrame(() => nameInputRef.current?.focus()); return () => window.cancelAnimationFrame(frame); }, [isOpen]); useEffect(() => { if (!isOpen) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [isOpen, onClose]); useEffect(() => { if (isOpen) return; previousFocusRef.current?.focus(); }, [isOpen]); const validation = useMemo(() => validateRoomName(rawName, existingRoomNames), [rawName, existingRoomNames]); const filteredAgents = useMemo(() => { const normalized = search.trim().toLowerCase(); if (!normalized) return agents; return agents.filter((agent) => agent.name.toLowerCase().includes(normalized)); }, [agents, search]); const selectedAgents = useMemo( () => agents.filter((agent) => selectedAgentIds.includes(agent.id)), [agents, selectedAgentIds], ); const canSubmit = validation.ok && selectedAgentIds.length > 0 && !isSubmitting && !loadingAgents; if (!isOpen) return null; const toggleAgent = (id: string) => { if (isSubmitting) return; setSelectedAgentIds((prev) => (prev.includes(id) ? prev.filter((current) => current !== id) : [...prev, id])); }; const handleSubmit = async () => { if (!validation.ok) { setSubmitError(validation.error); return; } if (selectedAgentIds.length === 0) { setSubmitError("Select at least one member."); return; } setSubmitError(null); setIsSubmitting(true); try { await onCreate({ name: validation.name, displayName: `#${validation.name}`, memberAgentIds: selectedAgentIds, }); onClose(); } catch (error) { setSubmitError(error instanceof Error ? error.message : "Failed to create room."); } finally { setIsSubmitting(false); } }; return createPortal(
event.target === event.currentTarget && onClose()}>
event.stopPropagation()}>

Create room

{ const normalized = event.target.value.replace(/^#/, "").replace(/\s+/g, "-").toLowerCase(); setRawName(normalized); }} />
{!validation.ok &&
{validation.error}
}
setSearch(event.target.value)} />
{selectedAgents.length > 0 && (
{selectedAgents.map((agent) => ( ))}
)}
{loadingAgents ? (
Loading agents...
) : filteredAgents.length === 0 ? (
{agents.length === 0 ? "No agents in this project yet." : "No agents match your search."}
) : ( filteredAgents.map((agent) => { const selected = selectedAgentIds.includes(agent.id); return ( ); }) )}
{submitError &&
{submitError}
}
, document.body, ); }