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. - 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. - 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, 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. - 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: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. --> <!-- 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; 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) { @media (max-width: 768px), (max-height: 480px) {
.chat-view-header-scope-toggle { .chat-view-header-scope-toggle {
flex-basis: clamp(112px, 42vw, 180px); 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 FNXC:ChatRenderMode 2026-07-02-00:00:
* used to live inside every assistant bubble. */ 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 { .chat-thread-header-render-toggle {
margin-left: auto; margin-left: auto;
flex-shrink: 0; 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); 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 */ /* Messages */
.chat-messages { .chat-messages {
flex: 1 1 auto; flex: 1 1 auto;

View File

@@ -2151,6 +2151,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
}) })
: null; : null;
const showMobileSessionSwitcher = isChatMobile && chatScope === "direct" && !!activeSession; const showMobileSessionSwitcher = isChatMobile && chatScope === "direct" && !!activeSession;
const showMobileDirectThreadHeaderControls = isChatMobile && chatScope === "direct" && hasThreadInView;
const agentName = const agentName =
agentsMap.get(activeSession?.agentId ?? "")?.name || agentsMap.get(activeSession?.agentId ?? "")?.name ||
@@ -2578,6 +2579,71 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
FNXC:ChatHeader 2026-06-22-18:44: 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. 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 ? ( const scopeToggle = chatRoomsEnabled ? (
<div className="chat-sidebar-scope-toggle chat-view-header-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle"> <div className="chat-sidebar-scope-toggle chat-view-header-scope-toggle" role="tablist" data-testid="chat-sidebar-scope-toggle">
<button <button
@@ -2610,13 +2676,26 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
FNXC:Chat 2026-06-22-12:55: 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. 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 <ViewHeader
icon={MessageSquare} icon={MessageSquare}
title={t("chat.title", "Chat")} title={t("chat.title", "Chat")}
actions={ 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 ? ( {!isChatMobile ? (
<button <button
className="btn btn-sm btn-primary chat-view-header-new-chat" 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>
) : ( ) : (
<div ref={chatThreadRef} className="chat-thread"> <div ref={chatThreadRef} className="chat-thread">
{/* Header - always rendered in desktop/tablet, only rendered in mobile when viewing a thread */} {/* Header - desktop/tablet keeps the thread identity row; mobile direct-thread controls move into ViewHeader. */}
{(hasThreadInView || !isChatMobile) && ( {!isChatMobile && (hasThreadInView || !isChatMobile) && (
<div className="chat-thread-header"> <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"> <div className="chat-thread-header-identity" data-testid="chat-thread-header-identity">
{showMobileSessionSwitcher ? ( {activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
<div className="chat-mobile-session-menu" ref={mobileSessionMenuRef}> <span className="chat-thread-header-title">{threadHeaderTitle}</span>
<button {showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
type="button" {showThreadHeaderContextWindow && threadHeaderContextTotal && threadHeaderContextLabel ? (
className="btn chat-mobile-session-trigger" <span
data-testid="chat-mobile-session-trigger" className="chat-thread-header-context"
aria-haspopup="menu" data-testid="chat-thread-context-window"
aria-expanded={mobileSessionMenuOpen} title={threadHeaderContextLabel}
onClick={() => setMobileSessionMenuOpen((open) => !open)} aria-label={threadHeaderContextLabel}
> >
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />} {threadHeaderContextUsed} / {threadHeaderContextTotal}
<span className="chat-thread-header-title">{threadHeaderTitle}</span> </span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>} ) : null}
<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}
</>
)}
</div> </div>
{hasThreadInView && ( {hasThreadInView && (
<button <button
@@ -3387,6 +3394,18 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div> </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 {/* Messages + composer. CLI-backed chat sessions delegate this
region to <CliChatSurface> (transcript/raw-terminal toggle + region to <CliChatSurface> (transcript/raw-terminal toggle +
queued composer); generic-tier adapters render terminal-only. */} 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", () => { describe("FN-3911 chat session list layout", () => {
const css = loadAllAppCss(); 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 titleMatch = css.match(/\.chat-session-title\s*\{([^}]*)\}/);
const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/); const previewMatch = css.match(/\.chat-session-preview\s*\{([^}]*)\}/);
expect(titleMatch).toBeTruthy(); expect(titleMatch).toBeTruthy();
expect(previewMatch).toBeTruthy(); expect(previewMatch).toBeTruthy();
expect(titleMatch?.[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]).toMatch(/padding-right:\s*calc\(var\(--space-md\)\s*\*\s*3\)/); 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( 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(); const css = loadAllAppCss();
it(".chat-session-delete-btn exists with opacity: 0", async () => { it(".chat-session-actions positions the edit/delete pair outside row text flow", async () => {
const match = css.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/); const actionsMatch = css.match(/\.chat-session-actions\s*\{([^}]*)\}/);
expect(match).toBeTruthy(); const actionButtonMatch = css.match(/\.chat-session-action-btn\s*\{([^}]*)\}/);
expect(match![1]).toContain("opacity: 0"); 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 () => { it("FN-4352: mobile row action buttons stay compact without min-size inflation", 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 () => {
const mobileRegex = /@media[^{]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g; const mobileRegex = /@media[^{]*\(max-width:\s*768px\)[^{]*\{([\s\S]*?)\n\}/g;
let match; let match;
let deleteRule = ""; let actionRule = "";
while ((match = mobileRegex.exec(css)) !== null) { while ((match = mobileRegex.exec(css)) !== null) {
const mediaContent = match[1]; const mediaContent = match[1];
if (mediaContent.includes(".chat-session-delete-btn")) { if (mediaContent.includes(".chat-session-title")) {
deleteRule = mediaContent.match(/\.chat-session-delete-btn\s*\{([^}]*)\}/)?.[1] ?? ""; actionRule = mediaContent.match(/\.chat-session-title,\s*\.chat-session-preview\s*\{([^}]*)\}/)?.[1] ?? "";
if (deleteRule) break; if (actionRule) break;
} }
} }
expect(deleteRule).toContain("opacity: 1"); expect(actionRule).toContain("padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2))");
expect(deleteRule).not.toContain("min-width:"); expect(actionRule).not.toContain("min-width:");
expect(deleteRule).not.toContain("min-height:"); 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 // Thread header should not be rendered when there's no active session
expect(document.querySelector(".chat-thread-header")).not.toBeInTheDocument(); 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-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 { } finally {
restoreMatchMedia.mockRestore(); 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(); const restoreMatchMedia = mockMobileViewport();
try { try {
setupMockChat({ setupMockChat({
@@ -237,10 +240,51 @@ describe("ChatView mobile behavior", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />); 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")).not.toBeInTheDocument();
expect(document.querySelector(".chat-thread-header")).toBeInTheDocument(); expect(document.querySelector(".chat-view--mobile-direct-thread")).toBeInTheDocument();
// Back button should be visible in mobile thread view
expect(screen.getByTestId("chat-back-btn")).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 { } finally {
restoreMatchMedia.mockRestore(); 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 restoreMatchMedia = mockMobileViewport();
const selectSession = vi.fn(); const selectSession = vi.fn();
try { 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%/); 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 () => { it("mobile direct thread collapses header text and floats the render toggle with tokenized sizing", async () => {
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header\s*\{[^}]*flex-wrap:\s*nowrap/); const directTitleRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__title\s+span\s*\{([^}]*)\}/)?.[1] ?? "";
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/); const actionsRule = css.match(/\.chat-view--mobile-direct-thread\s*>\s*\.view-header\s+\.view-header__actions\s*\{([^}]*)\}/)?.[1] ?? "";
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-render-toggle\s*\{[^}]*flex-shrink:\s*0/); 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 () => { it("FN-4352: response copy action stays compact on mobile", async () => {