Keep Create Room member searches focused when deferred modal autofocus runs. - Preserve focus when it is already inside the Create Room modal. - Cover deferred autofocus with real member-search keyboard input. - Exercise picker search state transitions with user-level typing. Files changed: .../dashboard/app/components/CreateRoomModal.tsx | 14 +++++++-- .../components/__tests__/CreateRoomModal.test.tsx | 33 ++++++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-9121 Fusion-Task-Lineage: 088ddc4f-0e2f-4ba0-ba12-ffe396654e1f Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
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<void>;
|
||
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<Agent[]>([]);
|
||
const [search, setSearch] = useState("");
|
||
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
|
||
const [agentLoadPhase, setAgentLoadPhase] = useState<"idle" | "loading" | "loaded" | "failed">("idle");
|
||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||
const modalRef = useRef<HTMLDivElement>(null);
|
||
const previousFocusRef = useRef<HTMLElement | null>(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 (
|
||
<FloatingWindow
|
||
windowKey="create-room"
|
||
title={t("createRoom.title", "Create room")}
|
||
ariaLabel={t("createRoom.title", "Create room")}
|
||
onClose={onClose}
|
||
modal
|
||
hideHeader
|
||
dragHandleSelector=".modal-header"
|
||
className="floating-window--create-room"
|
||
defaultSize={{ width: 640, height: 640 }}
|
||
minSize={{ width: 360, height: 400 }}
|
||
persistGeometryKey="floating-window:create-room"
|
||
suspendGeometryPersistenceOnMobile
|
||
suspendGeometryPersistenceOnShortViewport
|
||
closeOnOutsidePointerDown
|
||
layer="utility"
|
||
>
|
||
<div ref={modalRef} className="modal create-room-modal">
|
||
<div className="modal-header">
|
||
<h3>{t("createRoom.title", "Create room")}</h3>
|
||
<button type="button" className="modal-close" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>
|
||
</div>
|
||
|
||
<div className="form-group create-room-modal-name-group">
|
||
<label htmlFor="create-room-name">{t("createRoom.nameLabel", "Room name")}</label>
|
||
<div className="create-room-modal-name-field">
|
||
<span aria-hidden="true" className="create-room-modal-name-hash">#</span>
|
||
<input
|
||
ref={nameInputRef}
|
||
id="create-room-name"
|
||
className="input"
|
||
value={rawName}
|
||
disabled={isSubmitting}
|
||
onChange={(event) => {
|
||
const normalized = event.target.value.replace(/^#/, "").replace(/\s+/g, "-").toLowerCase();
|
||
setRawName(normalized);
|
||
}}
|
||
/>
|
||
</div>
|
||
{!validation.ok && <div className="form-error">{validation.error}</div>}
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="create-room-member-search">{t("createRoom.members", "Members")}</label>
|
||
<input
|
||
id="create-room-member-search"
|
||
className="input"
|
||
placeholder={t("createRoom.searchAgents", "Search agents")}
|
||
value={search}
|
||
disabled={isSubmitting}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{selectedAgents.length > 0 && (
|
||
<div className="create-room-modal-selected" data-testid="create-room-selected-chips">
|
||
{selectedAgents.map((agent) => (
|
||
<button
|
||
key={agent.id}
|
||
type="button"
|
||
className="btn btn-sm create-room-modal-chip"
|
||
onClick={() => toggleAgent(agent.id)}
|
||
disabled={isSubmitting}
|
||
>
|
||
{agent.name} ×
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/*
|
||
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.
|
||
*/}
|
||
<div className="create-room-modal-member-list" data-testid="create-room-member-list">
|
||
{/*
|
||
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" ? (
|
||
<div className="create-room-modal-empty"><LoadingSpinner label={t("createRoom.loadingAgents", "Loading agents...")} /></div>
|
||
) : agentLoadPhase === "failed" ? (
|
||
<div className="create-room-modal-empty">{t("createRoom.failedLoadAgents", "Failed to load agents.")}</div>
|
||
) : filteredAgents.length === 0 ? (
|
||
<div className="create-room-modal-empty">
|
||
{agents.length === 0 ? t("createRoom.noAgents", "No agents in this project yet.") : t("createRoom.noMatch", "No agents match your search.")}
|
||
</div>
|
||
) : (
|
||
filteredAgents.map((agent) => {
|
||
const selected = selectedAgentIds.includes(agent.id);
|
||
return (
|
||
<button
|
||
key={agent.id}
|
||
type="button"
|
||
className={`create-room-modal-member-row${selected ? " create-room-modal-member-row--selected" : ""}`}
|
||
onClick={() => toggleAgent(agent.id)}
|
||
disabled={isSubmitting}
|
||
>
|
||
<AgentAvatar agent={agent} size={20} />
|
||
<span>{agent.name}</span>
|
||
<span className="create-room-modal-member-role">{agent.role}</span>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{submitError && <div className="form-group"><div className="form-error">{submitError}</div></div>}
|
||
|
||
<div className="modal-actions">
|
||
<button type="button" className="btn" onClick={onClose} disabled={isSubmitting}>{t("actions.cancel", "Cancel")}</button>
|
||
<button type="button" className="btn btn-primary" onClick={() => void handleSubmit()} disabled={!canSubmit}>
|
||
{isSubmitting ? t("createRoom.creating", "Creating...") : t("createRoom.create", "Create room")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</FloatingWindow>
|
||
);
|
||
}
|