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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user