feat(FN-3879): add chat room management in ChatView

- Add useChatRooms hook with room lifecycle state and API integration
- Wire ChatView UI for room selection/creation and update room-focused tests
- Tokenize ChatView and TaskCard color usage to align with dashboard design tokens
- Update architecture docs and include FN-3879 changesets for published CLI package

Fusion-Task-Id: FN-3879
This commit is contained in:
Fusion
2026-05-09 12:34:22 -07:00
committed by gsxdsm
parent 003e51aef6
commit e7acd2788b
9 changed files with 767 additions and 112 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Add a compatibility self-heal for legacy task databases that report `schemaVersion >= 20` but are missing checkout lease columns (`checkedOutBy`, `checkedOutAt`, `checkoutNodeId`, `checkoutRunId`, `checkoutLeaseRenewedAt`, `checkoutLeaseEpoch`).
On initialization, missing lease columns are now added idempotently before version-guarded migrations, matching the earlier `nodeId` mitigation pattern and preventing `no such column: checkoutNodeId` crashes in task listing paths.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Wire chat rooms UI to backend. Creating a room now persists via /api/chat/rooms, the sidebar lists real rooms, room threads load history and stream new messages over chat:room:* SSE events, and the FN-3807 "Coming soon" placeholder is gone.

View File

@@ -265,6 +265,13 @@ Intentional exclusions from shared snapshots:
- `streamChatResponse()` must flush trailing buffered SSE data on EOF even without a final newline, so terminal `done`/`error` events are not dropped at chunk boundaries. - `streamChatResponse()` must flush trailing buffered SSE data on EOF even without a final newline, so terminal `done`/`error` events are not dropped at chunk boundaries.
- Chat generation ownership is isolated by `generationId` (`ChatManager.beginGeneration` + `ChatStreamManager` subscription filters + route preallocation), preventing stale generation terminal events from leaking into a newer active request. - Chat generation ownership is isolated by `generationId` (`ChatManager.beginGeneration` + `ChatStreamManager` subscription filters + route preallocation), preventing stale generation terminal events from leaking into a newer active request.
#### Chat Rooms (Dashboard)
- The Rooms tab in `packages/dashboard/app/components/ChatView.tsx` is wired through `useChatRooms` (`packages/dashboard/app/hooks/useChatRooms.ts`).
- `useChatRooms` owns room list fetch/sort, active-room selection, member+message hydration, room creation/deletion, and room message sends.
- The hook subscribes to `/api/events` and consumes `chat:room:created`, `chat:room:updated`, `chat:room:deleted`, `chat:room:member:added`, `chat:room:member:removed`, `chat:room:message:added`, `chat:room:message:updated`, and `chat:room:message:deleted` to keep UI state in sync.
- Room messages persist through `POST /api/chat/rooms/:id/messages`; UI does not optimistically insert and instead renders persisted messages from `chat:room:message:*` SSE events.
### Agent Companies ### Agent Companies
- Import/export utilities: `agent-companies-parser.ts`, `agent-companies-exporter.ts`, `agent-companies-types.ts` - Import/export utilities: `agent-companies-parser.ts`, `agent-companies-exporter.ts`, `agent-companies-types.ts`

View File

@@ -142,21 +142,27 @@
font-size: var(--space-sm); font-size: var(--space-sm);
} }
.chat-thread--rooms-placeholder { .chat-room-thread-header {
display: flex; display: flex;
flex-direction: column;
gap: var(--space-sm);
justify-content: center;
align-items: center; align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--border);
} }
.chat-rooms-placeholder-title { .chat-room-thread-members {
color: var(--text); margin-left: auto;
font-weight: 600; display: flex;
gap: var(--space-xs);
overflow-x: auto;
} }
.chat-rooms-placeholder-copy { .chat-room-empty-pane {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted); color: var(--text-muted);
padding: var(--space-2xl);
} }
.chat-sidebar-search-container { .chat-sidebar-search-container {
@@ -1387,8 +1393,16 @@
justify-content: center; justify-content: center;
} }
.chat-thread--rooms-placeholder { .chat-room-thread-header {
padding: var(--space-lg); padding: var(--space-sm) var(--space-md);
text-align: center; }
.chat-room-thread-header .btn-icon {
min-height: 36px;
min-width: 36px;
}
.chat-room-thread-members {
max-width: 50%;
} }
} }

View File

@@ -24,6 +24,7 @@ import {
Check, Check,
} from "lucide-react"; } from "lucide-react";
import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat"; import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat";
import { useChatRooms } from "../hooks/useChatRooms";
import { useViewportMode } from "./Header"; import { useViewportMode } from "./Header";
import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api"; import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api";
import type { Agent } from "@fusion/core"; import type { Agent } from "@fusion/core";
@@ -32,8 +33,9 @@ import type { ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { AgentMentionPopup } from "./AgentMentionPopup"; import { AgentMentionPopup } from "./AgentMentionPopup";
import { AgentAvatar } from "./AgentAvatar";
import { FileMentionPopup } from "./FileMentionPopup"; import { FileMentionPopup } from "./FileMentionPopup";
import { CreateRoomModal, type RoomDraft } from "./CreateRoomModal"; import { CreateRoomModal } 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";
@@ -736,16 +738,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [sidebarVisible, setSidebarVisible] = useState(true); const [sidebarVisible, setSidebarVisible] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH); const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
/**
* FN-3805: sidebar scope scaffold for Direct vs Rooms tabs.
* Rooms remains placeholder-only here; follow-up tasks FN-3806…FN-3813
* 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); const [createRoomOpen, setCreateRoomOpen] = useState(false);
// FN-3807: replace draftRooms with backend-backed state. const rooms = useChatRooms(projectId, addToast);
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);
@@ -1807,30 +1802,30 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
Create room Create room
</button> </button>
</div> </div>
{draftRooms.length === 0 ? ( {rooms.rooms.length === 0 ? (
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty"> <div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
No rooms yet. No rooms yet.
</div> </div>
) : ( ) : (
<div className="chat-session-list chat-sidebar-list"> <div className="chat-session-list chat-sidebar-list">
{draftRooms.map((room) => { {rooms.rooms.map((room) => {
const memberCount = room.memberAgentIds.length; const isActive = rooms.activeRoom?.id === room.id;
const isActive = activeDraftRoomName === room.name; const memberCount = isActive ? rooms.activeRoomMembers.length : "—";
return ( return (
<button <button
key={room.name} key={room.id}
type="button" type="button"
className={`chat-room-item${isActive ? " chat-room-item--active" : ""}`} className={`chat-room-item${isActive ? " chat-room-item--active" : ""}`}
data-testid={`chat-room-item-${room.name}`} data-testid={`chat-room-item-${room.slug}`}
onClick={() => { onClick={() => {
setActiveDraftRoomName(room.name); rooms.selectRoom(room.id);
if (isMobile) { if (isMobile) {
setSidebarVisible(false); setSidebarVisible(false);
} }
}} }}
> >
<span className="chat-room-item-name">{room.displayName}</span> <span className="chat-room-item-name">#{room.name}</span>
<span className="chat-room-item-meta">{memberCount} member{memberCount === 1 ? "" : "s"}</span> <span className="chat-room-item-meta">{memberCount} {memberCount === 1 ? "member" : "members"}</span>
</button> </button>
); );
})} })}
@@ -1918,13 +1913,101 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{/* Thread */} {/* Thread */}
{chatScope === "rooms" ? ( {chatScope === "rooms" ? (
<div className="chat-thread chat-thread--rooms-placeholder" data-testid="chat-rooms-placeholder-pane"> <div className="chat-thread">
<div className="chat-rooms-placeholder-title"> {rooms.activeRoom ? (
{activeDraftRoomName ? `#${activeDraftRoomName}` : "Select a room"} <>
<div className="chat-room-thread-header">
{isMobile && (
<button className="btn-icon" onClick={() => {
rooms.selectRoom(null);
setSidebarVisible(true);
}} data-testid="chat-back-btn">
<ChevronLeft size={16} />
</button>
)}
<div className="chat-thread-header-title">#{rooms.activeRoom.name}</div>
<div className="chat-room-thread-members">
{rooms.activeRoomMembers.map((member) => (
<AgentAvatar key={member.agentId} agent={agentsMap.get(member.agentId) ?? null} />
))}
</div> </div>
<div className="chat-rooms-placeholder-copy">
Coming soon room messaging is being wired up (FN-3807).
</div> </div>
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
{rooms.messagesLoading ? (
<div style={{ color: "var(--text-secondary)", fontSize: "13px" }}>Loading messages...</div>
) : rooms.messages.length === 0 ? (
<div style={{ color: "var(--text-secondary)", fontSize: "13px" }}>No messages yet. Start the conversation!</div>
) : (
rooms.messages.map((message) => {
const senderName = message.senderAgentId ? (agentsMap.get(message.senderAgentId)?.name ?? message.senderAgentId.slice(0, 30)) : "You";
const roomMessage: ChatMessageInfo = {
id: message.id,
sessionId: message.roomId,
role: message.role,
content: message.content,
thinkingOutput: message.thinkingOutput ?? undefined,
toolCalls: undefined,
fallbackInfo: undefined,
attachments: message.attachments,
createdAt: message.createdAt,
};
return (
<ChatMessageItem
key={message.id}
message={roomMessage}
forcePlain={showAllAsPlain}
agentName={senderName}
hideAssistantIdentity={false}
showAssistantModelTag={false}
activeModelTag={null}
activeModelProvider={null}
activeSessionId={rooms.activeRoom?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
/>
);
})
)}
<div ref={messagesEndRef} />
</div>
</>
) : (
<div className="chat-room-empty-pane" data-testid="chat-rooms-empty-pane">Select a room or create one</div>
)}
{rooms.activeRoom && (
<div className="chat-input-area">
<div className="chat-input-row">
<div className="chat-input-wrapper">
<textarea
ref={inputRef}
className="chat-input-textarea"
placeholder="Type a message..."
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
rows={1}
data-testid="chat-input"
/>
</div>
<button
type="button"
className="chat-input-send"
onClick={() => {
const trimmed = messageInput.trim();
if (!trimmed) return;
void rooms.sendRoomMessage(trimmed).then(() => {
setMessageInput("");
});
}}
disabled={!messageInput.trim()}
data-testid="chat-send-btn"
style={{ touchAction: "manipulation" }}
>
<Send size={16} />
</button>
</div>
</div>
)}
</div> </div>
) : ( ) : (
<div <div
@@ -2259,10 +2342,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
isOpen={createRoomOpen} isOpen={createRoomOpen}
onClose={() => setCreateRoomOpen(false)} onClose={() => setCreateRoomOpen(false)}
projectId={projectId} projectId={projectId}
existingRoomNames={draftRooms.map((room) => room.name)} existingRoomNames={rooms.rooms.map((room) => room.name)}
onCreate={(draft) => { onCreate={async (draft) => {
setDraftRooms((prev) => [...prev, draft]); await rooms.createRoom({ name: draft.name, memberAgentIds: draft.memberAgentIds });
setActiveDraftRoomName(draft.name);
setCreateRoomOpen(false); setCreateRoomOpen(false);
}} }}
/> />

View File

@@ -818,7 +818,7 @@
.card-edit-desc-textarea:focus { .card-edit-desc-textarea:focus {
border-color: var(--todo); border-color: var(--todo);
box-shadow: var(--focus-ring); box-shadow: var(--focus-ring);
border-radius: var(--radius); border-radius: var(--radius-sm);
} }
.card-edit-desc-textarea::placeholder { .card-edit-desc-textarea::placeholder {
@@ -892,7 +892,7 @@
} }
.card-delete-btn:hover { .card-delete-btn:hover {
color: var(--color-error, #f85149); color: var(--color-error);
} }
.card-delete-btn:focus { .card-delete-btn:focus {
@@ -1007,12 +1007,12 @@
} }
.card-send-back-menu-item:hover { .card-send-back-menu-item:hover {
background: var(--surface-hover); background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
} }
.card-send-back-menu-item:focus { .card-send-back-menu-item:focus {
outline: none; outline: none;
background: var(--surface-hover); background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
} }
/* Loading state during save */ /* Loading state during save */

View File

@@ -1,107 +1,135 @@
import { render, screen, waitFor, within } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView"; import { ChatView } from "../ChatView";
import * as useChatModule from "../../hooks/useChat"; import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import * as headerModule from "../Header"; import * as headerModule from "../Header";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
vi.mock("lucide-react", async (importOriginal) => { vi.mock("../../hooks/useChat", () => ({ useChat: vi.fn() }));
const actual = await importOriginal<typeof import("lucide-react")>(); vi.mock("../../hooks/useChatRooms", () => ({ useChatRooms: vi.fn() }));
return { vi.mock("../Header", () => ({ useViewportMode: vi.fn() }));
...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", () => ({ vi.mock("../../api", () => ({
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchAgents: vi.fn().mockResolvedValue([ fetchAgents: vi.fn().mockResolvedValue([{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" }]),
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
updateGlobalSettings: vi.fn(), updateGlobalSettings: vi.fn(),
searchFiles: vi.fn().mockResolvedValue({ files: [] }), searchFiles: vi.fn().mockResolvedValue({ files: [] }),
})); }));
const mockUseChat = vi.mocked(useChatModule.useChat); const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
const mockUseViewportMode = vi.mocked(headerModule.useViewportMode); const mockUseViewportMode = vi.mocked(headerModule.useViewportMode);
function buildRoomsMock(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoomsResult {
return {
rooms: [],
roomsLoading: false,
roomsError: null,
activeRoom: null,
activeRoomMembers: [],
messages: [],
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn().mockResolvedValue({ id: "room-2", name: "product", slug: "product", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "" }),
deleteRoom: vi.fn().mockResolvedValue(undefined),
sendRoomMessage: vi.fn().mockResolvedValue(undefined),
refreshRooms: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockUseViewportMode.mockReturnValue("desktop"); mockUseViewportMode.mockReturnValue("desktop");
mockUseChat.mockReturnValue({ mockUseChat.mockReturnValue({
sessions: [], sessions: [], activeSession: null, sessionsLoading: false, messages: [], messagesLoading: false,
activeSession: null, isStreaming: false, streamingText: "", streamingThinking: "", streamingToolCalls: [],
sessionsLoading: false, selectSession: vi.fn(), createSession: vi.fn(), archiveSession: vi.fn(), deleteSession: vi.fn(),
messages: [], sendMessage: vi.fn(), stopStreaming: vi.fn(), pendingMessage: "", clearPendingMessage: vi.fn(),
messagesLoading: false, loadMoreMessages: vi.fn(), hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(),
isStreaming: false, filteredSessions: [], refreshSessions: vi.fn(), agentsMap: new Map(),
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); } as any);
}); });
describe("ChatView rooms", () => { describe("ChatView rooms", () => {
it("renders create room flow and local draft list", async () => { it("lists rooms and selects one", async () => {
const roomsMock = buildRoomsMock({
rooms: [{ id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" }],
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
messages: [{ id: "msg-1", roomId: "room-1", role: "user", content: "hello", thinkingOutput: null, metadata: null, senderAgentId: null, mentions: [], createdAt: "2026-05-09T00:00:00.000Z" }],
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />); render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms")); await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.getByTestId("chat-create-room-btn")).toBeInTheDocument(); await userEvent.click(screen.getByTestId("chat-room-item-engineering"));
await userEvent.click(screen.getByTestId("chat-create-room-btn")); expect(roomsMock.selectRoom).toHaveBeenCalledWith("room-1");
await userEvent.type(screen.getByLabelText("Room name"), "engineering"); expect(screen.getByTestId("chat-room-item-engineering")).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i })); expect(screen.getByText("hello")).toBeInTheDocument();
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 () => { it("create room modal submits via rooms hook", async () => {
mockUseViewportMode.mockReturnValue("mobile"); const roomsMock = buildRoomsMock({ rooms: [] });
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />); render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms")); await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-create-room-btn")); await userEvent.click(screen.getByTestId("chat-create-room-btn"));
await userEvent.type(screen.getByLabelText("Room name"), "engineering"); await userEvent.type(screen.getByLabelText("Room name"), "product");
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i })); await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
const dialog = screen.getByRole("dialog", { name: "Create room" }); await userEvent.click(screen.getByRole("dialog", { name: "Create room" }).querySelector(".btn.btn-primary") as HTMLButtonElement);
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
await userEvent.click(await screen.findByTestId("chat-room-item-engineering"));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); expect(roomsMock.createRoom).toHaveBeenCalledWith({ name: "product", memberAgentIds: ["agent-1"] });
}); });
expect(screen.getByTestId("chat-rooms-placeholder-pane")).toHaveTextContent("#engineering"); });
it("mobile selection hides sidebar and supports back button", async () => {
mockUseViewportMode.mockReturnValue("mobile");
const roomsMock = buildRoomsMock({
rooms: [{ id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" }],
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-room-item-engineering"));
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
});
it("sending room message calls hook and clears input", async () => {
const roomsMock = buildRoomsMock({
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.type(screen.getByTestId("chat-input"), "hello room");
await userEvent.click(screen.getByTestId("chat-send-btn"));
await waitFor(() => expect(roomsMock.sendRoomMessage).toHaveBeenCalledWith("hello room"));
await waitFor(() => expect(screen.getByTestId("chat-input")).toHaveValue(""));
});
it("renders newly appended messages from hook updates", async () => {
const state = buildRoomsMock({
activeRoom: { id: "room-1", name: "engineering", slug: "engineering", description: null, projectId: "proj-1", createdBy: null, status: "active", createdAt: "", updatedAt: "2026-05-09T00:00:00.000Z" },
messages: [],
});
mockUseChatRooms.mockImplementation(() => state);
const { rerender } = render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
state.messages = [{ id: "msg-2", roomId: "room-1", role: "assistant", content: "reply", thinkingOutput: null, metadata: null, senderAgentId: "agent-1", mentions: [], createdAt: "2026-05-09T00:00:00.000Z" }];
rerender(<ChatView addToast={vi.fn()} projectId="proj-1" />);
expect(screen.getByText("reply")).toBeInTheDocument();
}); });
}); });

View File

@@ -0,0 +1,214 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatRoom, ChatRoomMember, ChatRoomMessage } from "@fusion/core";
import { useChatRooms } from "../useChatRooms";
import * as apiModule from "../../api";
import * as sseBusModule from "../../sse-bus";
vi.mock("../../api", () => ({
fetchChatRooms: vi.fn(),
createChatRoom: vi.fn(),
fetchChatRoomMembers: vi.fn(),
fetchChatRoomMessages: vi.fn(),
deleteChatRoom: vi.fn(),
postChatRoomMessage: vi.fn(),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(() => () => {}),
}));
vi.mock("../../utils/projectStorage", () => ({
getScopedItem: vi.fn(() => null),
setScopedItem: vi.fn(),
removeScopedItem: vi.fn(),
}));
const mockFetchChatRooms = vi.mocked(apiModule.fetchChatRooms);
const mockCreateChatRoom = vi.mocked(apiModule.createChatRoom);
const mockFetchChatRoomMembers = vi.mocked(apiModule.fetchChatRoomMembers);
const mockFetchChatRoomMessages = vi.mocked(apiModule.fetchChatRoomMessages);
const mockDeleteChatRoom = vi.mocked(apiModule.deleteChatRoom);
const mockPostChatRoomMessage = vi.mocked(apiModule.postChatRoomMessage);
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
function room(id: string, name: string, updatedAt: string): ChatRoom {
return {
id,
name,
slug: name,
description: null,
projectId: "proj-1",
createdBy: null,
status: "active",
createdAt: updatedAt,
updatedAt,
};
}
function roomMessage(id: string, roomId: string, content: string, createdAt = "2026-05-09T00:00:00.000Z"): ChatRoomMessage {
return {
id,
roomId,
role: "user",
content,
thinkingOutput: null,
metadata: null,
senderAgentId: null,
mentions: [],
createdAt,
};
}
function roomMember(roomId: string, agentId: string): ChatRoomMember {
return { roomId, agentId, role: "member", addedAt: "2026-05-09T00:00:00.000Z" };
}
describe("useChatRooms", () => {
let capturedEvents: Record<string, (event: MessageEvent) => void> = {};
let unsubscribe = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
capturedEvents = {};
unsubscribe = vi.fn();
mockSubscribeSse.mockImplementation((_url, sub) => {
capturedEvents = sub.events ?? {};
return unsubscribe;
});
mockFetchChatRooms.mockResolvedValue({ rooms: [] });
mockFetchChatRoomMembers.mockResolvedValue({ members: [] });
mockFetchChatRoomMessages.mockResolvedValue({ messages: [] });
mockCreateChatRoom.mockResolvedValue({ room: room("room-new", "new", "2026-05-09T01:00:00.000Z") });
mockDeleteChatRoom.mockResolvedValue({ success: true });
mockPostChatRoomMessage.mockResolvedValue({ message: roomMessage("msg-posted", "room-new", "posted") });
});
it("loads rooms on mount", async () => {
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [room("room-1", "one", "2026-05-09T01:00:00.000Z")] });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.roomsLoading).toBe(false));
expect(result.current.rooms).toHaveLength(1);
expect(mockFetchChatRooms).toHaveBeenCalledWith({}, "proj-1");
});
it("createRoom persists and loads active room members/messages", async () => {
const { result } = renderHook(() => useChatRooms("proj-1"));
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [roomMember("room-new", "agent-1")] });
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [roomMessage("msg-1", "room-new", "hello")] });
await waitFor(() => expect(result.current.roomsLoading).toBe(false));
await act(async () => {
await result.current.createRoom({ name: "new", memberAgentIds: ["agent-1"] });
});
expect(mockCreateChatRoom).toHaveBeenCalledWith({ name: "new", memberAgentIds: ["agent-1"] }, "proj-1");
expect(result.current.activeRoom?.id).toBe("room-new");
expect(result.current.activeRoomMembers).toHaveLength(1);
expect(result.current.messages).toHaveLength(1);
});
it("selectRoom loads messages and clears previous messages", async () => {
const first = room("room-1", "one", "2026-05-09T01:00:00.000Z");
const second = room("room-2", "two", "2026-05-09T02:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [first, second] });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBe(2));
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [roomMember("room-1", "agent-1")] });
mockFetchChatRoomMessages.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve({ messages: [roomMessage("msg-1", "room-1", "first")] }), 20)),
);
act(() => {
result.current.selectRoom("room-1");
});
await waitFor(() => expect(result.current.messages).toHaveLength(1));
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [roomMember("room-2", "agent-2")] });
mockFetchChatRoomMessages.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve({ messages: [roomMessage("msg-2", "room-2", "second")] }), 20)),
);
act(() => {
result.current.selectRoom("room-2");
});
expect(result.current.messages).toEqual([]);
await waitFor(() => expect(result.current.messages[0]?.id).toBe("msg-2"));
});
it("handles room message SSE for active and inactive rooms", async () => {
const older = room("room-1", "one", "2026-05-09T01:00:00.000Z");
const newer = room("room-2", "two", "2026-05-09T02:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [older, newer] });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBe(2));
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [] });
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
act(() => result.current.selectRoom("room-2"));
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-2"));
act(() => {
capturedEvents["chat:room:message:added"]?.({ data: JSON.stringify(roomMessage("msg-a", "room-2", "active")) } as MessageEvent);
});
expect(result.current.messages.map((message) => message.id)).toContain("msg-a");
act(() => {
capturedEvents["chat:room:message:added"]?.({ data: JSON.stringify(roomMessage("msg-b", "room-1", "inactive", "2026-05-09T03:00:00.000Z")) } as MessageEvent);
});
expect(result.current.rooms[0]?.id).toBe("room-1");
});
it("clears active room when active room is deleted via SSE", async () => {
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBe(1));
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [] });
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
act(() => result.current.selectRoom("room-1"));
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
act(() => {
capturedEvents["chat:room:deleted"]?.({ data: JSON.stringify({ id: "room-1" }) } as MessageEvent);
});
expect(result.current.activeRoom).toBeNull();
});
it("sendRoomMessage posts without optimistic insert", async () => {
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
const { result } = renderHook(() => useChatRooms("proj-1"));
await waitFor(() => expect(result.current.rooms.length).toBe(1));
mockFetchChatRoomMembers.mockResolvedValueOnce({ members: [] });
mockFetchChatRoomMessages.mockResolvedValueOnce({ messages: [] });
act(() => result.current.selectRoom("room-1"));
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
await act(async () => {
await result.current.sendRoomMessage("hello");
});
expect(mockPostChatRoomMessage).toHaveBeenCalledWith("room-1", { content: "hello" }, "proj-1");
expect(result.current.messages).toHaveLength(0);
act(() => {
capturedEvents["chat:room:message:added"]?.({ data: JSON.stringify(roomMessage("msg-1", "room-1", "hello")) } as MessageEvent);
});
expect(result.current.messages).toHaveLength(1);
});
it("tears down sse subscription on unmount", async () => {
const { unmount } = renderHook(() => useChatRooms("proj-1"));
unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,298 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChatAttachment, ChatRoom, ChatRoomMember, ChatRoomMessage } from "@fusion/core";
import {
createChatRoom,
deleteChatRoom,
fetchChatRoomMembers,
fetchChatRoomMessages,
fetchChatRooms,
postChatRoomMessage,
} from "../api";
import { subscribeSse } from "../sse-bus";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
const ACTIVE_ROOM_STORAGE_KEY = "fusion:chat-active-room";
export interface UseChatRoomsResult {
rooms: ChatRoom[];
roomsLoading: boolean;
roomsError: string | null;
activeRoom: ChatRoom | null;
activeRoomMembers: ChatRoomMember[];
messages: ChatRoomMessage[];
messagesLoading: boolean;
selectRoom: (roomId: string | null) => void;
createRoom: (input: { name: string; memberAgentIds: string[] }) => Promise<ChatRoom>;
deleteRoom: (roomId: string) => Promise<void>;
sendRoomMessage: (content: string, opts?: { attachments?: ChatAttachment[] }) => Promise<void>;
refreshRooms: () => Promise<void>;
}
function sortRooms(nextRooms: ChatRoom[]): ChatRoom[] {
return [...nextRooms].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
function upsertRoom(existingRooms: ChatRoom[], room: ChatRoom): ChatRoom[] {
const idx = existingRooms.findIndex((candidate) => candidate.id === room.id);
if (idx === -1) return sortRooms([room, ...existingRooms]);
const next = [...existingRooms];
next[idx] = room;
return sortRooms(next);
}
function parseSsePayload<T>(event: MessageEvent): T | null {
try {
return JSON.parse(event.data) as T;
} catch {
return null;
}
}
export function useChatRooms(
projectId?: string,
addToast?: (msg: string, type?: "success" | "error" | "warning") => void,
): UseChatRoomsResult {
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [roomsLoading, setRoomsLoading] = useState(true);
const [roomsError, setRoomsError] = useState<string | null>(null);
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null);
const [activeRoomMembers, setActiveRoomMembers] = useState<ChatRoomMember[]>([]);
const [messages, setMessages] = useState<ChatRoomMessage[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const roomsRef = useRef(rooms);
const activeRoomRef = useRef(activeRoom);
const projectContextVersionRef = useRef(0);
const previousProjectIdRef = useRef<string | undefined>(projectId);
roomsRef.current = rooms;
activeRoomRef.current = activeRoom;
if (previousProjectIdRef.current !== projectId) {
previousProjectIdRef.current = projectId;
projectContextVersionRef.current += 1;
}
const loadRoomData = useCallback(async (room: ChatRoom | null, clearFirst = true) => {
if (!room) {
setActiveRoomMembers([]);
setMessages([]);
setMessagesLoading(false);
return;
}
if (clearFirst) {
setMessages([]);
}
setMessagesLoading(true);
try {
const [membersData, messagesData] = await Promise.all([
fetchChatRoomMembers(room.id, projectId),
fetchChatRoomMessages(room.id, { limit: 100 }, projectId),
]);
setActiveRoomMembers(membersData.members);
setMessages(messagesData.messages);
} catch {
setActiveRoomMembers([]);
setMessages([]);
} finally {
setMessagesLoading(false);
}
}, [projectId]);
const refreshRooms = useCallback(async () => {
setRoomsLoading(true);
try {
const data = await fetchChatRooms({}, projectId);
const sortedRooms = sortRooms(data.rooms);
setRooms(sortedRooms);
setRoomsError(null);
const persistedRoomId = getScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
if (persistedRoomId) {
const persistedRoom = sortedRooms.find((room) => room.id === persistedRoomId) ?? null;
if (persistedRoom) {
setActiveRoom(persistedRoom);
void loadRoomData(persistedRoom, true);
} else {
removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load chat rooms";
setRoomsError(message);
addToast?.(message, "error");
} finally {
setRoomsLoading(false);
}
}, [addToast, loadRoomData, projectId]);
const selectRoom = useCallback((roomId: string | null) => {
if (!roomId) {
setActiveRoom(null);
removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
void loadRoomData(null, true);
return;
}
const room = roomsRef.current.find((candidate) => candidate.id === roomId) ?? null;
setActiveRoom(room);
if (room) {
setScopedItem(ACTIVE_ROOM_STORAGE_KEY, room.id, projectId);
void loadRoomData(room, true);
}
}, [loadRoomData, projectId]);
const createRoomLocal = useCallback(async (input: { name: string; memberAgentIds: string[] }) => {
const created = await createChatRoom({ name: input.name, memberAgentIds: input.memberAgentIds }, projectId);
const nextRoom = created.room;
setRooms((previous) => upsertRoom(previous, nextRoom));
setActiveRoom(nextRoom);
setScopedItem(ACTIVE_ROOM_STORAGE_KEY, nextRoom.id, projectId);
await loadRoomData(nextRoom, true);
return nextRoom;
}, [loadRoomData, projectId]);
const deleteRoomLocal = useCallback(async (roomId: string) => {
await deleteChatRoom(roomId, projectId);
setRooms((previous) => previous.filter((room) => room.id !== roomId));
if (activeRoomRef.current?.id === roomId) {
setActiveRoom(null);
setActiveRoomMembers([]);
setMessages([]);
removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
}
}, [projectId]);
const sendRoomMessage = useCallback(async (content: string, opts?: { attachments?: ChatAttachment[] }) => {
const roomId = activeRoomRef.current?.id;
if (!roomId) {
throw new Error("Select a room before sending a message");
}
await postChatRoomMessage(roomId, {
content,
...(opts?.attachments ? { attachments: opts.attachments } : {}),
}, projectId);
}, [projectId]);
useEffect(() => {
void refreshRooms();
}, [refreshRooms]);
useEffect(() => {
const contextVersionAtStart = projectContextVersionRef.current;
const eventsUrl = projectId ? `/api/events?projectId=${encodeURIComponent(projectId)}` : "/api/events";
return subscribeSse(eventsUrl, {
onReconnect: () => {
void refreshRooms();
},
events: {
"chat:room:created": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return;
const room = parseSsePayload<ChatRoom>(event);
if (!room) return;
setRooms((previous) => upsertRoom(previous, room));
},
"chat:room:updated": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return;
const room = parseSsePayload<ChatRoom>(event);
if (!room) return;
setRooms((previous) => upsertRoom(previous, room));
if (activeRoomRef.current?.id === room.id) {
setActiveRoom(room);
}
},
"chat:room:deleted": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return;
const payload = parseSsePayload<{ id: string }>(event);
if (!payload?.id) return;
setRooms((previous) => previous.filter((room) => room.id !== payload.id));
if (activeRoomRef.current?.id === payload.id) {
setActiveRoom(null);
setActiveRoomMembers([]);
setMessages([]);
removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
}
},
"chat:room:member:added": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return;
const payload = parseSsePayload<ChatRoomMember>(event);
if (!payload || activeRoomRef.current?.id !== payload.roomId) return;
setActiveRoomMembers((previous) => {
if (previous.some((member) => member.agentId === payload.agentId)) {
return previous;
}
return [...previous, payload];
});
},
"chat:room:member:removed": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return;
const payload = parseSsePayload<{ roomId: string; agentId: string }>(event);
if (!payload || activeRoomRef.current?.id !== payload.roomId) return;
setActiveRoomMembers((previous) => previous.filter((member) => member.agentId !== payload.agentId));
},
"chat:room:message:added": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return;
const message = parseSsePayload<ChatRoomMessage>(event);
if (!message) return;
setRooms((previous) => {
const room = previous.find((candidate) => candidate.id === message.roomId);
if (!room) return previous;
return upsertRoom(previous, { ...room, updatedAt: message.createdAt });
});
if (activeRoomRef.current?.id !== message.roomId) return;
setMessages((previous) => {
if (previous.some((candidate) => candidate.id === message.id)) {
return previous;
}
return [...previous, message];
});
},
"chat:room:message:updated": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return;
const message = parseSsePayload<ChatRoomMessage>(event);
if (!message || activeRoomRef.current?.id !== message.roomId) return;
setMessages((previous) => previous.map((candidate) => (candidate.id === message.id ? message : candidate)));
},
"chat:room:message:deleted": (event) => {
if (projectContextVersionRef.current !== contextVersionAtStart) return;
const payload = parseSsePayload<{ id: string }>(event);
if (!payload?.id) return;
setMessages((previous) => previous.filter((message) => message.id !== payload.id));
},
},
});
}, [projectId, refreshRooms]);
useEffect(() => {
if (!activeRoom) return;
if (!rooms.some((room) => room.id === activeRoom.id)) {
setActiveRoom(null);
setActiveRoomMembers([]);
setMessages([]);
removeScopedItem(ACTIVE_ROOM_STORAGE_KEY, projectId);
}
}, [activeRoom, projectId, rooms]);
return {
rooms,
roomsLoading,
roomsError,
activeRoom,
activeRoomMembers,
messages,
messagesLoading,
selectRoom,
createRoom: createRoomLocal,
deleteRoom: deleteRoomLocal,
sendRoomMessage,
refreshRooms,
};
}