feat(FN-4849): complete Step 2 — route room thread reads and sends

Fusion-Task-Id: FN-4849
Fusion-Task-Lineage: 624f86c4-0b0f-4543-90d2-381f890da401
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 00:20:03 -07:00
committed by gsxdsm
parent 2adc9fa0da
commit b97709a3d4
2 changed files with 237 additions and 27 deletions

View File

@@ -1129,7 +1129,19 @@ export function QuickChatFAB({
}, [chatMode, selectedAgentId, targetModelSelection]);
const hasChatTarget = chatMode === "agent" ? Boolean(selectedAgentId) : Boolean(targetModelSelection);
const inputDisabled = !hasChatTarget || !activeSession;
const roomThreadActive = chatRoomsEnabled && Boolean(roomsState.activeRoom);
const displayedMessages = useMemo<ChatMessageInfo[]>(() => {
if (!roomThreadActive) {
return messages;
}
return roomsState.messages.map((message) => ({
id: message.id,
role: message.role,
content: message.content,
toolCalls: undefined,
}));
}, [messages, roomThreadActive, roomsState.messages]);
const inputDisabled = roomThreadActive ? false : (!hasChatTarget || !activeSession);
const hasPersistedAgentSessionSelection = useMemo(
() => Boolean(selectedAgentId) && sessions.some((session) => !session.modelProvider && !session.modelId && session.agentId === selectedAgentId),
[selectedAgentId, sessions],
@@ -1578,16 +1590,16 @@ export function QuickChatFAB({
}, [anchorToBottom]);
useLayoutEffect(() => {
const sessionId = activeSession?.id ?? null;
const threadId = roomThreadActive ? (roomsState.activeRoom?.id ?? null) : (activeSession?.id ?? null);
const previousState = previousOpenStateRef.current;
previousOpenStateRef.current = { isOpen, sessionId };
previousOpenStateRef.current = { isOpen, sessionId: threadId };
if (!isOpen || !sessionId) {
if (!isOpen || !threadId) {
return;
}
const openingNow = !previousState.isOpen && isOpen;
const sessionChangedWhileOpen = previousState.isOpen && previousState.sessionId !== sessionId;
const sessionChangedWhileOpen = previousState.isOpen && previousState.sessionId !== threadId;
if (!openingNow && !sessionChangedWhileOpen) {
return;
}
@@ -1596,7 +1608,7 @@ export function QuickChatFAB({
if (!messagesEl) return;
anchorToBottom(messagesEl);
}, [isOpen, activeSession?.id, anchorToBottom]);
}, [isOpen, activeSession?.id, anchorToBottom, roomThreadActive, roomsState.activeRoom?.id]);
useEffect(() => {
if (!isMobile || !isOpen || !activeSession) {
@@ -1633,7 +1645,7 @@ export function QuickChatFAB({
if (!isUserScrollingRef.current) {
scrollToBottom();
}
}, [messages, streamingText, streamingThinking, isStreaming, isOpen, scrollToBottom]);
}, [displayedMessages, streamingText, streamingThinking, isStreaming, isOpen, roomThreadActive, scrollToBottom]);
useEffect(() => {
if (!activeSession?.id) {
@@ -1716,7 +1728,7 @@ export function QuickChatFAB({
const showRoomGroups = chatRoomsEnabled && roomOptions.length > 0;
const activeSessionLabel = useMemo(() => {
if (showRoomGroups && roomsState.activeRoom) {
if (showRoomGroups && roomThreadActive && roomsState.activeRoom) {
return `#${roomsState.activeRoom.name}`;
}
const activeOption = sessionOptions.find((option) => option.id === activeSession?.id);
@@ -1727,7 +1739,7 @@ export function QuickChatFAB({
return "Loading sessions…";
}
return "Select a session";
}, [activeSession?.id, roomsState.activeRoom, sessionOptions, sessionsLoading, showRoomGroups]);
}, [activeSession?.id, roomThreadActive, roomsState.activeRoom, sessionOptions, sessionsLoading, showRoomGroups]);
useEffect(() => {
if (!isOpen) {
@@ -1763,7 +1775,7 @@ export function QuickChatFAB({
}, [sessionMenuOpen]);
const inputPlaceholder = useMemo(() => {
if (chatRoomsEnabled && roomsState.activeRoom) {
if (roomThreadActive && roomsState.activeRoom) {
return `Message #${roomsState.activeRoom.name}`;
}
if (chatMode === "agent") {
@@ -1777,7 +1789,7 @@ export function QuickChatFAB({
return `Message ${selectedModelTag}`;
}
return "Select a model to start chatting";
}, [chatMode, chatRoomsEnabled, roomsState.activeRoom?.name, selectedAgent, selectedModelTag]);
}, [chatMode, roomThreadActive, roomsState.activeRoom?.name, selectedAgent, selectedModelTag]);
const handleSessionSwitch = useCallback((sessionId: string) => {
const selectedSession = sessions.find((session) => session.id === sessionId);
@@ -1785,7 +1797,7 @@ export function QuickChatFAB({
return;
}
if (chatRoomsEnabled && roomsState.activeRoom) {
if (roomThreadActive) {
roomsState.selectRoom(null);
}
@@ -1808,7 +1820,7 @@ export function QuickChatFAB({
void selectSession(selectedSession);
setSessionMenuOpen(false);
}, [chatRoomsEnabled, markRead, roomsState, selectSession, sessions]);
}, [markRead, roomThreadActive, roomsState, selectSession, sessions]);
const handleRoomSwitch = useCallback((roomId: string) => {
const selectedRoom = roomsState.rooms.find((room) => room.id === roomId);
@@ -1926,9 +1938,14 @@ export function QuickChatFAB({
return;
}
if (roomThreadActive && attachmentsToSend.length > 0) {
addToast("Attachments are not supported in chat rooms yet", "warning");
focusComposerInput();
preserveComposerFocusRef.current = false;
return;
}
if (trimmed === "/clear" || trimmed === "/new") {
stopStreaming();
clearPendingMessage();
attachmentsToSend.forEach((attachment) => {
if (attachment.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
@@ -1937,13 +1954,20 @@ export function QuickChatFAB({
setPendingAttachments((previous) => previous.filter((attachment) => !attachmentsToSend.includes(attachment)));
try {
if (chatMode === "model") {
if (roomThreadActive && roomsState.activeRoom?.id) {
await roomsState.clearRoom(roomsState.activeRoom.id);
setHelpMessageVisible(false);
} else if (chatMode === "model") {
stopStreaming();
clearPendingMessage();
const parsed = parseModelSelection(resolvedModelSelection);
if (!parsed) {
return;
}
await startFreshSession(FN_AGENT_ID, parsed.modelProvider, parsed.modelId);
} else if (selectedAgentId) {
stopStreaming();
clearPendingMessage();
await startFreshSession(selectedAgentId);
}
} catch {
@@ -1957,7 +1981,11 @@ export function QuickChatFAB({
try {
setHelpMessageVisible(false);
await sendMessage(trimmed, attachmentsToSend.map((attachment) => attachment.file));
if (roomThreadActive) {
await roomsState.sendRoomMessage(trimmed);
} else {
await sendMessage(trimmed, attachmentsToSend.map((attachment) => attachment.file));
}
attachmentsToSend.forEach((attachment) => {
if (attachment.previewUrl) {
URL.revokeObjectURL(attachment.previewUrl);
@@ -1978,6 +2006,8 @@ export function QuickChatFAB({
inputDisabled,
messageInput,
resolvedModelSelection,
roomThreadActive,
roomsState,
selectedAgentId,
sendMessage,
startFreshSession,
@@ -2492,7 +2522,7 @@ export function QuickChatFAB({
<div className="quick-chat-panel-header">
<div className="quick-chat-panel-title-wrap">
<h3>Quick Chat</h3>
{chatRoomsEnabled && roomsState.activeRoom ? (
{roomThreadActive && roomsState.activeRoom ? (
<span className="quick-chat-model-tag" data-testid="quick-chat-room-tag" title={`#${roomsState.activeRoom.name}`}>
#{roomsState.activeRoom.name}
</span>
@@ -2553,7 +2583,7 @@ export function QuickChatFAB({
<input
type="hidden"
data-testid="quick-chat-session-dropdown"
value={showRoomGroups && roomsState.activeRoom ? "" : activeSession?.id ?? ""}
value={showRoomGroups && roomThreadActive ? "" : activeSession?.id ?? ""}
readOnly
/>
<button
@@ -2565,7 +2595,7 @@ export function QuickChatFAB({
data-testid="quick-chat-session-dropdown-trigger"
onClick={() => setSessionMenuOpen((current) => !current)}
>
{chatRoomsEnabled && roomsState.activeRoom ? (
{roomThreadActive && roomsState.activeRoom ? (
<Hash size={16} aria-hidden="true" />
) : activeSession?.modelProvider ? (
<ProviderIcon provider={activeSession.modelProvider} size="sm" />
@@ -2608,7 +2638,7 @@ export function QuickChatFAB({
</>
)}
{sessionOptions.map((sessionOption) => {
const isActiveSession = roomsState.activeRoom === null && activeSession?.id === sessionOption.id;
const isActiveSession = !roomThreadActive && activeSession?.id === sessionOption.id;
const session = sessions.find((item) => item.id === sessionOption.id);
const showUnreadDot = !isActiveSession && isUnread("direct", sessionOption.id, session?.lastMessageAt ?? session?.updatedAt);
return (
@@ -2717,9 +2747,9 @@ export function QuickChatFAB({
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages" onScroll={updateScrollState}>
{sessionsLoading ? (
<div className="quick-chat-panel-empty">Loading conversation</div>
) : isStreaming ? (
) : !roomThreadActive && isStreaming ? (
<>
{messages.map((message: ChatMessageInfo) => (
{displayedMessages.map((message: ChatMessageInfo) => (
<QuickChatMessageItem
key={message.id}
message={message}
@@ -2767,13 +2797,35 @@ export function QuickChatFAB({
)}
</div>
</>
) : messagesLoading ? (
) : roomThreadActive ? roomsState.messagesLoading ? (
<div className="quick-chat-panel-empty">Loading conversation</div>
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming && !helpMessageVisible ? (
) : displayedMessages.length === 0 && !helpMessageVisible ? (
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
) : (
<>
{messages.map((message: ChatMessageInfo) => (
{displayedMessages.map((message: ChatMessageInfo) => (
<QuickChatMessageItem
key={message.id}
message={message}
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
mentionAgentsByName={mentionAgentsByName}
roomContext={roomContext}
onToggleRender={toggleMessageRenderMode}
/>
))}
{helpMessageVisible && (
<div className="quick-chat-panel-message quick-chat-panel-message--received" data-testid="quick-chat-help-message">
{renderAssistantMessageContent("Available commands:\n- `/new` or `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help")}
</div>
)}
</>
) : messagesLoading ? (
<div className="quick-chat-panel-empty">Loading conversation</div>
) : displayedMessages.length === 0 && !streamingText && !streamingThinking && !isStreaming && !helpMessageVisible ? (
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
) : (
<>
{displayedMessages.map((message: ChatMessageInfo) => (
<QuickChatMessageItem
key={message.id}
message={message}
@@ -3018,7 +3070,7 @@ export function QuickChatFAB({
)}
</div>
)}
{pendingMessage && (
{!roomThreadActive && pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{`Queued: ${pendingPreview}`}</span>
<button

View File

@@ -1444,4 +1444,162 @@ describe("QuickChatFAB session-first UX", () => {
expect(openFile).toHaveBeenCalledWith("packages/dashboard/app/components/QuickChatFAB.tsx", { line: undefined, col: undefined });
});
describe("FN-4849 room switching", () => {
const room = {
id: "room-1",
name: "engineering",
slug: "engineering",
memberCount: 2,
createdAt: "2026-05-16T00:00:00.000Z",
updatedAt: "2026-05-16T00:00:03.000Z",
};
it("switching to a room renders room messages, not session messages", async () => {
const selectRoom = vi.fn();
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
mockUseChatRooms.mockReturnValue({
rooms: [room],
roomsLoading: false,
roomsError: null,
activeRoom: room,
activeRoomMembers: [],
messages: [
{ id: "room-msg-1", roomId: room.id, role: "assistant", content: "room msg 1", createdAt: "2026-05-16T00:00:01.000Z" },
{ id: "room-msg-2", roomId: room.id, role: "user", content: "room msg 2", createdAt: "2026-05-16T00:00:02.000Z" },
],
messagesLoading: false,
selectRoom,
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
clearRoom: vi.fn(),
refreshRooms: vi.fn(),
});
mockFetchChatMessages.mockResolvedValueOnce({
messages: [{ id: "session-msg", sessionId: "session-model", role: "assistant", content: "hello from session", createdAt: "2026-05-16T00:00:00.000Z" }],
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const messages = await screen.findByTestId("quick-chat-messages");
expect(messages).toHaveTextContent("room msg 1");
expect(messages).toHaveTextContent("room msg 2");
expect(messages).not.toHaveTextContent("hello from session");
expect(screen.getByTestId("quick-chat-session-dropdown-trigger")).toHaveTextContent("#engineering");
expect(screen.getByTestId("quick-chat-input")).toHaveAttribute("placeholder", "Message #engineering");
});
it("sending while in a room routes to sendRoomMessage, not sendMessage", async () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
mockUseChatRooms.mockReturnValue({
rooms: [room], roomsLoading: false, roomsError: null, activeRoom: room, activeRoomMembers: [], messages: [], messagesLoading: false,
selectRoom: vi.fn(), createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage, clearRoom: vi.fn(), refreshRooms: vi.fn(),
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "room dispatch" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(sendRoomMessage).toHaveBeenCalledWith("room dispatch");
});
expect(mockStreamChatResponse).not.toHaveBeenCalled();
});
it("/clear while in a room calls clearRoom, not startFreshSession", async () => {
const clearRoom = vi.fn().mockResolvedValue(undefined);
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
mockUseChatRooms.mockReturnValue({
rooms: [room], roomsLoading: false, roomsError: null, activeRoom: room, activeRoomMembers: [], messages: [], messagesLoading: false,
selectRoom: vi.fn(), createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage: vi.fn(), clearRoom, refreshRooms: vi.fn(),
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "/clear" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(clearRoom).toHaveBeenCalledWith("room-1");
});
expect(mockCreateChatSession).not.toHaveBeenCalled();
});
it("composer is enabled in a room even without an activeSession", async () => {
mockFetchResumeChatSession.mockRejectedValueOnce(new Error("resume failed"));
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
mockUseChatRooms.mockReturnValue({
rooms: [room], roomsLoading: false, roomsError: null, activeRoom: room, activeRoomMembers: [], messages: [], messagesLoading: false,
selectRoom: vi.fn(), createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage: vi.fn(), clearRoom: vi.fn(), refreshRooms: vi.fn(),
});
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
expect(await screen.findByTestId("quick-chat-input")).not.toBeDisabled();
});
it("switching back from a room to a session restores session messages", async () => {
const selectRoom = vi.fn();
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
mockUseChatRooms.mockReturnValue({
rooms: [room], roomsLoading: false, roomsError: null, activeRoom: room, activeRoomMembers: [],
messages: [{ id: "room-msg-1", roomId: room.id, role: "assistant", content: "room msg 1", createdAt: "2026-05-16T00:00:01.000Z" }],
messagesLoading: false,
selectRoom, createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage: vi.fn(), clearRoom: vi.fn(), refreshRooms: vi.fn(),
});
mockFetchChatMessages.mockResolvedValueOnce({
messages: [{ id: "session-msg", sessionId: "session-model", role: "assistant", content: "hello from session", createdAt: "2026-05-16T00:00:00.000Z" }],
});
const view = render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
fireEvent.click(await screen.findByTestId("quick-chat-session-dropdown-trigger"));
fireEvent.click(screen.getByTestId("quick-chat-session-option-session-model"));
await waitFor(() => {
expect(selectRoom).toHaveBeenCalledWith(null);
});
mockUseChatRooms.mockReturnValue({
rooms: [room], roomsLoading: false, roomsError: null, activeRoom: null, activeRoomMembers: [],
messages: [], messagesLoading: false,
selectRoom, createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage: vi.fn(), clearRoom: vi.fn(), refreshRooms: vi.fn(),
});
view.rerender(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
expect(await screen.findByTestId("quick-chat-messages")).toHaveTextContent("hello from session");
});
it("attachment-in-room is blocked with a warning toast", async () => {
const addToast = vi.fn();
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
mockUseAppSettings.mockReturnValue({ experimentalFeatures: { chatRooms: true } } as ReturnType<typeof useAppSettings>);
mockUseChatRooms.mockReturnValue({
rooms: [room], roomsLoading: false, roomsError: null, activeRoom: room, activeRoomMembers: [], messages: [], messagesLoading: false,
selectRoom: vi.fn(), createRoom: vi.fn(), deleteRoom: vi.fn(), sendRoomMessage, clearRoom: vi.fn(), refreshRooms: vi.fn(),
});
render(<QuickChatFAB addToast={addToast} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const attachmentInput = document.querySelector(".quick-chat-attachment-input") as HTMLInputElement | null;
const input = screen.getByTestId("quick-chat-input");
const file = new File(["hi"], "note.txt", { type: "text/plain" });
expect(attachmentInput).not.toBeNull();
fireEvent.change(attachmentInput!, { target: { files: [file] } });
fireEvent.change(input, { target: { value: "try send" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
expect(addToast).toHaveBeenCalledWith("Attachments are not supported in chat rooms yet", "warning");
expect(sendRoomMessage).not.toHaveBeenCalled();
});
});
});