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");