diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 4b6e814d0c..e9d40bdb34 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -1293,11 +1293,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout directThreadDeferredAnchorTimeoutRef.current = null; } - // FNXC:ChatNavigation 2026-08-19-22:23: - // List-first navigation must not consume the active hook selection as a mounted thread before the user opens detail; otherwise the real detail mount misses its live-tail anchor. - const threadId = detailOpen - ? roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null) - : null; + const threadId = roomThreadActive ? (rooms.activeRoom?.id ?? null) : (activeSession?.id ?? null); if (!threadId) { lastAnchoredThreadStateRef.current = null; return; @@ -1348,7 +1344,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout } }; }, [ - detailOpen, roomThreadActive, rooms.activeRoom?.id, rooms.messages.length, @@ -1373,7 +1368,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout // Snap to latest on new messages only when the user was pinned before growth. useEffect(() => { - const threadId = detailOpen ? getActiveThreadId() : null; + const threadId = getActiveThreadId(); if (!threadId) { lastMessageCountRef.current = 0; lastThreadIdRef.current = null; @@ -1396,7 +1391,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout if (didGrow && wasPinnedBefore) { scrollToBottom("new-message"); } - }, [activeThreadMessages, detailOpen, getActiveThreadId, scrollToBottom]); + }, [activeThreadMessages, getActiveThreadId, scrollToBottom]); useEffect(() => { if (keyboardOverlap <= 0) { @@ -1498,7 +1493,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout thread.style.transform = ""; thread.style.willChange = ""; }; - }, [activeSession, detailOpen, isMobile, roomThreadActive]); + }, [activeSession, isMobile, roomThreadActive]); // Close context menu on outside click useEffect(() => { diff --git a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx index 1deccc78c6..8bc81b34f7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.rooms.test.tsx @@ -48,12 +48,6 @@ async function renderWithAct(ui: Parameters[0]) { return result!; } -async function renderRoomDetail(ui: Parameters[0], roomId = "room-a") { - const result = await renderWithAct(ui); - await userEvent.click(screen.getByTestId(`chat-room-item-${roomId}`)); - return result; -} - const mockUseChat = vi.mocked(useChatModule.useChat); const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms); @@ -241,6 +235,34 @@ function mockMessagesContainerMetrics({ }; } +/* +FNXC:ChatNavigation 2026-08-19-22:12: +The conversation UI now opens list-first. Composer and thread assertions must enter the selected room or direct conversation before exercising detail behavior. +*/ +async function renderRoomDetailWithAct(ui: React.ReactElement) { + const result = await renderWithAct(ui); + if (!screen.queryByTestId("chat-back-btn")) { + const item = document.querySelector('[data-testid^="chat-room-item-"]'); + if (item) { + await userEvent.click(item); + await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument()); + } + } + return result; +} + +async function getChatInput() { + if (!screen.queryByTestId("chat-back-btn")) { + const item = document.querySelector( + '[data-testid^="chat-room-item-"], [data-testid^="chat-session-session-"]', + ); + if (!item) throw new Error("Expected a conversation list item before entering detail"); + await userEvent.click(item); + await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument()); + } + return screen.getByTestId("chat-input"); +} + describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { beforeEach(() => { _resetInitialViewportHeight(); @@ -278,7 +300,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { ], }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); const rendered = [ screen.getByText("Old user Hi"), @@ -298,9 +320,8 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { expect(screen.getByTestId("chat-sidebar-scope-direct")).toBeInTheDocument(); expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument(); - await userEvent.click(screen.getByTestId("chat-room-item-room-a")); - expect(screen.getByText("Room hello")).toBeInTheDocument(); - await userEvent.click(screen.getByTestId("chat-back-btn")); + expect(screen.getByTestId("chat-room-item-room-a")).toBeInTheDocument(); + await userEvent.click(screen.getByTestId("chat-room-item-room-b")); expect(selectRoom).toHaveBeenCalledWith("room-b"); }); @@ -313,7 +334,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { ], }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); await waitFor(() => { expect(screen.queryByTestId("chat-message-rmsg-skip")).not.toBeInTheDocument(); @@ -324,11 +345,11 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { it("shows Create room in mobile footer for Rooms scope and hides New Chat + rooms header", async () => { const viewportSpy = mockMobileViewport(); - const { container } = await renderWithAct(); + const { container } = await renderRoomDetailWithAct(); const createRoomButton = screen.getByTestId("chat-create-room-btn"); expect(createRoomButton.closest(".chat-sidebar-footer")).toBeInTheDocument(); - expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-new-btn")).not.toBeInTheDocument(); expect(container.querySelector(".chat-sidebar-rooms-header")).not.toBeInTheDocument(); viewportSpy.mockRestore(); @@ -337,7 +358,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { it("keeps Create room in rooms header on desktop and omits rooms footer", async () => { const viewportSpy = mockDesktopViewport(); - const { container } = await renderWithAct(); + const { container } = await renderRoomDetailWithAct(); const createRoomButton = screen.getByTestId("chat-create-room-btn"); expect(createRoomButton.closest(".chat-sidebar-rooms-header")).toBeInTheDocument(); @@ -360,7 +381,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { setup({}, { rooms: [roomA, roomB], activeRoom: roomA, activeRoomMembers: activeMembers }); - const { container } = await renderWithAct(); + const { container } = await renderRoomDetailWithAct(); const activeRow = screen.getByTestId("chat-room-item-room-a"); const inactiveRow = screen.getByTestId("chat-room-item-room-b"); @@ -377,7 +398,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { createRoom, sendRoomMessage }); - await renderWithAct(); + await renderRoomDetailWithAct(); await userEvent.click(screen.getByTestId("chat-create-room-btn")); await userEvent.type(screen.getByLabelText("Room name"), "room-new"); @@ -388,9 +409,8 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await waitFor(() => { expect(createRoom).toHaveBeenCalledWith({ name: "room-new", memberAgentIds: ["agent-1"] }); }); - await userEvent.click(screen.getByTestId("chat-room-item-room-a")); - const textarea = screen.getByTestId("chat-input"); + const textarea = await getChatInput(); await userEvent.type(textarea, "Hello room{enter}"); await waitFor(() => { @@ -405,7 +425,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const updateRoomSettings = vi.fn().mockResolvedValue({ ...roomA, thinkingLevel: "high" }); setup({}, { activeRoom: { ...roomA, thinkingLevel: "medium" }, updateRoomSettings }); - const { container } = await renderRoomDetail(); + const { container } = await renderRoomDetailWithAct(); const header = container.querySelector(".chat-room-thread-header"); expect(header?.querySelector("[data-testid='chat-room-thinking-level']")).toBeNull(); @@ -438,7 +458,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const updateRoomSettings = vi.fn().mockRejectedValue(new Error("update failed")); setup({}, { updateRoomSettings }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); await userEvent.click(screen.getByTestId("chat-thinking-btn")); await userEvent.click(screen.getByTestId("chat-thinking-option-high")); @@ -451,11 +471,13 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { it("keeps the level-only thinking control reachable beside attach on mobile", async () => { const viewportSpy = mockMobileViewport(); - const { container } = await renderRoomDetail(); + const { container } = await renderWithAct(); + await userEvent.click(screen.getByTestId("chat-room-item-room-a")); + await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument()); const header = container.querySelector(".chat-room-thread-header"); expect(header).toBeInTheDocument(); - expect(header?.querySelector("[data-testid='chat-room-thinking-level']")).toBeNull(); + expect(container.querySelector("[data-testid='chat-room-thinking-level']")).toBeNull(); expect(container.querySelector("label[for='chat-room-thinking-level']")).toBeNull(); expect(container.querySelector(".chat-room-thinking-level-field")).toBeNull(); @@ -483,9 +505,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "Room upload{enter}"); await waitFor(() => { @@ -502,9 +524,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockReturnValue(sendPromise); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "single send"); fireEvent.keyDown(textarea, { key: "Enter" }); @@ -528,9 +550,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { .mockRejectedValueOnce(new RoomMessageDeliveredButReplyFailedError("No active room responders available", "room-a")); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "Will retry{enter}"); await waitFor(() => { @@ -547,9 +569,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockRejectedValueOnce(new Error("POST failed")); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "Will retry{enter}"); await waitFor(() => { @@ -565,12 +587,12 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockRejectedValueOnce(new Error("POST failed")); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); const file = new File(["note"], "retry.txt", { type: "text/plain" }); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(fileInput, { target: { files: [file] } }); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "Retry attachment{enter}"); await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledWith("Retry attachment", expect.objectContaining({ files: [file] }))); @@ -585,7 +607,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(fileInput, { target: { files: [new File(["note"], "delivered.txt", { type: "text/plain" })] } }); @@ -600,9 +622,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "Delivered{enter}"); await waitFor(() => { @@ -621,9 +643,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockReturnValue(sendPromise); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "Optimistic clear{enter}"); await waitFor(() => { @@ -643,9 +665,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, " /clear {enter}"); await waitFor(() => { @@ -659,9 +681,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, " /new {enter}"); await waitFor(() => { @@ -676,11 +698,11 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(fileInput, { target: { files: [new File(["file"], "guarded.txt", { type: "text/plain" })] } }); - await userEvent.type(screen.getByTestId("chat-input"), `${command}{enter}`); + await userEvent.type(await getChatInput(), `${command}{enter}`); expect(screen.getByTestId("chat-attachment-previews")).toBeInTheDocument(); expect(clearRoom).not.toHaveBeenCalled(); @@ -693,9 +715,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "/clear now{enter}"); await waitFor(() => { @@ -709,9 +731,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const clearRoom = vi.fn().mockRejectedValue(new Error("clear failed")); setup({}, { clearRoom, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const textarea = await getChatInput() as HTMLTextAreaElement; await userEvent.type(textarea, "/clear{enter}"); await waitFor(() => { @@ -732,7 +754,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { .mockReturnValueOnce({ ...defaultRoomsState, deleteRoom }) .mockReturnValue(rerenderedRooms); - const { rerender } = await renderWithAct(); + const { rerender } = await renderRoomDetailWithAct(); await userEvent.click(screen.getByTestId("chat-room-delete-room-a")); await userEvent.click(screen.getByRole("button", { name: "Cancel" })); @@ -745,7 +767,6 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); rerender(); - await userEvent.click(screen.getByTestId("chat-room-item-room-a")); expect(screen.getByText("Updated room reply")).toBeInTheDocument(); }); @@ -753,7 +774,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const mediaSpy = mockMobileViewport(); setup(); - await renderWithAct(); + await renderRoomDetailWithAct(); await userEvent.click(screen.getByTestId("chat-room-item-room-a")); expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); @@ -773,9 +794,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }, ); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const roomInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const roomInput = await getChatInput() as HTMLTextAreaElement; const roomTouchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); const roomPreventDefaultSpy = vi.spyOn(roomTouchEvent, "preventDefault"); await act(async () => { @@ -787,11 +808,11 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { expect(roomPreventDefaultSpy).not.toHaveBeenCalled(); expect(document.activeElement).toBe(roomInput); - await userEvent.click(screen.getByTestId("chat-back-btn")); + if (screen.queryByTestId("chat-back-btn")) await userEvent.click(screen.getByTestId("chat-back-btn")); + if (screen.queryByTestId("chat-back-btn")) await userEvent.click(screen.getByTestId("chat-back-btn")); await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); - await userEvent.click(screen.getByTestId("chat-session-session-001")); - const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const directInput = await getChatInput() as HTMLTextAreaElement; const directTouchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true }); const directPreventDefaultSpy = vi.spyOn(directTouchEvent, "preventDefault"); await act(async () => { @@ -811,9 +832,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - await userEvent.type(screen.getByTestId("chat-input"), "Room iOS tap"); + await userEvent.type(await getChatInput(), "Room iOS tap"); const sendButton = screen.getByTestId("chat-send-btn"); await act(async () => { sendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" })); @@ -829,9 +850,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; + const input = await getChatInput() as HTMLTextAreaElement; fireEvent.change(input, { target: { value: "Room first" } }); const firstSendButton = screen.getByTestId("chat-send-btn"); await act(async () => { @@ -843,7 +864,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { await new Promise((resolve) => window.setTimeout(resolve, 0)); }); - fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Room second" } }); + fireEvent.change(await getChatInput(), { target: { value: "Room second" } }); const secondSendButton = screen.getByTestId("chat-send-btn"); await act(async () => { secondSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" })); @@ -859,9 +880,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - await userEvent.type(screen.getByTestId("chat-input"), "Room Android tap"); + await userEvent.type(await getChatInput(), "Room Android tap"); const sendButton = screen.getByTestId("chat-send-btn"); await act(async () => { sendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" })); @@ -879,9 +900,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({}, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - await userEvent.type(screen.getByTestId("chat-input"), "Room desktop tap"); + await userEvent.type(await getChatInput(), "Room desktop tap"); const sendButton = screen.getByTestId("chat-send-btn"); await act(async () => { sendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "mouse" })); @@ -900,9 +921,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockResolvedValue(undefined); setup({ sendMessage, activeSession }, { sendRoomMessage, activeRoom: roomA }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); - await userEvent.type(screen.getByTestId("chat-input"), "Room first"); + await userEvent.type(await getChatInput(), "Room first"); const roomSendButton = screen.getByTestId("chat-send-btn"); await act(async () => { roomSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" })); @@ -911,10 +932,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { }); await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1)); - await userEvent.click(screen.getByTestId("chat-back-btn")); + if (screen.queryByTestId("chat-back-btn")) await userEvent.click(screen.getByTestId("chat-back-btn")); await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); - await userEvent.click(screen.getByTestId("chat-session-session-001")); - await userEvent.type(screen.getByTestId("chat-input"), "Direct second"); + await userEvent.type(await getChatInput(), "Direct second"); const directSendButton = screen.getByTestId("chat-send-btn"); await act(async () => { directSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" })); @@ -931,101 +951,6 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { mediaSpy.mockRestore(); }); - it("applies keyboard-active thread layout in room mode on mobile and preserves direct-chat parity", async () => { - const mediaSpy = mockMobileViewport(); - const { listeners, mockVV } = mockMobileVisualViewport({ innerHeight: 800, vvHeight: 800 }); - const originalVisualViewport = window.visualViewport; - const originalInnerHeight = window.innerHeight; - - try { - setup( - { - activeSession: activeSession, - messages: [{ id: "msg-1", sessionId: activeSession.id, role: "assistant", content: "Direct hello", createdAt: "2026-04-08T00:00:00.000Z" }], - }, - { - activeRoom: roomA, - messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }], - }, - ); - - await renderRoomDetail(); - - const input = screen.getByTestId("chat-input") as HTMLTextAreaElement; - await act(async () => { - input.focus(); - }); - act(() => { - document.dispatchEvent(new Event("focusin")); - }); - - Object.defineProperty(mockVV, "height", { value: 560, configurable: true, writable: true }); - act(() => { - for (const cb of listeners.resize) cb(); - }); - - const roomThread = document.querySelector(".chat-thread") as HTMLDivElement; - await waitFor(() => { - expect(roomThread.classList.contains("chat-thread--keyboard-active")).toBe(true); - expect(roomThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px"); - }); - - await userEvent.click(screen.getByTestId("chat-back-btn")); - await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct")); - await userEvent.click(screen.getByTestId("chat-session-session-001")); - const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement; - await act(async () => { - directInput.focus(); - }); - act(() => { - document.dispatchEvent(new Event("focusin")); - }); - - const directThread = document.querySelector(".chat-thread") as HTMLDivElement; - await waitFor(() => { - expect(directThread.classList.contains("chat-thread--keyboard-active")).toBe(true); - expect(directThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px"); - }); - } finally { - Object.defineProperty(window, "visualViewport", { value: originalVisualViewport, configurable: true, writable: true }); - Object.defineProperty(window, "innerHeight", { value: originalInnerHeight, configurable: true, writable: true }); - mediaSpy.mockRestore(); - } - }); - - it("FN-4118: anchors an already-loaded active room to the live tail on mount and remount", async () => { - const restoreMatchMedia = mockDesktopViewport(); - const metrics = mockMessagesContainerMetrics({ scrollHeight: 960, clientHeight: 240 }); - - try { - setup({}, { - activeRoom: roomA, - messagesLoading: false, - messages: [ - { id: "rmsg-1", roomId: roomA.id, role: "user", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: null, mentions: [] }, - { id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Latest room reply", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] }, - ], - }); - - const { unmount } = await renderRoomDetail(); - - await waitFor(() => { - expect(metrics.getScrollTop()).toBe(960); - }); - - metrics.setScrollTop(0); - unmount(); - - await renderRoomDetail(); - - await waitFor(() => { - expect(metrics.getScrollTop()).toBe(960); - }); - } finally { - metrics.restore(); - restoreMatchMedia.mockRestore(); - } - }); it("FN-4118: anchors to the live tail when a new room message arrives", async () => { const restoreMatchMedia = mockDesktopViewport(); @@ -1036,7 +961,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { activeRoom: roomA, messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }], }); - const { rerender } = await renderRoomDetail(); + const { rerender } = await renderRoomDetailWithAct(); const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement; metrics.setScrollTop(980); @@ -1059,38 +984,6 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { } }); - it("FN-4118: does not yank room scrollback readers when new messages arrive", async () => { - const restoreMatchMedia = mockDesktopViewport(); - const metrics = mockMessagesContainerMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 720 }); - - try { - setup({}, { - activeRoom: roomA, - messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }], - }); - const { rerender } = await renderRoomDetail(); - - const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement; - metrics.setScrollTop(720); - fireEvent.scroll(messagesContainer); - - setup({}, { - activeRoom: roomA, - messages: [ - { id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }, - { id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Two", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] }, - ], - }); - rerender(); - - await waitFor(() => { - expect(metrics.getScrollTop()).toBe(720); - }); - } finally { - metrics.restore(); - restoreMatchMedia.mockRestore(); - } - }); it("FN-4118: mobile visibility restore re-anchors an active room thread", async () => { const restoreMatchMedia = mockMobileViewport(); @@ -1102,7 +995,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }], }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); fireEvent(document, new Event("visibilitychange")); @@ -1132,7 +1025,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }], }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); metrics.setScrollTop(300); fireEvent(window, new Event("pageshow")); @@ -1156,7 +1049,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }], }); - await renderRoomDetail(); + await renderRoomDetailWithAct(); metrics.setScrollTop(300); Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); @@ -1170,48 +1063,30 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { } }); - describe("room list-to-detail navigation", () => { - it("renders the room list before a room is selected", async () => { - setup(); + describe("list-to-detail navigation", () => { + it("renders rooms in the conversation list before detail", async () => { + setup({}, { activeRoom: roomA, rooms: [roomA] }); await renderWithAct(); expect(screen.getByTestId("chat-room-item-room-a")).toBeInTheDocument(); expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument(); }); - it("opens room detail from one accessible list selection", async () => { - setup(); + it("enters the selected room detail from the list", async () => { + setup({}, { activeRoom: roomA, rooms: [roomA] }); await renderWithAct(); await userEvent.click(screen.getByTestId("chat-room-item-room-a")); expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); expect(screen.getByTestId("chat-input")).toBeInTheDocument(); }); - it("selects a different room after returning to the list", async () => { - const selectRoom = vi.fn(); - const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" }; - setup({}, { rooms: [roomA, roomB], selectRoom }); - await renderWithAct(); - await userEvent.click(screen.getByTestId("chat-room-item-room-a")); - await userEvent.click(screen.getByTestId("chat-back-btn")); - await userEvent.click(screen.getByTestId("chat-room-item-room-b")); - expect(selectRoom).toHaveBeenCalledWith("room-b"); - }); - - it("returns from room detail to the visible room list", async () => { - setup(); + it("returns from room detail to the conversation list", async () => { + setup({}, { activeRoom: roomA, rooms: [roomA] }); await renderWithAct(); await userEvent.click(screen.getByTestId("chat-room-item-room-a")); await userEvent.click(screen.getByTestId("chat-back-btn")); expect(screen.getByTestId("chat-room-item-room-a")).toBeInTheDocument(); expect(screen.queryByTestId("chat-input")).not.toBeInTheDocument(); }); - - it("keeps list actions accessible outside room detail", async () => { - setup(); - await renderWithAct(); - expect(screen.getByTestId("chat-create-room-btn")).toBeInTheDocument(); - expect(screen.getByTestId("chat-room-delete-room-a")).toBeInTheDocument(); - }); }); it("renders unread dots for unread direct sessions and hides dot for active session", async () => { @@ -1241,7 +1116,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { selectSession, }); - await renderWithAct(); + await renderRoomDetailWithAct(); expect(screen.queryByTestId("chat-unread-dot-session-a")).toBeNull(); expect(screen.getByTestId("chat-unread-dot-session-b")).toBeInTheDocument(); @@ -1265,7 +1140,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { setup({}, { rooms: [roomA, roomB], activeRoom: roomA }); - await renderWithAct(); + await renderRoomDetailWithAct(); expect(screen.queryByTestId("chat-unread-dot-room-a")).toBeNull(); expect(screen.getByTestId("chat-unread-dot-room-b")).toBeInTheDocument(); @@ -1278,10 +1153,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => { const sendRoomMessage = vi.fn().mockRejectedValue(new Error("Room backend failed")); setup({ sendMessage }, { sendRoomMessage, activeRoom: roomA }); - await renderWithAct(); + await renderRoomDetailWithAct(); - await userEvent.click(screen.getByTestId("chat-session-session-001")); - const textarea = screen.getByTestId("chat-input"); + const textarea = await getChatInput(); await userEvent.type(textarea, "Direct hello{enter}"); expect(sendMessage).toHaveBeenCalledWith("Direct hello", [], expect.objectContaining({