From 59a798bc0d4b6581d3fdb9ab307244ca6d08c7d6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 08:21:52 -0700 Subject: [PATCH] FN-7784: keep mobile chat header controls in sync with visible pane Fix Chat header showing thread controls (session switcher, back button, room header) while the conversation list is still displayed on mobile after re-entering Chat, since restored session/room state no longer implied the detail pane was visible. - Derive mobileThreadPaneOpen from actual sidebar visibility (!sidebarVisible) combined with active thread/room state instead of relying solely on restored session/room presence. - Gate showMobileSessionSwitcher, showMobileDirectThreadHeaderControls, and new showMobileRoomThreadHeaderControls off the corrected mobileThreadPaneOpen signal. - Wrap the room thread header in showMobileRoomThreadHeaderControls so it only renders when the detail pane is actually shown. - Add regression coverage in ChatView.mobile.test.tsx and ChatView.swipe-back.test.tsx for the list-shown-after-remount case. - Add changeset documenting the fix. Files changed: .changeset/fn-7784-chat-header-list-consistency.md | 7 + packages/dashboard/app/components/ChatView.tsx | 18 ++- .../components/__tests__/ChatView.mobile.test.tsx | 141 +++++++++++++++++++-- .../__tests__/ChatView.swipe-back.test.tsx | 74 +++++++++-- 4 files changed, 214 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-7784 Fusion-Task-Lineage: e2571efa-dff9-43ef-af09-f7d2ce32788b Co-authored-by: Fusion (runfusion.ai) --- .../fn-7784-chat-header-list-consistency.md | 7 + .../dashboard/app/components/ChatView.tsx | 18 ++- .../__tests__/ChatView.mobile.test.tsx | 141 ++++++++++++++++-- .../__tests__/ChatView.swipe-back.test.tsx | 74 +++++++-- 4 files changed, 214 insertions(+), 26 deletions(-) create mode 100644 .changeset/fn-7784-chat-header-list-consistency.md diff --git a/.changeset/fn-7784-chat-header-list-consistency.md b/.changeset/fn-7784-chat-header-list-consistency.md new file mode 100644 index 0000000000..ae68e43bfd --- /dev/null +++ b/.changeset/fn-7784-chat-header-list-consistency.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Chat header showing thread controls while the conversation list is displayed after re-entering Chat. +category: fix +dev: On mobile remount, useChat/useChatRooms restore the active session/room while sidebarVisible resets true; mobile thread controls now key off actual pane visibility. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index a06cc6e78d..83d1f371a9 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -2130,7 +2130,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout const activeModelTag = formatModelTag(activeResolvedModel?.provider, activeResolvedModel?.modelId); const activeModelProvider = activeResolvedModel?.provider ?? null; const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0); - const hasMobileDetailSelection = chatScope === "rooms" ? roomThreadActive : Boolean(activeSession); + /* + FNXC:ChatHeader 2026-07-10-00:00: + After Chat remounts, useChat/useChatRooms can restore persisted activeSession/activeRoom while sidebarVisible resets to true. On mobile, header controls, the direct-thread shell class, and swipe-back history must follow the pane the body is actually showing, so detail-open requires the sidebar/list to be hidden instead of relying on restored thread presence alone. + */ + const mobileThreadPaneOpen = isChatMobile && !sidebarVisible && (chatScope === "rooms" ? roomThreadActive : hasThreadInView); + const hasMobileDetailSelection = isChatMobile + ? mobileThreadPaneOpen + : chatScope === "rooms" ? roomThreadActive : Boolean(activeSession); const previousHasMobileDetailSelectionRef = useRef(hasMobileDetailSelection); useEffect(() => { @@ -2168,8 +2175,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout total: threadHeaderContextTotal, }) : null; - const showMobileSessionSwitcher = isChatMobile && chatScope === "direct" && !!activeSession; - const showMobileDirectThreadHeaderControls = isChatMobile && chatScope === "direct" && hasThreadInView; + const showMobileSessionSwitcher = mobileThreadPaneOpen && chatScope === "direct" && !!activeSession; + const showMobileDirectThreadHeaderControls = mobileThreadPaneOpen && chatScope === "direct"; + const showMobileRoomThreadHeaderControls = mobileThreadPaneOpen && chatScope === "rooms"; const agentName = agentsMap.get(activeSession?.agentId ?? "")?.name || @@ -3181,8 +3189,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
{rooms.activeRoom ? ( <> + {(!isChatMobile || showMobileRoomThreadHeaderControls) && (
- {isChatMobile && ( + {showMobileRoomThreadHeaderControls && ( @@ -3238,6 +3247,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout ))}
+ )}
{rooms.messagesLoading ? (
{t("chat.loadingMessages", "Loading messages...")}
diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx index 2b12096b17..ce26c527dd 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx @@ -208,6 +208,13 @@ describe("ChatView mobile behavior", () => { })); } + async function openMobileDirectThread(sessionId = "session-001") { + await userEvent.click(screen.getByTestId(`chat-session-${sessionId}`)); + await waitFor(() => { + expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); + }); + } + async function focusComposerAndOpenKeyboard({ listeners, mockVV, @@ -259,10 +266,87 @@ describe("ChatView mobile behavior", () => { } }); + it("mobile mode: restored direct active session keeps list header after remount", async () => { + const restoreMatchMedia = mockMobileViewport(); + try { + setupMockChat({ + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + activeSession: activeSessionFixture, + messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Cached reply", createdAt: "2026-04-08T00:00:00.000Z" }], + }); + + const firstRender = await renderWithAct(); + firstRender.unmount(); + await renderWithAct(); + + const sidebar = document.querySelector(".chat-sidebar") as HTMLElement; + expect(sidebar).toBeInTheDocument(); + expect(sidebar).not.toHaveClass("chat-sidebar--hidden"); + expect(screen.getByTestId("chat-sidebar-scope-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("chat-session-session-001")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument(); + expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument(); + expect(document.querySelector(".chat-view--mobile-direct-thread")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Back to conversations")).not.toBeInTheDocument(); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("mobile mode: restored room keeps list header after remount", async () => { + const restoreMatchMedia = mockMobileViewport(); + const backendRoom = createRoomFixture("backend"); + try { + localStorage.setItem("fusion:chat-scope", "rooms"); + setupMockRooms({ + rooms: [backendRoom], + activeRoom: backendRoom, + messages: [{ id: "room-msg-001", roomId: backendRoom.id, role: "assistant", senderAgentId: "agent-001", content: "Cached room reply", createdAt: "2026-04-08T00:00:00.000Z" }], + }); + + const firstRender = await renderWithAct(); + firstRender.unmount(); + await renderWithAct(); + + const sidebar = document.querySelector(".chat-sidebar") as HTMLElement; + expect(sidebar).toBeInTheDocument(); + expect(sidebar).not.toHaveClass("chat-sidebar--hidden"); + expect(screen.getByTestId("chat-sidebar-scope-toggle")).toBeInTheDocument(); + expect(screen.getByTestId("chat-room-item-backend")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument(); + expect(screen.queryByTestId("chat-room-switcher-trigger")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Back to conversations")).not.toBeInTheDocument(); + } finally { + localStorage.setItem("fusion:chat-scope", "direct"); + restoreMatchMedia.mockRestore(); + } + }); + it("mobile mode: collapses direct thread controls into one far-left ViewHeader row", async () => { const restoreMatchMedia = mockMobileViewport(); try { setupMockChat({ + sessions: [{ + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Testing", + modelProvider: "minimax", + modelId: "m3", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }], + filteredSessions: [{ + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Testing", + modelProvider: "minimax", + modelId: "m3", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }], activeSession: { id: "session-001", agentId: "__fn_agent__", @@ -277,6 +361,7 @@ describe("ChatView mobile behavior", () => { }); await renderWithAct(); + await openMobileDirectThread(); expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument(); expect(document.querySelector(".chat-view--mobile-direct-thread")).toBeInTheDocument(); @@ -316,6 +401,26 @@ describe("ChatView mobile behavior", () => { const restoreMatchMedia = mockMobileViewport(); try { setupMockChat({ + sessions: [{ + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: null, + modelProvider: "minimax", + modelId: "m3", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }], + filteredSessions: [{ + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: null, + modelProvider: "minimax", + modelId: "m3", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }], activeSession: { id: "session-001", agentId: "__fn_agent__", @@ -330,6 +435,7 @@ describe("ChatView mobile behavior", () => { }); await renderWithAct(); + await openMobileDirectThread(); const trigger = screen.getByTestId("chat-mobile-session-trigger"); expect(trigger).toHaveTextContent("Untitled"); @@ -376,12 +482,15 @@ describe("ChatView mobile behavior", () => { const selectSession = vi.fn(); try { setupMockChat({ - activeSession: { 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" }, + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + activeSession: activeSessionFixture, messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }], selectSession, }); await renderWithAct(); + await openMobileDirectThread(); const backBtn = screen.getByTestId("chat-back-btn"); await userEvent.click(backBtn); @@ -443,6 +552,8 @@ describe("ChatView mobile behavior", () => { }); await renderWithAct(); + await openMobileDirectThread(); + selectSession.mockClear(); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-002")); @@ -484,6 +595,7 @@ describe("ChatView mobile behavior", () => { }); await renderWithAct(); + await openMobileDirectThread(); const headerActions = document.querySelector(".view-header__actions") as HTMLElement; const backButton = screen.getByTestId("chat-back-btn"); @@ -522,6 +634,7 @@ describe("ChatView mobile behavior", () => { }); await renderWithAct(); + await openMobileDirectThread(); const trigger = screen.getByTestId("chat-mobile-session-trigger"); expect(trigger).toHaveClass("btn", "chat-mobile-session-trigger"); @@ -542,8 +655,9 @@ describe("ChatView mobile behavior", () => { it("mobile mode: quick session switcher closes on outside click and is not shown for rooms", async () => { const restoreMatchMedia = mockMobileViewport(); try { - setupMockChat({ activeSession: activeSessionFixture }); + setupMockChat({ sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture], activeSession: activeSessionFixture }); const initialRender = await renderWithAct(); + await openMobileDirectThread(); expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeInTheDocument(); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); @@ -557,20 +671,23 @@ describe("ChatView mobile behavior", () => { initialRender.unmount(); localStorage.setItem("fusion:chat-scope", "rooms"); + const backendRoom = { + id: "room-001", + projectId: "proj-123", + slug: "backend", + name: "backend", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }; setupMockRooms({ - activeRoom: { - id: "room-001", - projectId: "proj-123", - name: "backend", - createdAt: "2026-04-08T00:00:00.000Z", - updatedAt: "2026-04-08T00:00:00.000Z", - }, + rooms: [backendRoom], + activeRoom: backendRoom, }); await renderWithAct(); expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument(); expect(screen.queryByTestId("chat-mobile-session-new")).not.toBeInTheDocument(); - expect(screen.getByText("#backend")).toBeInTheDocument(); + expect(screen.getByTestId("chat-room-item-backend")).toBeInTheDocument(); } finally { localStorage.setItem("fusion:chat-scope", "direct"); restoreMatchMedia.mockRestore(); @@ -587,6 +704,7 @@ describe("ChatView mobile behavior", () => { }); await renderWithAct(); + await openMobileDirectThread(); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); const dropdown = screen.getByTestId("chat-mobile-session-dropdown"); @@ -611,6 +729,7 @@ describe("ChatView mobile behavior", () => { activeSession: activeSessionFixture, }); const singleRender = await renderWithAct(); + await openMobileDirectThread(); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); @@ -632,6 +751,7 @@ describe("ChatView mobile behavior", () => { activeSession: activeSessionFixture, }); await renderWithAct(); + await openMobileDirectThread(); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); @@ -672,6 +792,7 @@ describe("ChatView mobile behavior", () => { }); await renderWithAct(); + await openMobileDirectThread(); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); diff --git a/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx index 2c3e4a49a1..b094a6debb 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx @@ -65,6 +65,23 @@ function HistoryHarness({ children }: { children: ReactNode }) { const selectSessionSpy = vi.fn(); +function mockRoomsIdle() { + mockUseChatRooms.mockReturnValue({ + rooms: [], + roomsLoading: false, + roomsError: null, + activeRoom: null, + activeRoomMembers: [], + messages: [], + messagesLoading: false, + selectRoom: vi.fn(), + createRoom: vi.fn(), + deleteRoom: vi.fn(), + sendRoomMessage: vi.fn(), + refreshRooms: vi.fn(), + }); +} + function StatefulChatView() { const [activeSessionId, setActiveSessionId] = useState(""); const handleSelectSession = (id: string) => { @@ -99,20 +116,39 @@ function StatefulChatView() { agentsMap: new Map(), })); - mockUseChatRooms.mockReturnValue({ - rooms: [], - roomsLoading: false, - roomsError: null, - activeRoom: null, - activeRoomMembers: [], - messages: [], + mockRoomsIdle(); + + return ; +} + +function RestoredActiveSessionChatView() { + mockUseChat.mockReturnValue({ + sessions: [session], + activeSession: session, + sessionsLoading: false, + messages: [{ id: "msg-001", sessionId: session.id, role: "assistant", content: "Restored", createdAt: "2026-04-08T00:00:00.000Z" }], messagesLoading: false, - selectRoom: vi.fn(), - createRoom: vi.fn(), - deleteRoom: vi.fn(), - sendRoomMessage: vi.fn(), - refreshRooms: vi.fn(), + isStreaming: false, + streamingText: "", + streamingThinking: "", + streamingToolCalls: [], + selectSession: selectSessionSpy, + createSession: vi.fn(), + archiveSession: vi.fn(), + deleteSession: vi.fn(), + sendMessage: vi.fn(), + stopStreaming: vi.fn(), + pendingMessages: [], + clearPendingMessage: vi.fn(), + loadMoreMessages: vi.fn(), + hasMoreMessages: false, + searchQuery: "", + setSearchQuery: vi.fn(), + filteredSessions: [session], + refreshSessions: vi.fn(), + agentsMap: new Map(), }); + mockRoomsIdle(); return ; } @@ -154,6 +190,20 @@ describe("ChatView mobile swipe-back", () => { expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument(); }); + it("does not push a phantom mobile nav entry for a restored active session while the list is visible", async () => { + mockViewport("mobile"); + + render( + + + , + ); + + expect(screen.getByTestId("chat-session-session-001")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument(); + expect(window.history.pushState).not.toHaveBeenCalled(); + }); + it("does not push a nav entry on desktop selection", async () => { mockViewport("desktop");