feat(FN-4659): add room title switcher dropdown

Adds a room title switcher dropdown to the chat view, with corresponding styles and a new rooms test file.

Fusion-Task-Id: FN-4659
This commit is contained in:
Fusion
2026-05-15 14:52:56 -07:00
committed by gsxdsm
parent 73d8cb92af
commit e50a4b5ad6
4 changed files with 223 additions and 1 deletions

View File

@@ -392,6 +392,85 @@
max-width: 100%;
}
.chat-room-switcher-menu {
position: relative;
min-width: 0;
}
.chat-room-switcher-trigger {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
padding: 0;
font: inherit;
line-height: normal;
cursor: pointer;
}
.chat-room-switcher-trigger:hover {
color: var(--text);
background: var(--card-hover);
}
.chat-room-switcher-trigger:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-room-switcher-trigger > svg {
color: var(--text-muted);
flex-shrink: 0;
}
.chat-room-switcher-dropdown {
position: absolute;
top: calc(100% + var(--space-xs));
left: 0;
min-width: calc(var(--space-xl) * 6);
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-lg);
z-index: 4;
max-height: calc(var(--space-xl) * 10);
overflow-y: auto;
}
.chat-room-switcher-option {
width: 100%;
display: flex;
align-items: center;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: left;
padding: var(--space-sm) var(--space-md);
line-height: normal;
}
.chat-room-switcher-option:hover {
background: var(--card-hover);
}
.chat-room-switcher-option:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-room-switcher-option--active {
background: color-mix(in srgb, var(--todo) 12%, transparent);
}
.chat-mobile-session-trigger {
width: 100%;
max-width: 100%;
@@ -1634,6 +1713,13 @@
width: 100%;
}
.chat-room-switcher-dropdown {
left: 0;
right: auto;
min-width: calc(var(--space-xl) * 5);
max-width: min(calc(var(--space-xl) * 10), calc(100vw - var(--space-2xl)));
}
.chat-thread-header-identity .chat-thread-header-title,
.chat-thread-header-identity .chat-model-tag {
overflow: hidden;

View File

@@ -932,6 +932,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState<Record<string, CopyFeedbackState>>({});
const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false);
const [roomSwitcherOpen, setRoomSwitcherOpen] = useState(false);
const { pushNav } = useNavigationHistoryContext();
// File mention state and hook
@@ -957,6 +958,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
const messagesEndRef = useRef<HTMLDivElement>(null);
const mobileSessionMenuRef = useRef<HTMLDivElement>(null);
const roomSwitcherRef = useRef<HTMLDivElement>(null);
const isUserScrollingRef = useRef(false);
const lastAnchoredThreadStateRef = useRef<{ threadId: string; loaded: boolean; hasMessages: boolean } | null>(null);
const previousChatScopeRef = useRef<"direct" | "rooms" | null>(null);
@@ -2141,12 +2143,42 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
};
}, [mobileSessionMenuOpen]);
useEffect(() => {
if (!roomSwitcherOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (roomSwitcherRef.current?.contains(event.target as Node)) {
return;
}
setRoomSwitcherOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setRoomSwitcherOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [roomSwitcherOpen]);
useEffect(() => {
if (!isMobile || chatScope !== "direct" || sidebarVisible) {
setMobileSessionMenuOpen(false);
}
}, [isMobile, chatScope, sidebarVisible]);
useEffect(() => {
setRoomSwitcherOpen(false);
}, [rooms.activeRoom?.id]);
const setCopyFeedback = useCallback((messageId: string, feedback: CopyFeedbackState) => {
const existingTimeout = copyFeedbackTimeoutsRef.current.get(messageId);
if (existingTimeout) {
@@ -2518,7 +2550,42 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
<ChevronLeft size={16} />
</button>
)}
<div className="chat-thread-header-title">#{rooms.activeRoom.name}</div>
<div className="chat-room-switcher-menu" ref={roomSwitcherRef}>
<button
type="button"
className="chat-room-switcher-trigger"
data-testid="chat-room-switcher-trigger"
aria-haspopup="menu"
aria-expanded={roomSwitcherOpen}
onClick={() => setRoomSwitcherOpen((open) => !open)}
>
<span className="chat-thread-header-title">#{rooms.activeRoom.name}</span>
<ChevronDown size={16} aria-hidden="true" />
</button>
{roomSwitcherOpen && (
<div
role="menu"
className="chat-room-switcher-dropdown"
data-testid="chat-room-switcher-dropdown"
>
{rooms.rooms.map((room) => (
<button
key={room.id}
type="button"
role="menuitem"
className={`chat-room-switcher-option${room.id === rooms.activeRoom?.id ? " chat-room-switcher-option--active" : ""}`}
data-testid={`chat-room-switcher-option-${room.id}`}
onClick={() => {
rooms.selectRoom(room.id);
setRoomSwitcherOpen(false);
}}
>
#{room.name}
</button>
))}
</div>
)}
</div>
<div className="chat-room-thread-members">
{rooms.activeRoomMembers.map((member) => (
<AgentAvatar

View File

@@ -691,6 +691,70 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
}
});
describe("room switcher dropdown", () => {
it("renders trigger with active room and menu semantics", () => {
setup({}, { activeRoom: roomA, rooms: [roomA] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const trigger = screen.getByTestId("chat-room-switcher-trigger");
expect(trigger).toHaveTextContent("#Room A");
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
});
it("opens dropdown, lists rooms, and marks active option", async () => {
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
setup({}, { activeRoom: roomA, rooms: [roomA, roomB] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-switcher-trigger"));
const dropdown = screen.getByTestId("chat-room-switcher-dropdown");
expect(dropdown).toBeInTheDocument();
expect(screen.getByTestId("chat-room-switcher-option-room-a")).toHaveClass("chat-room-switcher-option--active");
expect(screen.getByTestId("chat-room-switcher-option-room-b")).toBeInTheDocument();
});
it("selects a different room and closes dropdown", async () => {
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
const selectRoom = vi.fn();
setup({}, { activeRoom: roomA, rooms: [roomA, roomB], selectRoom });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-switcher-trigger"));
await userEvent.click(screen.getByTestId("chat-room-switcher-option-room-b"));
expect(selectRoom).toHaveBeenCalledWith("room-b");
expect(screen.queryByTestId("chat-room-switcher-dropdown")).not.toBeInTheDocument();
});
it("closes dropdown on Escape", async () => {
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
setup({}, { activeRoom: roomA, rooms: [roomA, roomB] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-switcher-trigger"));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("chat-room-switcher-dropdown")).not.toBeInTheDocument();
});
it("closes dropdown on outside click", async () => {
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
setup({}, { activeRoom: roomA, rooms: [roomA, roomB] });
render(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-switcher-trigger"));
fireEvent.mouseDown(screen.getByText("Room hello"));
expect(screen.queryByTestId("chat-room-switcher-dropdown")).not.toBeInTheDocument();
});
});
it("keeps direct mode behavior unchanged when rooms are enabled", async () => {
localStorage.setItem("fusion:chat-scope", "direct");
const addToast = vi.fn();