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:
@@ -142,21 +142,27 @@
|
||||
font-size: var(--space-sm);
|
||||
}
|
||||
|
||||
.chat-thread--rooms-placeholder {
|
||||
.chat-room-thread-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
justify-content: 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 {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
.chat-room-thread-members {
|
||||
margin-left: auto;
|
||||
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);
|
||||
padding: var(--space-2xl);
|
||||
}
|
||||
|
||||
.chat-sidebar-search-container {
|
||||
@@ -1387,8 +1393,16 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-thread--rooms-placeholder {
|
||||
padding: var(--space-lg);
|
||||
text-align: center;
|
||||
.chat-room-thread-header {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.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,
|
||||
} from "lucide-react";
|
||||
import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat";
|
||||
import { useChatRooms } from "../hooks/useChatRooms";
|
||||
import { useViewportMode } from "./Header";
|
||||
import { fetchAgents, fetchDiscoveredSkills, fetchModels, updateGlobalSettings } from "../api";
|
||||
import type { Agent } from "@fusion/core";
|
||||
@@ -32,8 +33,9 @@ import type { ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { FileMentionPopup } from "./FileMentionPopup";
|
||||
import { CreateRoomModal, type RoomDraft } from "./CreateRoomModal";
|
||||
import { CreateRoomModal } from "./CreateRoomModal";
|
||||
import { useFileMention } from "../hooks/useFileMention";
|
||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
@@ -736,16 +738,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||
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 [createRoomOpen, setCreateRoomOpen] = useState(false);
|
||||
// FN-3807: replace draftRooms with backend-backed state.
|
||||
const [draftRooms, setDraftRooms] = useState<RoomDraft[]>([]);
|
||||
const [activeDraftRoomName, setActiveDraftRoomName] = useState<string | null>(null);
|
||||
const rooms = useChatRooms(projectId, addToast);
|
||||
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
|
||||
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
@@ -1807,30 +1802,30 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
Create room
|
||||
</button>
|
||||
</div>
|
||||
{draftRooms.length === 0 ? (
|
||||
{rooms.rooms.length === 0 ? (
|
||||
<div className="chat-sidebar-rooms-empty" data-testid="chat-sidebar-rooms-empty">
|
||||
No rooms yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-session-list chat-sidebar-list">
|
||||
{draftRooms.map((room) => {
|
||||
const memberCount = room.memberAgentIds.length;
|
||||
const isActive = activeDraftRoomName === room.name;
|
||||
{rooms.rooms.map((room) => {
|
||||
const isActive = rooms.activeRoom?.id === room.id;
|
||||
const memberCount = isActive ? rooms.activeRoomMembers.length : "—";
|
||||
return (
|
||||
<button
|
||||
key={room.name}
|
||||
key={room.id}
|
||||
type="button"
|
||||
className={`chat-room-item${isActive ? " chat-room-item--active" : ""}`}
|
||||
data-testid={`chat-room-item-${room.name}`}
|
||||
data-testid={`chat-room-item-${room.slug}`}
|
||||
onClick={() => {
|
||||
setActiveDraftRoomName(room.name);
|
||||
rooms.selectRoom(room.id);
|
||||
if (isMobile) {
|
||||
setSidebarVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="chat-room-item-name">{room.displayName}</span>
|
||||
<span className="chat-room-item-meta">{memberCount} member{memberCount === 1 ? "" : "s"}</span>
|
||||
<span className="chat-room-item-name">#{room.name}</span>
|
||||
<span className="chat-room-item-meta">{memberCount} {memberCount === 1 ? "member" : "members"}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -1918,13 +1913,101 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
|
||||
{/* Thread */}
|
||||
{chatScope === "rooms" ? (
|
||||
<div className="chat-thread chat-thread--rooms-placeholder" data-testid="chat-rooms-placeholder-pane">
|
||||
<div className="chat-rooms-placeholder-title">
|
||||
{activeDraftRoomName ? `#${activeDraftRoomName}` : "Select a room"}
|
||||
</div>
|
||||
<div className="chat-rooms-placeholder-copy">
|
||||
Coming soon — room messaging is being wired up (FN-3807).
|
||||
</div>
|
||||
<div className="chat-thread">
|
||||
{rooms.activeRoom ? (
|
||||
<>
|
||||
<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-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
|
||||
@@ -2259,10 +2342,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
isOpen={createRoomOpen}
|
||||
onClose={() => setCreateRoomOpen(false)}
|
||||
projectId={projectId}
|
||||
existingRoomNames={draftRooms.map((room) => room.name)}
|
||||
onCreate={(draft) => {
|
||||
setDraftRooms((prev) => [...prev, draft]);
|
||||
setActiveDraftRoomName(draft.name);
|
||||
existingRoomNames={rooms.rooms.map((room) => room.name)}
|
||||
onCreate={async (draft) => {
|
||||
await rooms.createRoom({ name: draft.name, memberAgentIds: draft.memberAgentIds });
|
||||
setCreateRoomOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -818,7 +818,7 @@
|
||||
.card-edit-desc-textarea:focus {
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.card-edit-desc-textarea::placeholder {
|
||||
@@ -892,7 +892,7 @@
|
||||
}
|
||||
|
||||
.card-delete-btn:hover {
|
||||
color: var(--color-error, #f85149);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.card-delete-btn:focus {
|
||||
@@ -1007,12 +1007,12 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
outline: none;
|
||||
background: var(--surface-hover);
|
||||
background: var(--surface-hover, color-mix(in srgb, var(--text) 6%, transparent));
|
||||
}
|
||||
|
||||
/* 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 { userEvent } from "@testing-library/user-event";
|
||||
import { ChatView } from "../ChatView";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||
import * as headerModule from "../Header";
|
||||
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||
return {
|
||||
...actual,
|
||||
Plus: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-plus"} {...props} />,
|
||||
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../hooks/useChat", () => ({
|
||||
useChat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../Header", () => ({
|
||||
useViewportMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useChat", () => ({ useChat: vi.fn() }));
|
||||
vi.mock("../../hooks/useChatRooms", () => ({ useChatRooms: vi.fn() }));
|
||||
vi.mock("../Header", () => ({ useViewportMode: vi.fn() }));
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
fetchAgents: vi.fn().mockResolvedValue([
|
||||
{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" },
|
||||
]),
|
||||
fetchAgents: vi.fn().mockResolvedValue([{ id: "agent-1", name: "Alpha", role: "executor", state: "idle", metadata: {}, createdAt: "", updatedAt: "" }]),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
}));
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||
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(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockUseChat.mockReturnValue({
|
||||
sessions: [],
|
||||
activeSession: null,
|
||||
sessionsLoading: false,
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
isStreaming: false,
|
||||
streamingText: "",
|
||||
streamingThinking: "",
|
||||
streamingToolCalls: [],
|
||||
selectSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
archiveSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
stopStreaming: vi.fn(),
|
||||
pendingMessage: "",
|
||||
clearPendingMessage: vi.fn(),
|
||||
loadMoreMessages: vi.fn(),
|
||||
hasMoreMessages: false,
|
||||
searchQuery: "",
|
||||
setSearchQuery: vi.fn(),
|
||||
filteredSessions: [],
|
||||
refreshSessions: vi.fn(),
|
||||
agentsMap: new Map(),
|
||||
sessions: [], activeSession: null, sessionsLoading: false, messages: [], messagesLoading: false,
|
||||
isStreaming: false, streamingText: "", streamingThinking: "", streamingToolCalls: [],
|
||||
selectSession: vi.fn(), createSession: vi.fn(), archiveSession: vi.fn(), deleteSession: vi.fn(),
|
||||
sendMessage: vi.fn(), stopStreaming: vi.fn(), pendingMessage: "", clearPendingMessage: vi.fn(),
|
||||
loadMoreMessages: vi.fn(), hasMoreMessages: false, searchQuery: "", setSearchQuery: vi.fn(),
|
||||
filteredSessions: [], refreshSessions: vi.fn(), agentsMap: new Map(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe("ChatView rooms", () => {
|
||||
it("renders create room flow and local draft list", async () => {
|
||||
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" />);
|
||||
|
||||
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"));
|
||||
await userEvent.type(screen.getByLabelText("Room name"), "engineering");
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Alpha/i }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Create room" });
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
|
||||
|
||||
const roomItem = await screen.findByTestId("chat-room-item-engineering");
|
||||
expect(within(roomItem).getByText("#engineering")).toBeInTheDocument();
|
||||
expect(within(roomItem).getByText("1 member")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-rooms-placeholder-pane")).toHaveTextContent("Coming soon — room messaging is being wired up (FN-3807)");
|
||||
expect(roomsMock.selectRoom).toHaveBeenCalledWith("room-1");
|
||||
expect(screen.getByTestId("chat-room-item-engineering")).toBeInTheDocument();
|
||||
expect(screen.getByText("hello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides sidebar on mobile when selecting a room", async () => {
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
it("create room modal submits via rooms hook", async () => {
|
||||
const roomsMock = buildRoomsMock({ rooms: [] });
|
||||
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-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 }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Create room" });
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Create room" }));
|
||||
|
||||
await userEvent.click(await screen.findByTestId("chat-room-item-engineering"));
|
||||
await userEvent.click(screen.getByRole("dialog", { name: "Create room" }).querySelector(".btn.btn-primary") as HTMLButtonElement);
|
||||
|
||||
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