import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { fetchAgents } from "../api"; import type { Agent } from "@fusion/core"; import { AgentAvatar } from "./AgentAvatar"; import { LoadingSpinner } from "./LoadingSpinner"; import { FloatingWindow } from "./FloatingWindow"; 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[] = [], t?: (key: string, defaultValue: string) => string): { ok: true; name: string } | { ok: false; error: string } { const raw = input.trim().replace(/^#/, ""); const getError = (key: string, defaultValue: string) => t ? t(key, defaultValue) : defaultValue; if (!raw) return { ok: false, error: getError("createRoom.nameRequired", "Room name is required.") }; if (/[A-Z]/.test(raw)) return { ok: false, error: getError("createRoom.lowercase", "Use lowercase letters only.") }; const stripped = raw.toLowerCase(); if (stripped.length > 80) return { ok: false, error: getError("createRoom.maxLength", "Room names can be at most 80 characters.") }; if (!/^[a-z0-9_-]+$/.test(stripped)) return { ok: false, error: getError("createRoom.validChars", "Use lowercase letters, numbers, hyphens, or underscores only.") }; if (/^[-_]|[-_]$/.test(stripped)) return { ok: false, error: getError("createRoom.noEdgeChars", "Room names cannot start or end with a hyphen or underscore.") }; if (existingRoomNames.some((name) => name.toLowerCase() === stripped)) { return { ok: false, error: getError("createRoom.duplicate", "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 { t } = useTranslation("app"); const [rawName, setRawName] = useState(""); const [agents, setAgents] = useState([]); const [search, setSearch] = useState(""); const [selectedAgentIds, setSelectedAgentIds] = useState([]); const [agentLoadPhase, setAgentLoadPhase] = useState<"idle" | "loading" | "loaded" | "failed">("idle"); const [submitError, setSubmitError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const nameInputRef = useRef(null); const modalRef = useRef(null); const previousFocusRef = useRef(null); const agentLoadEpochRef = useRef(0); /* FNXC:ModalTouchGeometry 2026-07-26-19:25: Create Room is a blocking child of Quick Chat. The shared utility layer now claims its fresh portal z-index on every mount, keeping this dialog above Chat without a bespoke overlay counter. */ useEffect(() => { const requestEpoch = ++agentLoadEpochRef.current; if (!isOpen) return; previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; setAgents([]); setAgentLoadPhase("loading"); setSubmitError(null); let cancelled = false; const isCurrentRequest = () => !cancelled && agentLoadEpochRef.current === requestEpoch; /* FNXC:CreateRoomModal 2026-08-16-08:11: The modal stays mounted while Chat toggles it and can switch projects mid-request. Only the latest open request may publish its roster, error, or phase; cleanup fences close, unmount, and project-change completions before they can overwrite the current picker. */ void fetchAgents(undefined, projectId) .then((result) => { if (!isCurrentRequest()) return; setAgents(result); setSelectedAgentIds((selected) => selected.filter((id) => result.some((agent) => agent.id === id))); setAgentLoadPhase("loaded"); }) .catch(() => { if (!isCurrentRequest()) return; setAgents([]); setAgentLoadPhase("failed"); }); return () => { cancelled = true; ++agentLoadEpochRef.current; }; }, [isOpen, projectId]); useEffect(() => { if (!isOpen) { setRawName(""); setSearch(""); setSelectedAgentIds([]); setAgents([]); setAgentLoadPhase("idle"); setSubmitError(null); setIsSubmitting(false); return; } /* FNXC:CreateRoomModal 2026-08-16-09:20: A late open-time animation frame must not yank focus from a field the user already selected inside this dialog. Otherwise member-search keystrokes can be swallowed by the room-name field. */ const frame = window.requestAnimationFrame(() => { const activeElement = document.activeElement; if (activeElement instanceof HTMLElement && modalRef.current?.contains(activeElement)) return; nameInputRef.current?.focus(); }); return () => window.cancelAnimationFrame(frame); }, [isOpen]); /* FNXC:ModalTouchGeometry 2026-07-26-19:25: FloatingWindow owns the modal focus boundary but not Escape dismissal. Retain this dialog's existing Escape and explicit prior-focus restoration behavior while moving its presentation. */ 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, t), [rawName, existingRoomNames, t]); 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 && agentLoadPhase === "loaded"; 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(t("createRoom.selectMember", "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 : t("createRoom.failedCreate", "Failed to create room.")); } finally { setIsSubmitting(false); } }; /* FNXC:ModalTouchGeometry 2026-07-26-19:25: This former portal dialog uses the shared FloatingWindow for clamped, persisted desktop and tablet-touch geometry. It explicitly opts into outside pointer-down because its former backdrop closed the dialog; FloatingWindow defaults that behavior off for durable utility windows. */ return (

{t("createRoom.title", "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) => ( ))}
)} {/* FNXC:ModalTouchGeometry 2026-07-26-19:25: The picker, not FloatingWindow's body, remains the nested scroll owner so long member lists preserve their independent scroll behavior inside the movable dialog. */}
{/* FNXC:CreateRoomModal 2026-08-16-08:11: Empty copy is meaningful only after the current request has settled. Until then show the loading status, and distinguish a failed request from an actually empty project roster. */} {agentLoadPhase === "idle" || agentLoadPhase === "loading" ? (
) : agentLoadPhase === "failed" ? (
{t("createRoom.failedLoadAgents", "Failed to load agents.")}
) : filteredAgents.length === 0 ? (
{agents.length === 0 ? t("createRoom.noAgents", "No agents in this project yet.") : t("createRoom.noMatch", "No agents match your search.")}
) : ( filteredAgents.map((agent) => { const selected = selectedAgentIds.includes(agent.id); return ( ); }) )}
{submitError &&
{submitError}
}
); }