FN-7455: fix mobile direct chat header alignment

Keep the mobile direct-chat header on a single left-aligned row without losing accessible context.

- Hide the direct-chat ViewHeader title shell from layout while preserving the accessible Chat heading.
- Keep the back button first and the active session switcher beside it on a non-wrapping mobile row.
- Extend mobile chat coverage for long duplicate session titles, Bot fallback labels, and the CSS layout contract.
- Document the updated mobile Chat header behavior and add a patch changeset.

Files changed:
 .changeset/fn-7455-mobile-chat-header-layout.md    |  7 ++
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/components/ChatView.css     | 17 +++--
 packages/dashboard/app/components/ChatView.tsx     |  4 +-
 .../components/__tests__/ChatView.mobile.test.tsx  | 80 ++++++++++++++++++++--
 5 files changed, 98 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-7455

Fusion-Task-Lineage: fe14b542-ff6a-437b-ad89-8011c2b3c299

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 18:11:49 -07:00
parent 78ebeafcec
commit 1430a42dd8
5 changed files with 98 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the mobile Chat header so back navigation and session selection stay on one row.
category: fix
dev: Keeps the direct-chat mobile header collapsed while preserving desktop and room-chat layouts.

View File

@@ -411,7 +411,7 @@ Chat view provides project-scoped conversations with agents.
- If you queue follow-up user messages while the assistant is still streaming, Chat persists them per session, stacks each queued preview above the input box with one shared divider, and restores/sends them one at a time in FIFO order once each active response finishes if you leave and return.
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
- On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail.
- On mobile direct-chat threads, the top Chat header collapses into one compact row: the back button and active conversation dropdown live beside the Chat icon, while the visible “Chat” title is hidden to preserve transcript space. Tapping the active conversation opens a lightweight dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
- On mobile direct-chat threads, the top Chat header collapses into one compact row: the back button is the far-left visible control and the active conversation dropdown stays beside it, while the visible Chat icon/title shell is hidden to preserve transcript space. Tapping the active conversation opens a lightweight dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
- On mobile direct-chat threads, the single thread-wide Markdown/plain eye toggle floats above the transcript/composer area instead of occupying a second header row; desktop/tablet keeps the toggle in the thread header.
- Direct chat sessions can be renamed from the sidebar row edit button, the desktop conversation context menu, and the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
<!-- FNXC:ChatViewDocs 2026-07-01-00:00: Task-detail planner chats are intentionally hidden from the common Direct feed by default after issue #1850; Settings keeps an opt-in for operators who want populated task-planner sessions restored without adding a mandatory Tasks tab. -->

View File

@@ -769,10 +769,14 @@ Mobile chat session switching needs a dedicated rename tap target beside each se
}
/*
FNXC:ChatHeader 2026-07-02-00:00:
Mobile direct-chat detail uses ViewHeader as the only visible header row. Keep the Chat icon/title relationship available to assistive tech while visually collapsing only the text label, and let the moved session switcher consume the action row width without duplicating the old thread-header controls.
FNXC:ChatHeader 2026-07-02-17:26:
Mobile direct-chat detail uses ViewHeader as the only visible header row. The back arrow must be the far-left visible/focusable control and the session selector must stay on that same non-wrapping row, so hide the entire Chat title/icon shell from layout while retaining the accessible heading and make the actions cluster own the row from the left edge.
*/
.chat-view--mobile-direct-thread > .view-header .view-header__title span {
.chat-view--mobile-direct-thread > .view-header {
flex-wrap: nowrap;
}
.chat-view--mobile-direct-thread > .view-header .view-header__title {
position: absolute;
inline-size: var(--btn-border-width);
block-size: var(--btn-border-width);
@@ -784,6 +788,10 @@ Mobile direct-chat detail uses ViewHeader as the only visible header row. Keep t
.chat-view--mobile-direct-thread > .view-header .view-header__actions {
flex: 1 1 auto;
width: 100%;
min-width: 0;
margin-left: 0;
flex-wrap: nowrap;
justify-content: flex-start;
}
@@ -792,7 +800,8 @@ Mobile direct-chat detail uses ViewHeader as the only visible header row. Keep t
}
.chat-view--mobile-direct-thread .chat-mobile-session-menu {
flex: 1 1 auto;
flex: 1 1 0;
min-width: 0;
}
@media (max-width: 768px), (max-height: 480px) {

View File

@@ -2685,8 +2685,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
{showMobileDirectThreadHeaderControls ? (
<>
{/*
FNXC:ChatHeader 2026-07-02-00:00:
Mobile direct-thread view has a single top row: move back navigation and the active conversation switcher into ViewHeader so the transcript gains the height formerly consumed by a second thread header. The ViewHeader still owns the accessible Chat title; CSS only hides its visible text in this direct-thread mobile state.
FNXC:ChatHeader 2026-07-02-17:26:
Mobile direct-thread view has a single top row: back navigation must be the first visible/focusable control at the far-left edge and the active conversation switcher must stay beside it. The ViewHeader still owns the accessible Chat title; ChatView-scoped CSS hides the entire title/icon shell only in this direct-thread mobile state so it cannot reserve left-edge layout space.
*/}
<button className="btn-icon chat-back-btn" onClick={handleBack} data-testid="chat-back-btn" aria-label={t("chat.backToConversations", "Back to conversations")}>
<ChevronLeft size={16} />

View File

@@ -22,6 +22,7 @@ import {
createRoomFixture,
ensureMatchMedia,
installChatViewEnv,
mockFetchModels,
} from "./ChatView.test-harness";
// Mock the hooks
@@ -230,11 +231,20 @@ describe("ChatView mobile behavior", () => {
}
});
it("mobile mode: collapses direct thread controls into the top ViewHeader row", async () => {
it("mobile mode: collapses direct thread controls into one far-left ViewHeader row", async () => {
const restoreMatchMedia = mockMobileViewport();
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" },
activeSession: {
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",
},
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" }],
});
@@ -244,12 +254,21 @@ describe("ChatView mobile behavior", () => {
expect(document.querySelector(".chat-view--mobile-direct-thread")).toBeInTheDocument();
const viewHeader = document.querySelector(".view-header") as HTMLElement;
const headerActions = viewHeader.querySelector(".view-header__actions") as HTMLElement;
const headerTitle = viewHeader.querySelector(".view-header__title") as HTMLElement;
const backButton = screen.getByTestId("chat-back-btn");
const sessionTrigger = screen.getByTestId("chat-mobile-session-trigger");
const renderToggle = screen.getByTestId("chat-thread-render-toggle");
expect(viewHeader).toContainElement(backButton);
expect(viewHeader).toContainElement(sessionTrigger);
expect(headerActions).toContainElement(backButton);
expect(headerActions).toContainElement(sessionTrigger);
expect(headerActions.firstElementChild).toBe(backButton);
expect(backButton.compareDocumentPosition(sessionTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(headerTitle).not.toContainElement(backButton);
expect(headerTitle.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
expect(sessionTrigger).toHaveTextContent("M3");
expect(viewHeader).not.toContainElement(renderToggle);
expect(renderToggle).toHaveClass("chat-thread-header-render-toggle--floating");
expect(screen.getAllByTestId("chat-back-btn")).toHaveLength(1);
@@ -384,6 +403,44 @@ describe("ChatView mobile behavior", () => {
}
});
it("mobile mode: long duplicate session titles stay in the single-row switcher with Bot fallback", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();
const longTitle = "MiniMax M3 with an extraordinarily long duplicated conversation label that must truncate";
try {
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [], defaultProvider: null, defaultModelId: null });
const duplicateSessions = [
{ id: "session-001", agentId: "agent-unresolved", status: "active" as const, title: longTitle, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
{ id: "session-002", agentId: "agent-unresolved-2", status: "active" as const, title: longTitle, createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" },
];
setupMockChat({
sessions: duplicateSessions,
filteredSessions: duplicateSessions,
activeSession: duplicateSessions[0],
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()} />);
const headerActions = document.querySelector(".view-header__actions") as HTMLElement;
const backButton = screen.getByTestId("chat-back-btn");
const trigger = screen.getByTestId("chat-mobile-session-trigger");
expect(headerActions.firstElementChild).toBe(backButton);
expect(trigger).toHaveTextContent(longTitle);
expect(within(trigger).getByTestId("icon-bot")).toBeInTheDocument();
expect(screen.getAllByTestId("chat-mobile-session-trigger")).toHaveLength(1);
await userEvent.click(trigger);
const dropdown = screen.getByTestId("chat-mobile-session-dropdown");
expect(within(dropdown).getAllByText(longTitle)).toHaveLength(2);
await userEvent.click(screen.getByTestId("chat-mobile-session-option-session-002"));
expect(selectSession).toHaveBeenCalledWith("session-002");
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: ViewHeader session trigger opens quick session switcher and closes after selection", async () => {
const restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn();
@@ -1921,14 +1978,27 @@ describe("ChatView mobile CSS contract", () => {
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message\s*\{[^}]*max-width:\s*100%/);
});
it("mobile direct thread collapses header text and floats the render toggle with tokenized sizing", async () => {
const directTitleRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__title\s+span\s*\{([^}]*)\}/)?.[1] ?? "";
it("mobile direct thread keeps a non-wrapping far-left header row with tokenized sizing", async () => {
const headerRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s*\{([^}]*)\}/)?.[1] ?? "";
const directTitleRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__title\s*\{([^}]*)\}/)?.[1] ?? "";
const actionsRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__actions\s*\{([^}]*)\}/)?.[1] ?? "";
const menuRule = css.match(/\.chat-view--mobile-direct-thread\s+\.chat-mobile-session-menu\s*\{([^}]*)\}/)?.[1] ?? "";
const triggerRule = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/)?.[1] ?? "";
const floatingToggleRule = css.match(/\.chat-thread-header-render-toggle--floating\s*\{([^}]*)\}/)?.[1] ?? "";
expect(headerRule).toContain("flex-wrap: nowrap");
expect(directTitleRule).toContain("position: absolute");
expect(directTitleRule).toContain("inline-size: var(--btn-border-width)");
expect(directTitleRule).toContain("clip-path: inset(50%)");
expect(actionsRule).toContain("flex: 1 1 auto");
expect(actionsRule).toContain("width: 100%");
expect(actionsRule).toContain("margin-left: 0");
expect(actionsRule).toContain("flex-wrap: nowrap");
expect(actionsRule).toContain("justify-content: flex-start");
expect(menuRule).toContain("flex: 1 1 0");
expect(menuRule).toContain("min-width: 0");
expect(triggerRule).toContain("min-width: 0");
expect(triggerRule).toContain("gap: var(--space-sm)");
expect(triggerRule).not.toMatch(/#[0-9a-fA-F]{3,8}|rgb\(/);
expect(floatingToggleRule).toContain("position: absolute");
expect(floatingToggleRule).toContain("right: var(--space-md)");
expect(floatingToggleRule).toContain("bottom: calc((var(--space-lg) * 2.5) + (var(--space-md) * 2) + var(--space-sm))");