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:
7
.changeset/fn-7450-mobile-chat-header.md
Normal file
7
.changeset/fn-7450-mobile-chat-header.md
Normal 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.
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user