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