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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 08:21:52 -07:00
parent a32307f8f1
commit 59a798bc0d
4 changed files with 214 additions and 26 deletions

View File

@@ -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.

View File

@@ -2130,7 +2130,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const activeModelTag = formatModelTag(activeResolvedModel?.provider, activeResolvedModel?.modelId); const activeModelTag = formatModelTag(activeResolvedModel?.provider, activeResolvedModel?.modelId);
const activeModelProvider = activeResolvedModel?.provider ?? null; const activeModelProvider = activeResolvedModel?.provider ?? null;
const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0); 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); const previousHasMobileDetailSelectionRef = useRef(hasMobileDetailSelection);
useEffect(() => { useEffect(() => {
@@ -2168,8 +2175,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
total: threadHeaderContextTotal, total: threadHeaderContextTotal,
}) })
: null; : null;
const showMobileSessionSwitcher = isChatMobile && chatScope === "direct" && !!activeSession; const showMobileSessionSwitcher = mobileThreadPaneOpen && chatScope === "direct" && !!activeSession;
const showMobileDirectThreadHeaderControls = isChatMobile && chatScope === "direct" && hasThreadInView; const showMobileDirectThreadHeaderControls = mobileThreadPaneOpen && chatScope === "direct";
const showMobileRoomThreadHeaderControls = mobileThreadPaneOpen && chatScope === "rooms";
const agentName = const agentName =
agentsMap.get(activeSession?.agentId ?? "")?.name || agentsMap.get(activeSession?.agentId ?? "")?.name ||
@@ -3181,8 +3189,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<div ref={chatThreadRef} className="chat-thread"> <div ref={chatThreadRef} className="chat-thread">
{rooms.activeRoom ? ( {rooms.activeRoom ? (
<> <>
{(!isChatMobile || showMobileRoomThreadHeaderControls) && (
<div className="chat-room-thread-header"> <div className="chat-room-thread-header">
{isChatMobile && ( {showMobileRoomThreadHeaderControls && (
<button className="btn-icon" onClick={handleRoomBack} data-testid="chat-back-btn"> <button className="btn-icon" onClick={handleRoomBack} data-testid="chat-back-btn">
<ChevronLeft size={16} /> <ChevronLeft size={16} />
</button> </button>
@@ -3238,6 +3247,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
))} ))}
</div> </div>
</div> </div>
)}
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}> <div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
{rooms.messagesLoading ? ( {rooms.messagesLoading ? (
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div> <div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>

View File

@@ -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({ async function focusComposerAndOpenKeyboard({
listeners, listeners,
mockVV, 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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
firstRender.unmount();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
firstRender.unmount();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
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 () => { it("mobile mode: collapses direct thread controls into one far-left ViewHeader row", async () => {
const restoreMatchMedia = mockMobileViewport(); const restoreMatchMedia = mockMobileViewport();
try { try {
setupMockChat({ 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: { activeSession: {
id: "session-001", id: "session-001",
agentId: "__fn_agent__", agentId: "__fn_agent__",
@@ -277,6 +361,7 @@ describe("ChatView mobile behavior", () => {
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument(); expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument();
expect(document.querySelector(".chat-view--mobile-direct-thread")).toBeInTheDocument(); expect(document.querySelector(".chat-view--mobile-direct-thread")).toBeInTheDocument();
@@ -316,6 +401,26 @@ describe("ChatView mobile behavior", () => {
const restoreMatchMedia = mockMobileViewport(); const restoreMatchMedia = mockMobileViewport();
try { try {
setupMockChat({ 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: { activeSession: {
id: "session-001", id: "session-001",
agentId: "__fn_agent__", agentId: "__fn_agent__",
@@ -330,6 +435,7 @@ describe("ChatView mobile behavior", () => {
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
const trigger = screen.getByTestId("chat-mobile-session-trigger"); const trigger = screen.getByTestId("chat-mobile-session-trigger");
expect(trigger).toHaveTextContent("Untitled"); expect(trigger).toHaveTextContent("Untitled");
@@ -376,12 +482,15 @@ describe("ChatView mobile behavior", () => {
const selectSession = vi.fn(); const selectSession = vi.fn();
try { try {
setupMockChat({ 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" }], messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
selectSession, selectSession,
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
const backBtn = screen.getByTestId("chat-back-btn"); const backBtn = screen.getByTestId("chat-back-btn");
await userEvent.click(backBtn); await userEvent.click(backBtn);
@@ -443,6 +552,8 @@ describe("ChatView mobile behavior", () => {
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
selectSession.mockClear();
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-002")); await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-002"));
@@ -484,6 +595,7 @@ describe("ChatView mobile behavior", () => {
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
const headerActions = document.querySelector(".view-header__actions") as HTMLElement; const headerActions = document.querySelector(".view-header__actions") as HTMLElement;
const backButton = screen.getByTestId("chat-back-btn"); const backButton = screen.getByTestId("chat-back-btn");
@@ -522,6 +634,7 @@ describe("ChatView mobile behavior", () => {
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
const trigger = screen.getByTestId("chat-mobile-session-trigger"); const trigger = screen.getByTestId("chat-mobile-session-trigger");
expect(trigger).toHaveClass("btn", "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 () => { it("mobile mode: quick session switcher closes on outside click and is not shown for rooms", async () => {
const restoreMatchMedia = mockMobileViewport(); const restoreMatchMedia = mockMobileViewport();
try { try {
setupMockChat({ activeSession: activeSessionFixture }); setupMockChat({ sessions: [activeSessionFixture], filteredSessions: [activeSessionFixture], activeSession: activeSessionFixture });
const initialRender = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); const initialRender = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeInTheDocument(); expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
@@ -557,20 +671,23 @@ describe("ChatView mobile behavior", () => {
initialRender.unmount(); initialRender.unmount();
localStorage.setItem("fusion:chat-scope", "rooms"); 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({ setupMockRooms({
activeRoom: { rooms: [backendRoom],
id: "room-001", activeRoom: backendRoom,
projectId: "proj-123",
name: "backend",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument(); expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-mobile-session-new")).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 { } finally {
localStorage.setItem("fusion:chat-scope", "direct"); localStorage.setItem("fusion:chat-scope", "direct");
restoreMatchMedia.mockRestore(); restoreMatchMedia.mockRestore();
@@ -587,6 +704,7 @@ describe("ChatView mobile behavior", () => {
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
const dropdown = screen.getByTestId("chat-mobile-session-dropdown"); const dropdown = screen.getByTestId("chat-mobile-session-dropdown");
@@ -611,6 +729,7 @@ describe("ChatView mobile behavior", () => {
activeSession: activeSessionFixture, activeSession: activeSessionFixture,
}); });
const singleRender = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); const singleRender = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument();
@@ -632,6 +751,7 @@ describe("ChatView mobile behavior", () => {
activeSession: activeSessionFixture, activeSession: activeSessionFixture,
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument();
@@ -672,6 +792,7 @@ describe("ChatView mobile behavior", () => {
}); });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating />); await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating />);
await openMobileDirectThread();
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument();

View File

@@ -65,6 +65,23 @@ function HistoryHarness({ children }: { children: ReactNode }) {
const selectSessionSpy = vi.fn(); 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() { function StatefulChatView() {
const [activeSessionId, setActiveSessionId] = useState(""); const [activeSessionId, setActiveSessionId] = useState("");
const handleSelectSession = (id: string) => { const handleSelectSession = (id: string) => {
@@ -99,20 +116,39 @@ function StatefulChatView() {
agentsMap: new Map(), agentsMap: new Map(),
})); }));
mockUseChatRooms.mockReturnValue({ mockRoomsIdle();
rooms: [],
roomsLoading: false, return <ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />;
roomsError: null, }
activeRoom: null,
activeRoomMembers: [], function RestoredActiveSessionChatView() {
messages: [], 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, messagesLoading: false,
selectRoom: vi.fn(), isStreaming: false,
createRoom: vi.fn(), streamingText: "",
deleteRoom: vi.fn(), streamingThinking: "",
sendRoomMessage: vi.fn(), streamingToolCalls: [],
refreshRooms: vi.fn(), 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 <ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />; return <ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />;
} }
@@ -154,6 +190,20 @@ describe("ChatView mobile swipe-back", () => {
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument(); 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(
<HistoryHarness>
<RestoredActiveSessionChatView />
</HistoryHarness>,
);
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 () => { it("does not push a nav entry on desktop selection", async () => {
mockViewport("desktop"); mockViewport("desktop");