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
This commit is contained in:
Fusion
2026-05-09 02:07:07 -07:00
committed by gsxdsm
parent 94a6fe41ef
commit dd295bdcdc
6 changed files with 694 additions and 3 deletions

View File

@@ -83,6 +83,18 @@
box-shadow: inset 0 calc(var(--btn-border-width) * -2) 0 var(--todo);
}
.chat-sidebar-rooms {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-sidebar-rooms-header {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
}
.chat-sidebar-rooms-empty {
flex: 1;
display: flex;
@@ -94,6 +106,59 @@
font-size: var(--space-md);
}
.chat-room-item {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
padding: var(--space-sm) var(--space-md);
text-align: left;
}
.chat-room-item:hover {
background: var(--card-hover);
}
.chat-room-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-room-item--active {
background: color-mix(in srgb, var(--todo) 12%, transparent);
}
.chat-room-item-name {
font-weight: 500;
}
.chat-room-item-meta {
color: var(--text-muted);
font-size: var(--space-sm);
}
.chat-thread--rooms-placeholder {
display: flex;
flex-direction: column;
gap: var(--space-sm);
justify-content: center;
align-items: center;
}
.chat-rooms-placeholder-title {
color: var(--text);
font-weight: 600;
}
.chat-rooms-placeholder-copy {
color: var(--text-muted);
}
.chat-sidebar-search-wrapper {
position: relative;
display: flex;
@@ -1312,4 +1377,14 @@
.chat-jump-to-latest {
bottom: calc(var(--space-xl) * 4);
}
.chat-sidebar-rooms-header .btn {
width: 100%;
justify-content: center;
}
.chat-thread--rooms-placeholder {
padding: var(--space-lg);
text-align: center;
}
}

View File

@@ -33,6 +33,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { AgentMentionPopup } from "./AgentMentionPopup";
import { FileMentionPopup } from "./FileMentionPopup";
import { CreateRoomModal, type RoomDraft } from "./CreateRoomModal";
import { useFileMention } from "../hooks/useFileMention";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -740,6 +741,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
* add room data models, APIs, and routing.
*/
const [chatScope, setChatScope] = useState<"direct" | "rooms">("direct");
const [createRoomOpen, setCreateRoomOpen] = useState(false);
// FN-3807: replace draftRooms with backend-backed state.
const [draftRooms, setDraftRooms] = useState<RoomDraft[]>([]);
const [activeDraftRoomName, setActiveDraftRoomName] = useState<string | null>(null);
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
@@ -1722,7 +1727,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{chatScope === "direct" ? (
<>
{/* Search section */}
<div className="chat-sidebar-search">
<div className="chat-sidebar-search-section">
<div className="chat-sidebar-search-wrapper">
<Search size={14} className="chat-sidebar-search-icon" />
<input
@@ -1792,8 +1797,47 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
</>
) : (
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
No rooms yet room creation lands in a follow-up task.
<div className="chat-sidebar-rooms" data-testid="chat-sidebar-rooms">
<div className="chat-sidebar-rooms-header">
<button
type="button"
className="btn btn-sm btn-primary"
data-testid="chat-create-room-btn"
onClick={() => setCreateRoomOpen(true)}
>
<Plus size={14} />
Create room
</button>
</div>
{draftRooms.length === 0 ? (
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
No rooms yet.
</div>
) : (
<div className="chat-session-list chat-sidebar-list">
{draftRooms.map((room) => {
const memberCount = room.memberAgentIds.length;
const isActive = activeDraftRoomName === room.name;
return (
<button
key={room.name}
type="button"
className={`chat-room-item${isActive ? " chat-room-item--active" : ""}`}
data-testid={`chat-room-item-${room.name}`}
onClick={() => {
setActiveDraftRoomName(room.name);
if (isMobile) {
setSidebarVisible(false);
}
}}
>
<span className="chat-room-item-name">{room.displayName}</span>
<span className="chat-room-item-meta">{memberCount} member{memberCount === 1 ? "" : "s"}</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* Mobile footer with New Chat action */}
@@ -1875,6 +1919,16 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
)}
{/* Thread */}
{chatScope === "rooms" ? (
<div className="chat-thread chat-thread--rooms-placeholder" data-testid="chat-rooms-placeholder-pane">
<div className="chat-rooms-placeholder-title">
{activeDraftRoomName ? `#${activeDraftRoomName}` : "Select a room"}
</div>
<div className="chat-rooms-placeholder-copy">
Coming soon room messaging is being wired up (FN-3807).
</div>
</div>
) : (
<div
className={`chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`}
style={threadKeyboardStyle}
@@ -2201,6 +2255,19 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
)}
</div>
)}
<CreateRoomModal
isOpen={createRoomOpen}
onClose={() => setCreateRoomOpen(false)}
projectId={projectId}
existingRoomNames={draftRooms.map((room) => room.name)}
onCreate={(draft) => {
setDraftRooms((prev) => [...prev, draft]);
setActiveDraftRoomName(draft.name);
setCreateRoomOpen(false);
}}
/>
{/* New Chat Dialog (rendered at root level) */}
{showNewDialog && (

View File

@@ -0,0 +1,104 @@
.create-room-modal {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.create-room-modal-name-group {
margin-bottom: 0;
}
.create-room-modal-name-field {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.create-room-modal-name-hash {
color: var(--text-muted);
font-family: var(--font-mono);
}
.create-room-modal-name-field .input {
flex: 1;
}
.create-room-modal-selected {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
padding: 0 var(--space-xl);
}
.create-room-modal-chip {
border-color: var(--border);
}
.create-room-modal-member-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
max-height: calc(var(--space-2xl) * 8);
overflow-y: auto;
padding: 0 var(--space-xl);
}
.create-room-modal-member-row {
display: flex;
align-items: center;
gap: var(--space-sm);
text-align: left;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-sm);
background: var(--surface);
color: var(--text);
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.create-room-modal-member-row:hover {
background: var(--card-hover);
}
.create-room-modal-member-row:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.create-room-modal-member-row--selected {
border-color: var(--todo);
background: color-mix(in srgb, var(--todo) 12%, transparent);
}
.create-room-modal-member-role {
margin-left: auto;
color: var(--text-muted);
font-size: var(--space-sm);
}
.create-room-modal-empty {
color: var(--text-muted);
padding: var(--space-md);
text-align: center;
}
@media (max-width: 768px) {
.create-room-modal {
width: 100%;
}
.create-room-modal-selected,
.create-room-modal-member-list {
padding: 0 var(--space-md);
}
.create-room-modal .modal-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.create-room-modal .modal-actions .btn {
width: 100%;
}
}

View File

@@ -0,0 +1,232 @@
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,
);
}

View File

@@ -0,0 +1,107 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView";
import * as useChatModule from "../../hooks/useChat";
import * as headerModule from "../Header";
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
Plus: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-plus"} {...props} />,
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
};
});
vi.mock("../../hooks/useChat", () => ({
useChat: vi.fn(),
}));
vi.mock("../Header", () => ({
useViewportMode: vi.fn(),
}));
vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchAgents: vi.fn().mockResolvedValue([
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
updateGlobalSettings: vi.fn(),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseViewportMode = vi.mocked(headerModule.useViewportMode);
beforeEach(() => {
vi.clearAllMocks();
mockUseViewportMode.mockReturnValue("desktop");
mockUseChat.mockReturnValue({
sessions: [],
activeSession: null,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn(),
archiveSession: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
stopStreaming: vi.fn(),
pendingMessage: "",
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [],
refreshSessions: vi.fn(),
agentsMap: new Map(),
} as any);
});
describe("ChatView rooms", () => {
it("renders create room flow and local draft list", async () => {
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.getByTestId("chat-create-room-btn")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
await userEvent.type(screen.getByLabelText("Room name"), "engineering");
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
const dialog = screen.getByRole("dialog", { name: "Create room" });
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
const roomItem = await screen.findByTestId("chat-room-item-engineering");
expect(within(roomItem).getByText("#engineering")).toBeInTheDocument();
expect(within(roomItem).getByText("1 member")).toBeInTheDocument();
expect(screen.getByTestId("chat-rooms-placeholder-pane")).toHaveTextContent("Coming soon — room messaging is being wired up (FN-3807)");
});
it("hides sidebar on mobile when selecting a room", async () => {
mockUseViewportMode.mockReturnValue("mobile");
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
await userEvent.type(screen.getByLabelText("Room name"), "engineering");
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
const dialog = screen.getByRole("dialog", { name: "Create room" });
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
await userEvent.click(await screen.findByTestId("chat-room-item-engineering"));
await waitFor(() => {
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
});
expect(screen.getByTestId("chat-rooms-placeholder-pane")).toHaveTextContent("#engineering");
});
});

View File

@@ -0,0 +1,106 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { userEvent } from "@testing-library/user-event";
import { CreateRoomModal, validateRoomName } from "../CreateRoomModal";
import * as apiModule from "../../api";
vi.mock("../../api", () => ({
fetchAgents: vi.fn(),
}));
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
describe("validateRoomName", () => {
it.each([
["engineering", true],
["#engineering", true],
["team-1", true],
["a", true],
["Engineering", false],
["team room", false],
["-team", false],
["team-", false],
["_team", false],
["team_", false],
["", false],
["team😀", false],
["a".repeat(81), false],
])("validates %s", (value, expectedOk) => {
expect(validateRoomName(value).ok).toBe(expectedOk);
});
it("handles duplicate names case-insensitively", () => {
expect(validateRoomName("Engineering", ["engineering"]).ok).toBe(false);
});
});
describe("CreateRoomModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchAgents.mockResolvedValue([
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
{ id: "agent-2", name: "Beta", role: "reviewer", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
] as any);
});
it("renders nothing when closed", () => {
const { container } = render(<CreateRoomModal isOpen={false} onClose={vi.fn()} onCreate={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it("requires valid name and member before submit", async () => {
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
const submit = await screen.findByRole("button", { name: "Create room" });
expect(submit).toBeDisabled();
await userEvent.type(screen.getByLabelText("Room name"), "engineering");
expect(submit).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: /Alpha/i }));
expect(submit).toBeEnabled();
});
it("submits selected draft payload", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={onCreate} />);
await userEvent.type(screen.getByLabelText("Room name"), "engineering");
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
await userEvent.click(screen.getByRole("button", { name: "Create room" }));
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
expect(onCreate).toHaveBeenCalledWith({ name: "engineering", displayName: "#engineering", memberAgentIds: ["agent-1"] });
});
it("closes on escape and overlay click", async () => {
const onClose = vi.fn();
render(<CreateRoomModal isOpen onClose={onClose} onCreate={vi.fn()} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(document.querySelector(".modal-overlay.open") as Element);
expect(onClose).toHaveBeenCalledTimes(2);
});
it("shows search-specific empty state copy", async () => {
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
await screen.findByRole("button", { name: /Alpha/i });
await userEvent.type(screen.getByLabelText("Members"), "zzz");
expect(screen.getByText("No agents match your search.")).toBeInTheDocument();
});
it("keeps open and shows error when create fails", async () => {
const onCreate = vi.fn().mockRejectedValue(new Error("boom"));
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={onCreate} />);
await userEvent.type(screen.getByLabelText("Room name"), "engineering");
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
await userEvent.click(screen.getByRole("button", { name: "Create room" }));
expect(await screen.findByText("boom")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});