feat(FN-3906): add Chat Rooms experimental feature with Settings toggle

Added an experimental Chat Rooms feature gated behind a settings toggle in the dashboard. The ChatView rooms UI is now conditionally rendered based on the experimental features setting, controlled via the SettingsModal. Included documentation updates and corresponding test coverage.

Fusion-Task-Id: FN-3906
This commit is contained in:
Fusion
2026-05-09 20:15:31 -07:00
committed by gsxdsm
parent 7e8541b92d
commit 772c9f6b24
8 changed files with 125 additions and 59 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Hide Chat Rooms behind the `chatRooms` experimental flag. By default, Chat now shows direct-chat-only UI; re-enable rooms via **Settings → Experimental Features → Chat Rooms**.

View File

@@ -99,6 +99,7 @@ Chat view provides project-scoped conversations with agents.
Chat Rooms are project-scoped group conversations for multiple agents. They are separate from one-on-one direct chat sessions.
- Chat Rooms are currently gated behind the `chatRooms` experimental feature flag. Enable it in **Settings → Experimental Features → Chat Rooms**.
- Use the **Direct / Rooms** toggle in the Chat sidebar to switch scopes. The selected scope is saved and restored the next time you open Chat.
- In **Rooms**, click **Create room** to open the room-creation modal.
- Room names follow strict validation: a leading `#` is removed automatically, names must be lowercase, up to 80 characters, use only `a-z`, `0-9`, `-`, or `_`, cannot start or end with `-`/`_`, and must be unique in the current project.

View File

@@ -1220,7 +1220,11 @@ function AppInner() {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<ChatView addToast={addToast} projectId={currentProject?.id} />
<ChatView
addToast={addToast}
projectId={currentProject?.id}
experimentalFeatures={experimentalFeatures}
/>
</Suspense>
</PageErrorBoundary>
);

View File

@@ -44,6 +44,7 @@ import { matchesAgentMentionFilter } from "./mentionMatching";
export interface ChatViewProps {
projectId?: string;
addToast: (msg: string, type?: "success" | "error" | "warning") => void;
experimentalFeatures?: Record<string, boolean>;
}
function formatRelativeTime(dateStr: string): string {
@@ -709,7 +710,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
);
});
export function ChatView({ projectId, addToast }: ChatViewProps) {
export function ChatView({ projectId, addToast, experimentalFeatures }: ChatViewProps) {
const {
activeSession,
sessionsLoading,
@@ -741,6 +742,9 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
const [chatScope, setChatScope] = useState<"direct" | "rooms">("direct");
const [createRoomOpen, setCreateRoomOpen] = useState(false);
const chatRoomsEnabled = experimentalFeatures?.chatRooms === true;
// Keep this hook unconditional to preserve hook ordering and test stability.
// Rooms UI and interactions are fully gated by `chatRoomsEnabled`.
const rooms = useChatRooms(projectId, addToast);
const [agentsMap, setAgentsMap] = useState<Map<string, Agent>>(new Map());
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkill[]>([]);
@@ -813,21 +817,29 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
useEffect(() => {
try {
const persistedScope = localStorage.getItem(CHAT_SCOPE_STORAGE_KEY);
if (persistedScope === "direct" || persistedScope === "rooms") {
setChatScope(persistedScope);
if (persistedScope === "direct") {
setChatScope("direct");
return;
}
if (persistedScope === "rooms" && chatRoomsEnabled) {
setChatScope("rooms");
}
} catch {
// Ignore storage errors.
}
}, []);
}, [chatRoomsEnabled]);
useEffect(() => {
if (!chatRoomsEnabled && chatScope === "rooms") {
setChatScope("direct");
return;
}
try {
localStorage.setItem(CHAT_SCOPE_STORAGE_KEY, chatScope);
} catch {
// Ignore storage errors.
}
}, [chatScope]);
}, [chatRoomsEnabled, chatScope]);
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
enabled: isMobile && !!activeSession,
@@ -1229,7 +1241,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
return;
}
if (chatScope === "rooms") {
if (chatRoomsEnabled && chatScope === "rooms") {
if (!rooms.activeRoom) {
return;
}
@@ -1239,7 +1251,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
}
handleSend();
}, [messageInput, chatScope, rooms, handleSend]);
}, [messageInput, chatRoomsEnabled, chatScope, rooms, handleSend]);
const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => {
@@ -1759,29 +1771,31 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
>
<div className="chat-sidebar-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
<button
type="button"
role="tab"
className={`chat-sidebar-scope-btn${chatScope === "direct" ? " chat-sidebar-scope-btn--active" : ""}`}
aria-selected={chatScope === "direct"}
data-testid="chat-sidebar-scope-direct"
onClick={() => setChatScope("direct")}
>
Direct
</button>
<button
type="button"
role="tab"
className={`chat-sidebar-scope-btn${chatScope === "rooms" ? " chat-sidebar-scope-btn--active" : ""}`}
aria-selected={chatScope === "rooms"}
data-testid="chat-sidebar-scope-rooms"
onClick={() => setChatScope("rooms")}
>
Rooms
</button>
</div>
{chatScope === "direct" ? (
{chatRoomsEnabled && (
<div className="chat-sidebar-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
<button
type="button"
role="tab"
className={`chat-sidebar-scope-btn${chatScope === "direct" ? " chat-sidebar-scope-btn--active" : ""}`}
aria-selected={chatScope === "direct"}
data-testid="chat-sidebar-scope-direct"
onClick={() => setChatScope("direct")}
>
Direct
</button>
<button
type="button"
role="tab"
className={`chat-sidebar-scope-btn${chatScope === "rooms" ? " chat-sidebar-scope-btn--active" : ""}`}
aria-selected={chatScope === "rooms"}
data-testid="chat-sidebar-scope-rooms"
onClick={() => setChatScope("rooms")}
>
Rooms
</button>
</div>
)}
{!chatRoomsEnabled || chatScope === "direct" ? (
<>
{/* Search section */}
<div className="chat-sidebar-search-container">
@@ -1999,7 +2013,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
)}
{confirmDeleteRoomId && (
{chatRoomsEnabled && confirmDeleteRoomId && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDeleteRoomId(null)}>
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>Delete Room?</h3>
@@ -2030,7 +2044,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
)}
{/* Thread */}
{chatScope === "rooms" ? (
{chatRoomsEnabled && chatScope === "rooms" ? (
<div className="chat-thread">
{rooms.activeRoom ? (
<>
@@ -2460,16 +2474,18 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</div>
)}
<CreateRoomModal
isOpen={createRoomOpen}
onClose={() => setCreateRoomOpen(false)}
projectId={projectId}
existingRoomNames={rooms.rooms.map((room) => room.name)}
onCreate={async (draft) => {
await rooms.createRoom({ name: draft.name, memberAgentIds: draft.memberAgentIds });
setCreateRoomOpen(false);
}}
/>
{chatRoomsEnabled && (
<CreateRoomModal
isOpen={createRoomOpen}
onClose={() => setCreateRoomOpen(false)}
projectId={projectId}
existingRoomNames={rooms.rooms.map((room) => room.name)}
onCreate={async (draft) => {
await rooms.createRoom({ name: draft.name, memberAgentIds: draft.memberAgentIds });
setCreateRoomOpen(false);
}}
/>
)}
{/* New Chat Dialog (rendered at root level) */}
{showNewDialog && (

View File

@@ -280,6 +280,7 @@ const KNOWN_EXPERIMENTAL_FEATURES: Record<string, string> = {
todoView: "Todo List",
researchView: "Research View",
evalsView: "Evals View",
chatRooms: "Chat Rooms",
agentOnboarding: "Planning-style Agent Onboarding",
};

View File

@@ -67,7 +67,7 @@ describe("ChatView rooms", () => {
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-room-item-engineering"));
@@ -80,7 +80,7 @@ describe("ChatView rooms", () => {
const roomsMock = buildRoomsMock({ rooms: [] });
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
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"), "product");
@@ -100,7 +100,7 @@ describe("ChatView rooms", () => {
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-room-item-engineering"));
@@ -113,7 +113,7 @@ describe("ChatView rooms", () => {
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
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"));
@@ -128,7 +128,7 @@ describe("ChatView rooms", () => {
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.type(screen.getByTestId("chat-input"), " hello room from enter ");
await userEvent.keyboard("{Enter}");
@@ -144,7 +144,7 @@ describe("ChatView rooms", () => {
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-room-delete-engineering"));
@@ -158,7 +158,7 @@ describe("ChatView rooms", () => {
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-room-delete-engineering"));
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
@@ -173,7 +173,7 @@ describe("ChatView rooms", () => {
});
mockUseChatRooms.mockReturnValue(roomsMock);
render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-room-delete-engineering"));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
@@ -189,11 +189,11 @@ describe("ChatView rooms", () => {
});
mockUseChatRooms.mockImplementation(() => state);
const { rerender } = render(<ChatView addToast={vi.fn()} projectId="proj-1" />);
const { rerender } = render(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
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" />);
rerender(<ChatView addToast={vi.fn()} projectId="proj-1" experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByText("reply")).toBeInTheDocument();
});

View File

@@ -2436,13 +2436,22 @@ describe("Direct/Rooms scope toggle", () => {
localStorage.clear();
});
it("hides rooms UI when chatRooms experimental flag is off", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
expect(screen.queryByTestId("chat-sidebar-scope-rooms")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-sidebar-rooms")).not.toBeInTheDocument();
});
it("defaults to Direct with sidebar list visible", () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-sidebar-scope-direct")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "false");
@@ -2450,13 +2459,23 @@ describe("Direct/Rooms scope toggle", () => {
expect(screen.queryByTestId("chat-sidebar-rooms-empty")).toBeNull();
});
it("shows rooms UI when chatRooms experimental flag is on", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.getByTestId("chat-sidebar-rooms")).toBeInTheDocument();
});
it("shows rooms placeholder and hides direct search/list in Rooms scope", async () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
@@ -2473,7 +2492,7 @@ describe("Direct/Rooms scope toggle", () => {
filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
@@ -2483,17 +2502,27 @@ describe("Direct/Rooms scope toggle", () => {
expect(screen.getByTestId("chat-session-session-001")).toHaveClass("chat-session-item--active");
});
it("forces direct scope when localStorage persisted rooms but chatRooms is off", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
localStorage.setItem("fusion:chat-scope", "rooms");
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{}} />);
expect(screen.queryByTestId("chat-sidebar-scope-rooms")).not.toBeInTheDocument();
expect(screen.getByTestId("chat-search-input")).toBeInTheDocument();
});
it("persists scope in localStorage and restores Rooms on next mount", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const { unmount } = render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const { unmount } = render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(localStorage.getItem("fusion:chat-scope")).toBe("rooms");
unmount();
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("chat-sidebar-rooms-empty")).toBeInTheDocument();
@@ -3206,7 +3235,9 @@ describe("ChatView mobile behavior", () => {
await userEvent.click(screen.getByTestId("chat-jump-to-latest"));
expect(scrollTopValue).toBe(1000);
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
});
} finally {
restoreMatchMedia.mockRestore();
}

View File

@@ -1933,6 +1933,14 @@ describe("SettingsModal", () => {
expect(screen.getByLabelText("Evals View")).toBeInTheDocument();
});
it("shows chatRooms in the Experimental Features list", async () => {
renderModal();
await openExperimentalFeaturesSection();
expect(screen.getByLabelText("Chat Rooms")).toBeInTheDocument();
});
it("shows agentOnboarding in the Experimental Features list", async () => {
renderModal();