Files
fusion/packages/dashboard/app/components/CreateRoomModal.tsx
Fusion dd295bdcdc feat(FN-3806): implement rooms create modal and chat sidebar with plugin ro
Adds a rooms feature to the chat sidebar including a new CreateRoomModal for creating chat rooms, with mobile selection handling and search empty-state copy fixes. Also introduces listTasksModifiedSince plugin contract and report plugin scaffold, replacing the removed dashboard roadmap backend.

Fusion-Task-Id: FN-3806
2026-05-09 10:31:05 -07:00

233 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
projectId?: string;
existingRoomNames?: string[];
}
export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existingRoomNames = [] }: CreateRoomModalProps) {
const [rawName, setRawName] = useState("");
const [agents, setAgents] = useState<Agent[]>([]);
const [search, setSearch] = useState("");
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
const [loadingAgents, setLoadingAgents] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(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(
<div className="modal-overlay open" onClick={(event) => event.target === event.currentTarget && onClose()}>
<div className="modal modal-lg create-room-modal" role="dialog" aria-modal="true" aria-label="Create room" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>Create room</h3>
<button type="button" className="modal-close" aria-label="Close" onClick={onClose}>×</button>
</div>
<div className="form-group create-room-modal-name-group">
<label htmlFor="create-room-name">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">Members</label>
<input
id="create-room-member-search"
className="input"
placeholder="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>
)}
<div className="create-room-modal-member-list" data-testid="create-room-member-list">
{loadingAgents ? (
<div className="create-room-modal-empty">Loading agents...</div>
) : filteredAgents.length === 0 ? (
<div className="create-room-modal-empty">
{agents.length === 0 ? "No agents in this project yet." : "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}>Cancel</button>
<button type="button" className="btn btn-primary" onClick={() => void handleSubmit()} disabled={!canSubmit}>
{isSubmitting ? "Creating..." : "Create room"}
</button>
</div>
</div>
</div>,
document.body,
);
}