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:
7
.changeset/fn-7784-chat-header-list-consistency.md
Normal file
7
.changeset/fn-7784-chat-header-list-consistency.md
Normal 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.
|
||||
@@ -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
|
||||
<div ref={chatThreadRef} className="chat-thread">
|
||||
{rooms.activeRoom ? (
|
||||
<>
|
||||
{(!isChatMobile || showMobileRoomThreadHeaderControls) && (
|
||||
<div className="chat-room-thread-header">
|
||||
{isChatMobile && (
|
||||
{showMobileRoomThreadHeaderControls && (
|
||||
<button className="btn-icon" onClick={handleRoomBack} data-testid="chat-back-btn">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
@@ -3238,6 +3247,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||||
{rooms.messagesLoading ? (
|
||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||
|
||||
@@ -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(<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 () => {
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
await openMobileDirectThread();
|
||||
|
||||
const backBtn = screen.getByTestId("chat-back-btn");
|
||||
await userEvent.click(backBtn);
|
||||
@@ -443,6 +552,8 @@ describe("ChatView mobile behavior", () => {
|
||||
});
|
||||
|
||||
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-rename-session-002"));
|
||||
@@ -484,6 +595,7 @@ describe("ChatView mobile behavior", () => {
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<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-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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} floating />);
|
||||
await openMobileDirectThread();
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
|
||||
expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument();
|
||||
|
||||
@@ -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 <ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />;
|
||||
}
|
||||
|
||||
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 <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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
mockViewport("desktop");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user