diff --git a/.changeset/fn-7450-mobile-chat-header.md b/.changeset/fn-7450-mobile-chat-header.md new file mode 100644 index 0000000000..dce5e39365 --- /dev/null +++ b/.changeset/fn-7450-mobile-chat-header.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Collapse mobile Chat thread controls into one compact header row. +category: feature +dev: Mobile direct-chat moves back/session controls into ViewHeader and floats the Markdown/plain toggle. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d0064cb0c3..5f2d0a5a31 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -411,7 +411,8 @@ 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, tapping the active title/identity in the thread header opens a lightweight conversation 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 now 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 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 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. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 104f6af6ef..36a01af426 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -768,6 +768,33 @@ Mobile chat session switching needs a dedicated rename tap target beside each se min-width: 0; } +/* +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. +*/ +.chat-view--mobile-direct-thread > .view-header .view-header__title span { + position: absolute; + inline-size: var(--btn-border-width); + block-size: var(--btn-border-width); + margin: calc(var(--btn-border-width) * -1); + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.chat-view--mobile-direct-thread > .view-header .view-header__actions { + flex: 1 1 auto; + justify-content: flex-start; +} + +.chat-view--mobile-direct-thread .chat-back-btn { + flex: 0 0 auto; +} + +.chat-view--mobile-direct-thread .chat-mobile-session-menu { + flex: 1 1 auto; +} + @media (max-width: 768px), (max-height: 480px) { .chat-view-header-scope-toggle { flex-basis: clamp(112px, 42vw, 180px); @@ -911,9 +938,10 @@ In the narrow/mobile chat layout there is no room for an expand/maximize afforda } } -/* Single thread-wide markdown / plain-text toggle, anchored to the right of - * the header next to "New Chat". Replaces the per-message eye toggle that - * used to live inside every assistant bubble. */ +/* +FNXC:ChatRenderMode 2026-07-02-00:00: +Chat keeps one thread-wide Markdown/plain control: desktop/tablet anchors it in the thread identity header, while mobile direct-thread view floats the same button above the transcript/composer so it does not reintroduce a second header row or per-message toggles. +*/ .chat-thread-header-render-toggle { margin-left: auto; flex-shrink: 0; @@ -948,6 +976,25 @@ In the narrow/mobile chat layout there is no room for an expand/maximize afforda color: var(--text); } +.chat-thread-header-render-toggle--floating { + position: absolute; + right: var(--space-md); + bottom: calc((var(--space-lg) * 2.5) + (var(--space-md) * 2) + var(--space-sm)); + z-index: 3; + width: calc(var(--space-lg) * 2.25); + height: calc(var(--space-lg) * 2.25); + min-width: calc(var(--space-lg) * 2.25); + min-height: calc(var(--space-lg) * 2.25); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-full); + background: var(--surface); + box-shadow: var(--shadow-md); +} + +.chat-thread-header-render-toggle--floating:hover { + background: var(--surface-hover); +} + /* Messages */ .chat-messages { flex: 1 1 auto; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 0436c64fd2..530f137545 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -2151,6 +2151,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout }) : null; const showMobileSessionSwitcher = isChatMobile && chatScope === "direct" && !!activeSession; + const showMobileDirectThreadHeaderControls = isChatMobile && chatScope === "direct" && hasThreadInView; const agentName = agentsMap.get(activeSession?.agentId ?? "")?.name || @@ -2578,6 +2579,71 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout FNXC:ChatHeader 2026-06-22-18:44: Very narrow chat headers collapse Direct/Rooms to icons while retaining aria-selected tabs and text labels for wider headers. The segmented control must stay height-aligned with the ViewHeader action row, so icon+label markup is stable and CSS hides only the label. */ + const mobileDirectSessionSwitcher = showMobileSessionSwitcher ? ( +
+ + {mobileSessionMenuOpen && ( +
+ {filteredSessions.map((session) => ( +
+ + +
+ ))} + {/* + FNXC:Chat 2026-06-27-00:00: + Mobile Direct-scope quick session switching must let users start a new chat without leaving the open thread. Route this affordance through the same setShowNewDialog(true) / NewChatDialog path as the header and sidebar-footer controls. + */} + +
+ )} +
+ ) : null; + const scopeToggle = chatRoomsEnabled ? (
+ {mobileDirectSessionSwitcher} + + ) : ( + scopeToggle + )} {!isChatMobile ? ( - )}
- {showMobileSessionSwitcher ? ( -
- - {mobileSessionMenuOpen && ( -
- {filteredSessions.map((session) => ( -
- - -
- ))} - {/* - FNXC:Chat 2026-06-27-00:00: - Mobile Direct-scope quick session switching must let users start a new chat without leaving the open thread. Route this affordance through the same setShowNewDialog(true) / NewChatDialog path as the header and sidebar-footer controls. - */} - -
- )} -
- ) : ( - <> - {activeModelProvider ? : } - {threadHeaderTitle} - {showThreadHeaderModelTag && {activeModelTag}} - {showThreadHeaderContextWindow && threadHeaderContextTotal && threadHeaderContextLabel ? ( - - {threadHeaderContextUsed} / {threadHeaderContextTotal} - - ) : null} - - )} + {activeModelProvider ? : } + {threadHeaderTitle} + {showThreadHeaderModelTag && {activeModelTag}} + {showThreadHeaderContextWindow && threadHeaderContextTotal && threadHeaderContextLabel ? ( + + {threadHeaderContextUsed} / {threadHeaderContextTotal} + + ) : null}
{hasThreadInView && ( + )} + {/* Messages + composer. CLI-backed chat sessions delegate this region to (transcript/raw-terminal toggle + queued composer); generic-tier adapters render terminal-only. */} diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx index 7c52b3e8e2..9989fda588 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx @@ -526,52 +526,52 @@ describe("ChatView CSS — active state edge highlights", () => { describe("FN-3911 chat session list layout", () => { const css = loadAllAppCss(); - it("reserves right padding on title and preview rows so text clears the delete button", async () => { + it("reserves right padding on title and preview rows so text clears the edit/delete action pair", async () => { const titleMatch = css.match(/\.chat-session-title\s*\{([^}]*)\}/); const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/); expect(titleMatch).toBeTruthy(); expect(previewMatch).toBeTruthy(); - expect(titleMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); - expect(previewMatch?.[1]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); + expect(titleMatch?.[1]).toContain("padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2))"); + expect(previewMatch?.[1]).toContain("padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2))"); }); - it("FN-4385: keeps mobile title/preview clearance matched to compact delete button", async () => { + it("FN-4385/FN-7441: keeps mobile title/preview clearance matched to compact row action pair", async () => { expect(css).toMatch( - /@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-session-title,\s*\.chat-session-preview\s*\{\s*padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\);\s*\}/, + /@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-session-title,\s*\.chat-session-preview\s*\{\s*padding-right:\s*calc\(\(var\(--space-md\)\s*\*\s*4\)\s*\+\s*\(var\(--space-xs\)\s*\*\s*2\)\);\s*\}/, ); }); }); -describe("Chat Session Delete Button CSS", () => { +describe("Chat Session Row Action CSS", () => { const css = loadAllAppCss(); - it(".chat-session-delete-btn exists with opacity: 0", async () => { - const match = css.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("opacity: 0"); + it(".chat-session-actions positions the edit/delete pair outside row text flow", async () => { + const actionsMatch = css.match(/\.chat-session-actions\s*\{([^}]*)\}/); + const actionButtonMatch = css.match(/\.chat-session-action-btn\s*\{([^}]*)\}/); + expect(actionsMatch).toBeTruthy(); + expect(actionButtonMatch).toBeTruthy(); + expect(actionsMatch![1]).toContain("position: absolute"); + expect(actionsMatch![1]).toContain("right: var(--space-sm)"); + expect(actionsMatch![1]).toContain("gap: var(--space-xs)"); + expect(actionButtonMatch![1]).toContain("width: calc(var(--space-md) * 2)"); + expect(actionButtonMatch![1]).toContain("min-height: calc(var(--space-md) * 2)"); }); - it(".chat-session-item:hover .chat-session-delete-btn has opacity: 1", async () => { - const match = css.match(/\.chat-session-item:hover\s*\.chat-session-delete-btn\s*\{([^}]*)\}/); - expect(match).toBeTruthy(); - expect(match![1]).toContain("opacity: 1"); - }); - - it("FN-4352: mobile delete button stays visible without min-size inflation", async () => { + it("FN-4352: mobile row action buttons stay compact without min-size inflation", async () => { const mobileRegex = /@media[^{]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; let match; - let deleteRule = ""; + let actionRule = ""; while ((match = mobileRegex.exec(css)) !== null) { const mediaContent = match[1]; - if (mediaContent.includes(".chat-session-delete-btn")) { - deleteRule = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/)?.[1] ?? ""; - if (deleteRule) break; + if (mediaContent.includes(".chat-session-title")) { + actionRule = mediaContent.match(/\.chat-session-title,\s*\.chat-session-preview\s*\{([^}]*)\}/)?.[1] ?? ""; + if (actionRule) break; } } - expect(deleteRule).toContain("opacity: 1"); - expect(deleteRule).not.toContain("min-width:"); - expect(deleteRule).not.toContain("min-height:"); + expect(actionRule).toContain("padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2))"); + expect(actionRule).not.toContain("min-width:"); + expect(actionRule).not.toContain("min-height:"); }); }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx index 7d97ca66dc..19eb676322 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx @@ -220,14 +220,17 @@ describe("ChatView mobile behavior", () => { // Thread header should not be rendered when there's no active session expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument(); - // Back button should not be visible + // Back/session controls should not appear before a direct thread is selected; ordinary Chat header remains visible. expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument(); + expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Chat" })).toBeInTheDocument(); + expect(document.querySelector(".chat-view--mobile-direct-thread")).not.toBeInTheDocument(); } finally { restoreMatchMedia.mockRestore(); } }); - it("mobile mode: renders thread header with back button when session is active", async () => { + it("mobile mode: collapses direct thread controls into the top ViewHeader row", async () => { const restoreMatchMedia = mockMobileViewport(); try { setupMockChat({ @@ -237,10 +240,51 @@ describe("ChatView mobile behavior", () => { await renderWithAct(); - // Thread header should be rendered when there's an active session - expect(document.querySelector(".chat-thread-header")).toBeInTheDocument(); - // Back button should be visible in mobile thread view - expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument(); + expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument(); + expect(document.querySelector(".chat-view--mobile-direct-thread")).toBeInTheDocument(); + + const viewHeader = document.querySelector(".view-header") 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(viewHeader).not.toContainElement(renderToggle); + expect(renderToggle).toHaveClass("chat-thread-header-render-toggle--floating"); + expect(screen.getAllByTestId("chat-back-btn")).toHaveLength(1); + expect(screen.getAllByTestId("chat-mobile-session-trigger")).toHaveLength(1); + expect(screen.getByRole("heading", { name: "Chat" })).toBeInTheDocument(); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("mobile mode: floating render toggle flips persisted and streaming assistant output", 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" }, + messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted** reply", createdAt: "2026-04-08T00:00:00.000Z" }], + isStreaming: true, + streamingText: "**Live** reply", + }); + + await renderWithAct(); + + const toggle = screen.getByTestId("chat-thread-render-toggle"); + const persistedBubble = screen.getByTestId("chat-message-msg-001"); + const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement; + + expect(toggle).toHaveClass("chat-thread-header-render-toggle--floating"); + expect(screen.queryAllByTestId("chat-message-render-toggle")).toHaveLength(0); + expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument(); + expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument(); + + await userEvent.click(toggle); + + expect(within(persistedBubble).getByText(/\*\*Persisted\*\* reply/)).toBeInTheDocument(); + expect(within(streamingBubble).getByText(/\*\*Live\*\* reply/)).toBeInTheDocument(); } finally { restoreMatchMedia.mockRestore(); } @@ -340,7 +384,7 @@ describe("ChatView mobile behavior", () => { } }); - it("mobile mode: thread header title opens quick session switcher and closes after selection", async () => { + it("mobile mode: ViewHeader session trigger opens quick session switcher and closes after selection", async () => { const restoreMatchMedia = mockMobileViewport(); const selectSession = vi.fn(); try { @@ -1877,10 +1921,20 @@ 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 keeps thread-header identity and render toggle inline", async () => { - expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header\s*\{[^}]*flex-wrap:\s*nowrap/); - expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*flex:\s*1\s+1\s+auto[^}]*white-space:\s*nowrap/); - expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-render-toggle\s*\{[^}]*flex-shrink:\s*0/); + 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] ?? ""; + const actionsRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__actions\s*\{([^}]*)\}/)?.[1] ?? ""; + const floatingToggleRule = css.match(/\.chat-thread-header-render-toggle--floating\s*\{([^}]*)\}/)?.[1] ?? ""; + + expect(directTitleRule).toContain("inline-size: var(--btn-border-width)"); + expect(directTitleRule).toContain("clip-path: inset(50%)"); + expect(actionsRule).toContain("flex: 1 1 auto"); + 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))"); + expect(floatingToggleRule).toContain("min-width: calc(var(--space-lg) * 2.25)"); + expect(floatingToggleRule).toContain("min-height: calc(var(--space-lg) * 2.25)"); + expect(floatingToggleRule).toContain("box-shadow: var(--shadow-md)"); }); it("FN-4352: response copy action stays compact on mobile", async () => {