FN-7450: collapse mobile chat header rows

Collapse the mobile direct chat header into the shared top row.

- Move the mobile direct-thread back button and session switcher into ViewHeader actions.
- Hide the redundant mobile thread header while preserving desktop thread identity and context details.
- Update mobile chat styling, docs, changeset, and header contract tests for the single-row layout.

Files changed:
 .changeset/fn-7450-mobile-chat-header.md           |   7 +
 docs/dashboard-guide.md                            |   3 +-
 packages/dashboard/app/components/ChatView.css     |  53 +++++-
 packages/dashboard/app/components/ChatView.tsx     | 197 +++++++++++----------
 .../__tests__/ChatView.core-contracts.test.tsx     |  48 ++---
 .../components/__tests__/ChatView.mobile.test.tsx  |  76 ++++++--
 6 files changed, 256 insertions(+), 128 deletions(-)

Fusion-Task-Id: FN-7450

Fusion-Task-Lineage: c266a9b8-7fb1-410e-9c35-adad4c6a2d47

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 14:09:35 -07:00
parent 22fd0da2ad
commit 1d6011c3a6
6 changed files with 256 additions and 128 deletions

View File

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

View File

@@ -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.
<!-- 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. -->
<!-- FNXC:TaskDetailPlannerChat 2026-07-01-22:02: Done-task planner Chat remains available for retrospective Q&A and can create a task-scoped refinement through the planner tool, while common Chat feed visibility remains opt-in. -->

View File

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

View File

@@ -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 ? (
<div className="chat-mobile-session-menu" ref={mobileSessionMenuRef}>
<button
type="button"
className="btn chat-mobile-session-trigger"
data-testid="chat-mobile-session-trigger"
aria-haspopup="menu"
aria-expanded={mobileSessionMenuOpen}
onClick={() => setMobileSessionMenuOpen((open) => !open)}
>
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
<ChevronDown size={16} aria-hidden="true" />
</button>
{mobileSessionMenuOpen && (
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
{filteredSessions.map((session) => (
<div
key={session.id}
className={`chat-mobile-session-option-row${activeSession?.id === session.id ? " chat-mobile-session-option-row--active" : ""}`}
role="none"
>
<button
type="button"
role="menuitem"
className={`chat-mobile-session-option${activeSession?.id === session.id ? " chat-mobile-session-option--active" : ""}`}
data-testid={`chat-mobile-session-option-${session.id}`}
onClick={() => handleSessionClick(session.id)}
>
<span className="chat-mobile-session-option-title">{session.title || t("chat.untitledSession", "Untitled")}</span>
</button>
<button
type="button"
className="btn-icon chat-mobile-session-rename"
data-testid={`chat-mobile-session-rename-${session.id}`}
aria-label={t("chat.renameConversationAria", "Rename conversation {{title}}", { title: session.title || t("chat.untitledSession", "Untitled") })}
onClick={() => openRenameDialog(session.id)}
>
<Pencil size={14} />
</button>
</div>
))}
{/*
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.
*/}
<button
type="button"
role="menuitem"
className="chat-mobile-session-new"
data-testid="chat-mobile-session-new"
onClick={() => {
setMobileSessionMenuOpen(false);
setShowNewDialog(true);
}}
>
<Plus size={16} aria-hidden="true" />
<span>{t("chat.newChat", "New Chat")}</span>
</button>
</div>
)}
</div>
) : null;
const scopeToggle = chatRoomsEnabled ? (
<div className="chat-sidebar-scope-toggle chat-view-header-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
<button
@@ -2610,13 +2676,26 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
FNXC:Chat 2026-06-22-12:55:
Chat uses the shared ViewHeader so its page chrome matches the other main-content views. The height-sensitive two-pane chat layout remains isolated in .chat-view__body beneath that header, preserving sidebar resize, thread scrolling, and mobile keyboard compensation while moving the desktop New Chat action into the canonical header actions cluster.
*/
<div ref={chatViewRef} className={`chat-view${floating ? " chat-view--floating" : ""}${isChatMobile ? " chat-view--narrow" : ""}`}>
<div ref={chatViewRef} className={`chat-view${floating ? " chat-view--floating" : ""}${isChatMobile ? " chat-view--narrow" : ""}${showMobileDirectThreadHeaderControls ? " chat-view--mobile-direct-thread" : ""}`}>
<ViewHeader
icon={MessageSquare}
title={t("chat.title", "Chat")}
actions={
<>
{scopeToggle}
{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.
*/}
<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} />
</button>
{mobileDirectSessionSwitcher}
</>
) : (
scopeToggle
)}
{!isChatMobile ? (
<button
className="btn btn-sm btn-primary chat-view-header-new-chat"
@@ -3283,95 +3362,23 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div>
) : (
<div ref={chatThreadRef} className="chat-thread">
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */}
{(hasThreadInView || !isChatMobile) && (
{/* Header - desktop/tablet keeps the thread identity row; mobile direct-thread controls move into ViewHeader. */}
{!isChatMobile && (hasThreadInView || !isChatMobile) && (
<div className="chat-thread-header">
{isChatMobile && hasThreadInView && (
<button className="btn-icon" onClick={handleBack} data-testid="chat-back-btn">
<ChevronLeft size={16} />
</button>
)}
<div className="chat-thread-header-identity" data-testid="chat-thread-header-identity">
{showMobileSessionSwitcher ? (
<div className="chat-mobile-session-menu" ref={mobileSessionMenuRef}>
<button
type="button"
className="btn chat-mobile-session-trigger"
data-testid="chat-mobile-session-trigger"
aria-haspopup="menu"
aria-expanded={mobileSessionMenuOpen}
onClick={() => setMobileSessionMenuOpen((open) => !open)}
>
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
<ChevronDown size={16} aria-hidden="true" />
</button>
{mobileSessionMenuOpen && (
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
{filteredSessions.map((session) => (
<div
key={session.id}
className={`chat-mobile-session-option-row${activeSession?.id === session.id ? " chat-mobile-session-option-row--active" : ""}`}
role="none"
>
<button
type="button"
role="menuitem"
className={`chat-mobile-session-option${activeSession?.id === session.id ? " chat-mobile-session-option--active" : ""}`}
data-testid={`chat-mobile-session-option-${session.id}`}
onClick={() => handleSessionClick(session.id)}
>
<span className="chat-mobile-session-option-title">{session.title || t("chat.untitledSession", "Untitled")}</span>
</button>
<button
type="button"
className="btn-icon chat-mobile-session-rename"
data-testid={`chat-mobile-session-rename-${session.id}`}
aria-label={t("chat.renameConversationAria", "Rename conversation {{title}}", { title: session.title || t("chat.untitledSession", "Untitled") })}
onClick={() => openRenameDialog(session.id)}
>
<Pencil size={14} />
</button>
</div>
))}
{/*
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.
*/}
<button
type="button"
role="menuitem"
className="chat-mobile-session-new"
data-testid="chat-mobile-session-new"
onClick={() => {
setMobileSessionMenuOpen(false);
setShowNewDialog(true);
}}
>
<Plus size={16} aria-hidden="true" />
<span>{t("chat.newChat", "New Chat")}</span>
</button>
</div>
)}
</div>
) : (
<>
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
{showThreadHeaderContextWindow && threadHeaderContextTotal && threadHeaderContextLabel ? (
<span
className="chat-thread-header-context"
data-testid="chat-thread-context-window"
title={threadHeaderContextLabel}
aria-label={threadHeaderContextLabel}
>
{threadHeaderContextUsed} / {threadHeaderContextTotal}
</span>
) : null}
</>
)}
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
{showThreadHeaderContextWindow && threadHeaderContextTotal && threadHeaderContextLabel ? (
<span
className="chat-thread-header-context"
data-testid="chat-thread-context-window"
title={threadHeaderContextLabel}
aria-label={threadHeaderContextLabel}
>
{threadHeaderContextUsed} / {threadHeaderContextTotal}
</span>
) : null}
</div>
{hasThreadInView && (
<button
@@ -3387,6 +3394,18 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div>
)}
{isChatMobile && hasThreadInView && (
<button
type="button"
className={`chat-thread-header-render-toggle chat-thread-header-render-toggle--floating${showAllAsPlain ? " chat-thread-header-render-toggle--plain" : ""}`}
data-testid="chat-thread-render-toggle"
aria-label={showAllAsPlain ? t("chat.showRenderedMarkdown", "Show all messages as rendered Markdown") : t("chat.showPlainText", "Show all messages as plain text")}
onClick={toggleAllAsPlain}
>
{showAllAsPlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
)}
{/* Messages + composer. CLI-backed chat sessions delegate this
region to <CliChatSurface> (transcript/raw-terminal toggle +
queued composer); generic-tier adapters render terminal-only. */}

View File

@@ -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:");
});
});

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// 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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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 () => {