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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
104
packages/dashboard/app/components/CreateRoomModal.css
Normal file
104
packages/dashboard/app/components/CreateRoomModal.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
232
packages/dashboard/app/components/CreateRoomModal.tsx
Normal file
232
packages/dashboard/app/components/CreateRoomModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user