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
233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
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,
|
||
);
|
||
}
|