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);
|
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 {
|
.chat-sidebar-rooms-empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -94,6 +106,59 @@
|
|||||||
font-size: var(--space-md);
|
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 {
|
.chat-sidebar-search-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1312,4 +1377,14 @@
|
|||||||
.chat-jump-to-latest {
|
.chat-jump-to-latest {
|
||||||
bottom: calc(var(--space-xl) * 4);
|
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 { ProviderIcon } from "./ProviderIcon";
|
||||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||||
import { FileMentionPopup } from "./FileMentionPopup";
|
import { FileMentionPopup } from "./FileMentionPopup";
|
||||||
|
import { CreateRoomModal, type RoomDraft } from "./CreateRoomModal";
|
||||||
import { useFileMention } from "../hooks/useFileMention";
|
import { useFileMention } from "../hooks/useFileMention";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
@@ -740,6 +741,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
* add room data models, APIs, and routing.
|
* add room data models, APIs, and routing.
|
||||||
*/
|
*/
|
||||||
const [chatScope, setChatScope] = useState<"direct" | "rooms">("direct");
|
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 [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
|
||||||
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
|
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
|
||||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||||
@@ -1722,7 +1727,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
{chatScope === "direct" ? (
|
{chatScope === "direct" ? (
|
||||||
<>
|
<>
|
||||||
{/* Search section */}
|
{/* Search section */}
|
||||||
<div className="chat-sidebar-search">
|
<div className="chat-sidebar-search-section">
|
||||||
<div className="chat-sidebar-search-wrapper">
|
<div className="chat-sidebar-search-wrapper">
|
||||||
<Search size={14} className="chat-sidebar-search-icon" />
|
<Search size={14} className="chat-sidebar-search-icon" />
|
||||||
<input
|
<input
|
||||||
@@ -1792,8 +1797,47 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
|
<div className="chat-sidebar-rooms" data-testid="chat-sidebar-rooms">
|
||||||
No rooms yet — room creation lands in a follow-up task.
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Mobile footer with New Chat action */}
|
{/* Mobile footer with New Chat action */}
|
||||||
@@ -1875,6 +1919,16 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thread */}
|
{/* 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
|
<div
|
||||||
className={`chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`}
|
className={`chat-thread${keyboardOpen && hasKeyboardViewportDisplacement ? " chat-thread--keyboard-active" : ""}`}
|
||||||
style={threadKeyboardStyle}
|
style={threadKeyboardStyle}
|
||||||
@@ -2201,6 +2255,19 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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) */}
|
{/* New Chat Dialog (rendered at root level) */}
|
||||||
{showNewDialog && (
|
{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