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:
7
.changeset/FN-3879-checkout-columns-self-heal.md
Normal file
7
.changeset/FN-3879-checkout-columns-self-heal.md
Normal 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.
|
||||||
5
.changeset/fn-3879-chat-rooms-wired.md
Normal file
5
.changeset/fn-3879-chat-rooms-wired.md
Normal 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.
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<div className="chat-room-thread-header">
|
||||||
<div className="chat-rooms-placeholder-copy">
|
{isMobile && (
|
||||||
Coming soon — room messaging is being wired up (FN-3807).
|
<button className="btn-icon" onClick={() => {
|
||||||
</div>
|
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-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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
214
packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts
Normal file
214
packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
298
packages/dashboard/app/hooks/useChatRooms.ts
Normal file
298
packages/dashboard/app/hooks/useChatRooms.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user