FN-054: add list-first chat conversation navigation

Move chat conversations to list-first navigation with explicit detail entry and back navigation.

- Replace room-switcher dropdown contracts with list-to-detail coverage.
- Update room composer, thread, gesture, and scroll tests for explicit detail selection.
- Remove obsolete room-switcher UI and refresh dashboard documentation and release metadata.

Files changed:
 .changeset/fn-054-removal.md                       |    7 +
 docs/dashboard-guide.md                            |   22 +-
 packages/dashboard/app/App.tsx                     |    2 +-
 packages/dashboard/app/components/ChatView.css     |  365 +--
 packages/dashboard/app/components/ChatView.tsx     |  446 +---
 .../__tests__/ChatView.context-window.test.tsx     |   19 +-
 .../__tests__/ChatView.core-contracts.test.tsx     |  158 +-
 .../__tests__/ChatView.mobile-render.test.tsx      |  251 +-
 .../components/__tests__/ChatView.mobile.test.tsx  | 2567 +-------------------
 .../__tests__/ChatView.new-chat-default.test.tsx   |   20 +-
 .../components/__tests__/ChatView.rooms.test.tsx   |  346 +--
 .../__tests__/ChatView.sessions-rooms.test.tsx     |  163 +-
 .../__tests__/ChatView.swipe-back.test.tsx         |   27 +-
 .../components/__tests__/ChatView.test-harness.tsx |    5 +-
 .../app/components/overflowViewRegistry.tsx        |    2 +-
 packages/dashboard/vitest.config.ts                |   12 +-
 16 files changed, 419 insertions(+), 3993 deletions(-)

Fusion-Task-Id: FN-054

Fusion-Task-Lineage: 6b139504-ecf3-4b29-ab72-34a4d593676c

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 22:17:44 +00:00
parent 2c16a7e9ba
commit 4d74560ae0
16 changed files with 428 additions and 4002 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Simplify Chat navigation to a conversation list and full-pane detail.
category: fix
dev: Removes the split history pane, resize handle, and in-detail conversation selector.

View File

@@ -731,7 +731,9 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
## Chat View
Chat view provides project-scoped conversations with agents. The default conversation list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action.
Chat view provides project-scoped conversations with agents. Every host—embedded Chat, Quick Chat, and the dock—starts at the conversation list. Select a conversation to open its thread as the full pane, then use **Back to conversations** to return to the loaded list without interrupting the thread.
The list owns **New Chat**, search and tag filters, archived/restore, and each row's rename, pin, archive, and delete actions. Detail intentionally contains only the thread and Back: it does not include a conversation selector or duplicate management controls. The default list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action.
### Conversation layout
@@ -774,12 +776,12 @@ Mailbox Inbox, Outbox, and agent lists exclude archived correspondence and unrea
- If you queue follow-up user messages while the assistant is still streaming, Chat persists them per session, stacks each queued preview above the input box with one shared divider, and restores/sends them one at a time in FIFO order once each active response finishes if you leave and return.
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
- On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail.
- On mobile direct-chat threads, the top Chat header collapses into one compact row: the back button is the far-left visible control and the active conversation dropdown stays beside it, while the visible Chat icon/title shell is hidden to preserve transcript space. The dropdown trigger shows the conversation title with the provider/model logo only (no model-name text), and tapping it opens a lightweight dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
- On mobile direct-chat threads, the top Chat header keeps one compact **Back to conversations** control. Conversation switching and management remain in the list, so no active-conversation dropdown or duplicate detail action shell competes with the thread.
- 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:ChatPinned 2026-07-16-12:00: Document the Direct-only pin contract across desktop and mobile surfaces, including the durable server-side scope limit. -->
<!-- FNXC:ChatPinned 2026-07-19-00:00: Pinned and unpinned Direct conversations must remain distinct named groups in every ChatView host, including the mobile switcher. -->
- You can pin up to **3** active Direct conversations per project scope from a sidebar row, desktop right-click menu, or the mobile session switcher. Pinned conversations show an indicator, sort above recent unpinned conversations, and appear in separately labeled **Pinned** and **Recent** sections on both desktop and mobile. The server serializes each scope's pin changes (including null-project/default sessions) so concurrent requests cannot exceed the limit. Archiving always removes a pin, whether archived through either archive path; archived conversations cannot be pinned.
- Direct chat sessions can be renamed from the conversation list row action menu; blank rename submissions clear the custom title so the default session label is shown again.
<!-- FNXC:ChatPinned 2026-07-16-12:00: Document the Direct-only pin contract across desktop and mobile list surfaces, including the durable server-side scope limit. -->
<!-- FNXC:ChatPinned 2026-08-19-21:10: Pinned and unpinned Direct conversations remain distinct named groups in every ChatView list; selected detail does not duplicate list controls. -->
- You can pin up to **3** active Direct conversations per project scope from its list-row action menu. Pinned conversations show an indicator, sort above recent unpinned conversations, and appear in separately labeled **Pinned** and **Recent** sections on both desktop and mobile lists. The server serializes each scope's pin changes (including null-project/default sessions) so concurrent requests cannot exceed the limit. Archiving always removes a pin, whether archived through either archive path; archived conversations cannot be pinned.
<!-- 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. -->
- Task-detail planner Chat conversations stay available from each task's **Chat** tab, including after the task is `done`. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. On completed tasks, clear follow-up implementation or improvement requests can create a normal refinement task from the completed source task.
@@ -850,12 +852,10 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio
- Supports `/skill:{name}` in model-loop chat to request a specific enabled skill for that session; slash/catalog forms such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token, and the slash token is removed from the model prompt while the original user message remains in chat history
- Uses the same model/provider infrastructure as full Chat view
- On small screens, compact tool-call summaries in the floating panel intentionally stay single-line (count + tool names + status) to preserve message density
- The panel header uses a session-first flow: the main dropdown lists persisted sessions (preferring `session.title`, then falling back to deterministic `Session N` labels)
- Quick Chat sessions can be renamed from the session dropdown, and the active title is shown in the header so custom names remain visible after the dropdown closes.
- Selecting a session from that dropdown resumes the persisted conversation; this keeps `switchSession()` resume-oriented rather than forcing a new thread
- Quick Chat uses the same list-first flow as embedded and dock Chat: its conversation list shows persisted sessions (preferring `session.title`, then falling back to deterministic `Session N` labels).
- Select a session to resume it in the full panel, then use **Back to conversations** to return to the list. Rename, pin, archive, delete, and New Chat stay in that list rather than appearing in the selected thread.
- Entering `/new` or `/clear` (exact match after trimming) in the Quick Chat composer clears the active thread target: direct/model targets use `startFreshSession(...)`, while room targets call `rooms.clearRoom(activeRoom.id)`.
- The `+` action opens an inline new-session chooser (inside the panel, not a modal) with `Model` selected by default and optional switch to `Agent`
- Submitting the inline chooser uses explicit fresh-session creation and immediately persists/selects the new thread, then refreshes the session dropdown list
- **New Chat** is available from the Quick Chat conversation list. Creating a session selects its new full-pane thread immediately; return with **Back to conversations** to browse or manage sessions.
- On first open for a project, Quick Chat restores the last opened non-archived session from per-project local storage; if that saved session is missing, it falls back to the most recently touched non-archived session by latest activity (`max(lastMessageAt, updatedAt)`), and only falls back to the first agent / configured default model when no prior session exists.
- Closing or minimizing Quick Chat keeps the active conversation, panel geometry, and message-list scroll position warm in memory. Reopening is an instant visibility restore with no conversation reload, layout reflow, or "Loading conversation…" flash.
- Clicking outside the desktop Quick Chat floating window closes it by default; disable **Settings → General → Close Quick Chat on outside click** to keep it open until you explicitly close/minimize/maximize it. Model, thinking-level, agent, dependency, node, and priority dropdowns that open from Quick Chat are treated as part of the panel even when they render in a page-level portal, so selecting from them does not close Quick Chat. Task pop-out floating windows remain persistent on page clicks.

View File

@@ -2165,7 +2165,7 @@ function AppInner() {
defaultSize={{ width: 980, height: 680 }}
/*
FNXC:ChatModal 2026-06-23-22:14:
The full Chat pop-out must be resizable into a very narrow desktop utility window. ChatView already switches to its mobile one-pane layout at narrow widths, so allow the FloatingWindow to shrink below the old two-pane desktop minimum while preserving enough width for composer controls.
The full Chat pop-out must be resizable into a very narrow desktop utility window. ChatView uses the same full-pane list/detail flow at narrow widths, so allow the FloatingWindow to shrink below the old two-pane desktop minimum while preserving enough width for composer controls.
*/
minSize={{ width: 300, height: 420 }}
>

View File

@@ -20,12 +20,11 @@
overflow: hidden;
}
/* Sidebar */
/* Conversation list */
.chat-sidebar {
min-width: 0;
max-width: 500px; /* FN-6210: defensive cap — matches CHAT_SIDEBAR_MAX_WIDTH */
border-right: 1px solid var(--border);
display: flex;
flex: 1 1 auto;
flex-direction: column;
background: var(--bg-secondary);
}
@@ -34,36 +33,6 @@
display: none;
}
.chat-sidebar-resize-handle {
position: relative;
width: var(--space-sm);
flex-shrink: 0;
cursor: col-resize;
background: transparent;
touch-action: none;
transition: background var(--transition-fast);
}
.chat-sidebar-resize-handle::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: var(--space-xs);
transform: translateX(-50%);
}
.chat-sidebar-resize-handle:hover::before,
.chat-sidebar-resize-handle:active::before {
background: color-mix(in srgb, var(--todo) 30%, transparent);
}
.chat-sidebar-resize-handle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-sidebar-scope-toggle {
display: flex;
gap: var(--space-xs);
@@ -411,12 +380,6 @@ FN-8173 replaces the inline Pin/Rename/Delete cluster with one overflow trigger.
color: var(--text);
}
.chat-mobile-session-pin:disabled {
color: var(--text-dim);
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.chat-session-pinned-indicator {
flex: 0 0 auto;
color: var(--color-warning);
@@ -528,7 +491,7 @@ The active-chat pane header must give the title the full available line to the L
/*
FNXC:ChatContextWindow 2026-06-27-00:00:
The Direct-chat context budget indicator is a secondary desktop/tablet header affordance. Keep it badge-like and shrinkable so the thread title and render toggle retain priority, and hide it at mobile widths where the session switcher owns the header.
The Direct-chat context budget indicator is a secondary desktop/tablet header affordance. Keep it badge-like and shrinkable so the thread title retains priority, and hide it at mobile widths where Back is the only detail-navigation control.
*/
.chat-thread-header-context {
flex: 0 1 auto;
@@ -544,253 +507,14 @@ The Direct-chat context budget indicator is a secondary desktop/tablet header af
color: var(--text-muted);
}
.chat-mobile-session-menu {
position: relative;
min-width: 0;
max-width: 100%;
/* Detail has one route back to the list; conversation controls stay in the list. */
.chat-view--detail > .view-header {
flex-wrap: nowrap;
}
.chat-room-switcher-menu {
position: relative;
min-width: 0;
}
.chat-room-switcher-trigger {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
padding: 0;
font: inherit;
line-height: normal;
cursor: pointer;
}
.chat-room-switcher-trigger:hover {
color: var(--text);
background: var(--card-hover);
}
.chat-room-switcher-trigger:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-room-switcher-trigger > svg {
color: var(--text-muted);
flex-shrink: 0;
}
.chat-room-switcher-dropdown {
position: absolute;
top: calc(100% + var(--space-xs));
left: 0;
min-width: calc(var(--space-xl) * 6);
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-lg);
z-index: 4;
max-height: calc(var(--space-xl) * 10);
overflow-y: auto;
}
.chat-room-switcher-option {
width: 100%;
display: flex;
align-items: center;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: left;
padding: var(--space-sm) var(--space-md);
line-height: normal;
}
.chat-room-switcher-option:hover {
background: var(--card-hover);
}
.chat-room-switcher-option:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-room-switcher-option--active {
background: color-mix(in srgb, var(--todo) 12%, transparent);
}
.chat-mobile-session-trigger {
width: 100%;
max-width: 100%;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-sm);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text);
padding: var(--space-xs) var(--space-sm);
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
font: inherit;
line-height: normal;
text-align: left;
}
.chat-mobile-session-trigger .chat-thread-header-title {
.chat-view--detail > .view-header .view-header__actions {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-mobile-session-trigger .provider-icon,
.chat-mobile-session-trigger > svg:first-child {
flex-shrink: 0;
}
.chat-mobile-session-trigger > svg {
width: var(--icon-size-md);
height: var(--icon-size-md);
}
.chat-mobile-session-trigger svg:last-child {
margin-left: auto;
color: var(--text-muted);
flex-shrink: 0;
}
.chat-mobile-session-trigger:hover {
background: var(--card-hover);
}
.chat-mobile-session-trigger:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-mobile-session-dropdown {
position: absolute;
top: calc(100% + var(--space-xs));
left: 0;
right: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-lg);
z-index: 4;
max-height: calc(var(--space-xl) * 10);
overflow-y: auto;
}
/*
FNXC:Chat 2026-06-16-22:12:
Mobile chat session switching needs a dedicated rename tap target beside each session without nesting buttons, so the row owns layout while the title and rename controls remain independently keyboard accessible.
*/
.chat-mobile-session-option-row {
display: flex;
align-items: stretch;
gap: var(--space-xs);
border-radius: var(--radius-sm);
}
.chat-mobile-session-option {
width: 100%;
display: flex;
align-items: flex-start;
min-height: calc(var(--space-lg) * 2.25);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: left;
padding: var(--space-sm) var(--space-md);
line-height: normal;
}
.chat-mobile-session-pin,
.chat-mobile-session-rename {
flex-shrink: 0;
align-self: stretch;
color: var(--text-muted);
}
.chat-mobile-session-pin:hover,
.chat-mobile-session-pin:focus-visible,
.chat-mobile-session-rename:hover {
color: var(--text);
background: var(--card-hover);
}
.chat-mobile-session-option:hover {
background: var(--card-hover);
}
.chat-mobile-session-option:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-mobile-session-option--active {
background: color-mix(in srgb, var(--todo) 12%, transparent);
}
.chat-mobile-session-new {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
margin-top: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border: none;
border-top: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
text-align: left;
line-height: normal;
}
.chat-mobile-session-new svg {
flex-shrink: 0;
color: var(--text-muted);
}
.chat-mobile-session-new:hover {
background: var(--card-hover);
}
.chat-mobile-session-new:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-mobile-session-option-title {
width: 100%;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
line-height: normal;
white-space: normal;
overflow-wrap: anywhere;
justify-content: flex-start;
}
.chat-view-header-new-chat {
@@ -806,42 +530,6 @@ Mobile chat session switching needs a dedicated rename tap target beside each se
min-width: 0;
}
/*
FNXC:ChatHeader 2026-07-02-17:26:
Mobile direct-chat detail uses ViewHeader as the only visible header row. The back arrow must be the far-left visible/focusable control and the session selector must stay on that same non-wrapping row, so hide the entire Chat title/icon shell from layout while retaining the accessible heading and make the actions cluster own the row from the left edge.
*/
.chat-view--mobile-direct-thread > .view-header {
flex-wrap: nowrap;
}
.chat-view--mobile-direct-thread > .view-header .view-header__title {
position: absolute;
inline-size: var(--btn-border-width);
block-size: var(--btn-border-width);
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;
width: 100%;
min-width: 0;
margin-left: 0;
flex-wrap: nowrap;
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 0;
min-width: 0;
}
@media (max-width: 768px), (max-height: 480px) {
.chat-view-header-scope-toggle {
flex-basis: clamp(112px, 42vw, 180px);
@@ -924,8 +612,8 @@ desktop geometry, and every other ViewHeader consumer remain unchanged.
}
/*
FNXC:ChatModal 2026-06-22-14:38:
Floating Chat can become narrow while the browser viewport remains desktop-sized. Mirror the mobile one-pane layout with a class driven by the modal ResizeObserver so a narrow pop-out hides the sidebar and shows the mobile thread/list surfaces.
FNXC:ChatNavigation 2026-08-19-20:40:
A narrow floating host uses the same full-pane list/detail flow as every other Chat host. Keep this layout class for surrounding list sizing only; selected detail hides the list through .chat-sidebar--hidden rather than restoring a split pane.
*/
.chat-view--narrow .chat-view__body {
flex-direction: column;
@@ -956,10 +644,6 @@ Floating Chat can become narrow while the browser viewport remains desktop-sized
overflow-y: auto;
}
.chat-view--narrow .chat-sidebar:not(.chat-sidebar--hidden) + .chat-thread {
display: none;
}
/*
FNXC:ChatModal 2026-06-22-18:00:
In the narrow/mobile chat layout there is no room for an expand/maximize affordance in the header. Hide only the expand controls (main Chat pop-out and floating-modal maximize); keep minimize/close visible so the user can still dismiss or dock the chat.
@@ -988,16 +672,6 @@ In the narrow/mobile chat layout there is no room for an expand/maximize afforda
margin-bottom: var(--space-md);
}
@media (max-width: 768px) {
.chat-mobile-session-option-row {
align-items: stretch;
}
.chat-mobile-session-rename {
min-width: calc(var(--space-lg) * 2.25);
}
}
/*
FNXC:ChatRenderToggle 2026-07-04-00:00:
The thread-wide Markdown/plain render toggle (`.chat-thread-header-render-toggle*`, desktop + mobile floating variants) was removed per FN-7541. Chat always renders Markdown now; the button and its CSS shells no longer exist.
@@ -2452,13 +2126,6 @@ Thinking-section text uses the defined muted text token across all themes. The m
overflow-y: auto;
}
/* When the sidebar is shown, the thread is hidden so only the session
list is visible. When the sidebar is hidden, the thread fills the
viewport. Adjacent-sibling selector keeps logic in CSS only. */
.chat-sidebar:not(.chat-sidebar--hidden) + .chat-thread {
display: none;
}
.chat-thread--keyboard-active {
height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
max-height: calc(var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px))) - var(--header-height));
@@ -2519,18 +2186,6 @@ Thinking-section text uses the defined muted text token across all themes. The m
white-space: nowrap;
overflow: visible;
}
.chat-mobile-session-menu {
width: 100%;
}
.chat-room-switcher-dropdown {
left: 0;
right: auto;
min-width: calc(var(--space-xl) * 5);
max-width: min(calc(var(--space-xl) * 10), calc(100vw - var(--space-2xl)));
}
.chat-thread-header-identity .chat-thread-header-title,
.chat-thread-header-identity .chat-model-tag {
overflow: hidden;

View File

@@ -165,10 +165,6 @@ function formatRelativeTime(dateStr: string, t: TFunction<"app">): string {
return date.toLocaleDateString();
}
const CHAT_SIDEBAR_DEFAULT_WIDTH = 280;
const CHAT_SIDEBAR_MIN_WIDTH = 180;
const CHAT_SIDEBAR_MAX_WIDTH = 500;
const CHAT_SIDEBAR_STORAGE_KEY = "fusion:chat-sidebar-width";
const CHAT_SCOPE_STORAGE_KEY = "fusion:chat-scope";
const CHAT_DRAFT_STORAGE_PREFIX = "fusion:chat-draft:";
@@ -747,8 +743,16 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const [renameTagDialog, setRenameTagDialog] = useState<{ id: string; name: string } | null>(null);
const [renameTagName, setRenameTagName] = useState("");
const [confirmDeleteTag, setConfirmDeleteTag] = useState<ChatTag | null>(null);
const [sidebarVisible, setSidebarVisible] = useState(true);
const [sidebarWidth, setSidebarWidth] = useState(CHAT_SIDEBAR_DEFAULT_WIDTH);
/*
FNXC:ChatNavigation 2026-08-19-19:36:
Chat is a list/detail flow on every host. The list alone owns selection and
conversation management; detail owns the thread and its single return path.
Keep this local state independent of useChat's restored active session so a
remount never creates a phantom drill-in history entry or replaces a stream.
Visible Back must consume its pushed navigation entry; popstate uses the raw
return callback so either route restores the same list state.
*/
const [detailOpen, setDetailOpen] = useState(false);
const [createRoomOpen, setCreateRoomOpen] = useState(false);
const { agentsMap: cachedAgentsMap } = useAgentsMapCache(projectId);
const agentsMap = useMemo(() => (chatAgentsMap.size > 0 ? chatAgentsMap : cachedAgentsMap), [cachedAgentsMap, chatAgentsMap]);
@@ -776,9 +780,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const [isDragOver, setIsDragOver] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState<Record<string, CopyFeedbackState>>({});
const [mobileSessionMenuOpen, setMobileSessionMenuOpen] = useState(false);
const [roomSwitcherOpen, setRoomSwitcherOpen] = useState(false);
const { pushNav } = useNavigationHistoryContext();
const { pushNav, removeNav } = useNavigationHistoryContext();
// File mention state and hook
const [, setFileMentionPopupVisible] = useState(false);
@@ -803,8 +805,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const messagesEndRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const mobileSessionMenuRef = useRef<HTMLDivElement>(null);
const roomSwitcherRef = useRef<HTMLDivElement>(null);
const isUserScrollingRef = useRef(false);
const lastAnchoredThreadStateRef = useRef<{ threadId: string; loaded: boolean; hasMessages: boolean } | null>(null);
const previousChatScopeRef = useRef<"direct" | "rooms" | null>(null);
@@ -916,18 +916,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
markRead("room", rooms.activeRoom.id, latestMessage?.createdAt ?? rooms.activeRoom.updatedAt);
}, [markRead, rooms.activeRoom?.id, rooms.activeRoom?.updatedAt, rooms.messages]);
useEffect(() => {
try {
const rawWidth = localStorage.getItem(CHAT_SIDEBAR_STORAGE_KEY);
if (!rawWidth) return;
const parsedWidth = Number.parseInt(rawWidth, 10);
if (Number.isNaN(parsedWidth)) return;
const clampedWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, parsedWidth));
setSidebarWidth(clampedWidth);
} catch {
// Ignore storage errors.
}
}, []);
useEffect(() => {
try {
@@ -997,7 +985,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
enabled: (isChatMobile || isTablet) && (!!activeSession || roomThreadActive),
allowNonMobileViewport: isTablet,
});
const tabletKeyboardOpen = isTablet && keyboardOpen;
const filteredSkills = useMemo(() => {
const normalizedFilter = skillFilter.trim().toLowerCase();
@@ -1711,8 +1698,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
try {
await createSession(input);
setShowNewDialog(false);
// On mobile, hide sidebar after selecting
if (isChatMobile) setSidebarVisible(false);
setDetailOpen(true);
return true;
} catch {
addToast(t("chat.failedToCreateSession", "Failed to create chat session"), "error");
@@ -2528,17 +2514,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
(id: string) => {
const session = filteredSessions.find((item) => item.id === id) ?? (activeSession?.id === id ? activeSession : null);
setContextMenu(null);
setMobileSessionMenuOpen(false);
setRenameTitle(session?.title ?? "");
setRenameDialog({ sessionId: id, title: session?.title ?? "" });
},
[activeSession, filteredSessions],
);
/**
* FNXC:Chat 2026-06-16-22:08:
* Regular chat exposes rename from the desktop context menu and mobile session switcher; saving delegates to the shared hook so the sidebar list and active thread header update from one optimistic state path.
*/
/** Regular chat saves list-owned rename actions through the shared hook. */
const handleRename = useCallback(async () => {
if (!renameDialog) return;
try {
@@ -2554,7 +2536,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const handlePin = useCallback(
async (id: string, pinned: boolean) => {
setContextMenu(null);
setMobileSessionMenuOpen(false);
try {
await pinSession(id, pinned);
addToast(pinned ? t("chat.conversationPinned", "Conversation pinned") : t("chat.conversationUnpinned", "Conversation unpinned"), "success");
@@ -2580,98 +2561,30 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
[deleteSession, addToast],
);
const persistSidebarWidth = useCallback((width: number) => {
try {
localStorage.setItem(CHAT_SIDEBAR_STORAGE_KEY, String(width));
} catch {
// Ignore storage errors.
}
}, []);
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (isChatMobile || tabletKeyboardOpen) {
return;
}
event.preventDefault();
event.stopPropagation();
const resizeHandle = event.currentTarget;
if (typeof resizeHandle.setPointerCapture === "function") {
resizeHandle.setPointerCapture(event.pointerId);
}
const startX = event.clientX;
const startWidth = sidebarWidth;
let latestWidth = startWidth;
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX;
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, startWidth + deltaX));
latestWidth = nextWidth;
setSidebarWidth(nextWidth);
persistSidebarWidth(nextWidth);
};
const onPointerUp = (upEvent: PointerEvent) => {
if (typeof resizeHandle.releasePointerCapture === "function") {
resizeHandle.releasePointerCapture(upEvent.pointerId);
}
document.body.style.userSelect = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
persistSidebarWidth(latestWidth);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
}, [isChatMobile, persistSidebarWidth, sidebarWidth, tabletKeyboardOpen]);
const handleResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (isChatMobile || tabletKeyboardOpen) {
return;
}
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
event.preventDefault();
const step = event.shiftKey ? 50 : 10;
const delta = event.key === "ArrowLeft" ? -step : step;
const nextWidth = Math.max(CHAT_SIDEBAR_MIN_WIDTH, Math.min(CHAT_SIDEBAR_MAX_WIDTH, sidebarWidth + delta));
setSidebarWidth(nextWidth);
persistSidebarWidth(nextWidth);
}, [isChatMobile, persistSidebarWidth, sidebarWidth, tabletKeyboardOpen]);
// Handle session click
const handleSessionClick = useCallback(
(id: string) => {
const selectedSession = filteredSessions.find((session) => session.id === id);
markRead("direct", id, selectedSession?.lastMessageAt ?? selectedSession?.updatedAt);
selectSession(id);
setMobileSessionMenuOpen(false);
if (isChatMobile) setSidebarVisible(false);
setDetailOpen(true);
},
[filteredSessions, isChatMobile, markRead, selectSession],
[filteredSessions, markRead, selectSession],
);
// Handle back to sidebar (mobile)
const handleBack = useCallback(() => {
selectSession("");
setSidebarVisible(true);
setMobileSessionMenuOpen(false);
}, [selectSession]);
setDetailOpen(false);
}, []);
const handleRoomBack = useCallback(() => {
rooms.selectRoom(null);
setSidebarVisible(true);
setMobileSessionMenuOpen(false);
}, [rooms]);
setDetailOpen(false);
}, []);
const handleVisibleDetailBack = useCallback(() => {
const revert = chatScope === "rooms" ? handleRoomBack : handleBack;
revert();
removeNav?.(revert);
}, [chatScope, handleBack, handleRoomBack, removeNav]);
// Render empty state (no active session)
const renderEmptyState = () => {
@@ -2708,40 +2621,19 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const activeModelTag = formatModelTag(activeResolvedModel?.provider, activeResolvedModel?.modelId);
const activeModelProvider = activeResolvedModel?.provider ?? null;
const hasThreadInView = Boolean(activeSession || isStreaming || messages.length > 0);
/*
FNXC:ChatHeader 2026-07-10-00:00:
After Chat remounts, useChat/useChatRooms can restore persisted activeSession/activeRoom while sidebarVisible resets to true. On mobile, header controls, the direct-thread shell class, and swipe-back history must follow the pane the body is actually showing, so detail-open requires the sidebar/list to be hidden instead of relying on restored thread presence alone.
*/
const mobileThreadPaneOpen = isChatMobile && !sidebarVisible && (chatScope === "rooms" ? roomThreadActive : hasThreadInView);
const hasMobileDetailSelection = isChatMobile
? mobileThreadPaneOpen
: chatScope === "rooms" ? roomThreadActive : Boolean(activeSession);
const previousHasMobileDetailSelectionRef = useRef(hasMobileDetailSelection);
const hasDetailSelection = detailOpen && (chatScope === "rooms" ? roomThreadActive : hasThreadInView);
const previousDetailOpenRef = useRef(hasDetailSelection);
useEffect(() => {
const previousHasMobileDetailSelection = previousHasMobileDetailSelectionRef.current;
previousHasMobileDetailSelectionRef.current = hasMobileDetailSelection;
if (!isChatMobile) {
return;
}
if (previousHasMobileDetailSelection || !hasMobileDetailSelection) {
return;
}
// Mobile list/detail surfaces must stack a view entry on top of the
// shared browser-history nav entry so swipe-back returns to the list.
pushNav({
type: "view",
revert: chatScope === "rooms" ? handleRoomBack : handleBack,
});
}, [chatScope, handleBack, handleRoomBack, hasMobileDetailSelection, isChatMobile, pushNav]);
const previousDetailOpen = previousDetailOpenRef.current;
previousDetailOpenRef.current = hasDetailSelection;
if (previousDetailOpen || !hasDetailSelection) return;
pushNav({ type: "view", revert: chatScope === "rooms" ? handleRoomBack : handleBack });
}, [chatScope, handleBack, handleRoomBack, hasDetailSelection, pushNav]);
const threadHeaderTitle = activeSession?.agentId === FN_AGENT_ID
? (activeModelTag ?? "Fusion")
: activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat";
const mobileDirectSessionTitle = activeSession?.title || t("chat.untitledSession", "Untitled");
const showThreadHeaderModelTag = Boolean(activeModelTag && activeModelTag !== threadHeaderTitle);
const showThreadHeaderContextWindow = !isChatMobile && hasThreadInView && activeContextWindow !== null;
@@ -2753,9 +2645,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
total: threadHeaderContextTotal,
})
: null;
const showMobileSessionSwitcher = mobileThreadPaneOpen && chatScope === "direct" && !!activeSession;
const showMobileDirectThreadHeaderControls = mobileThreadPaneOpen && chatScope === "direct";
const showMobileRoomThreadHeaderControls = mobileThreadPaneOpen && chatScope === "rooms";
const agentName =
agentsMap.get(activeSession?.agentId ?? "")?.name ||
@@ -2775,59 +2664,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
// assistant bubble is noise. Hide the per-message identity row entirely.
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
useEffect(() => {
if (!mobileSessionMenuOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (mobileSessionMenuRef.current?.contains(event.target as Node)) {
return;
}
setMobileSessionMenuOpen(false);
};
document.addEventListener("mousedown", handlePointerDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
};
}, [mobileSessionMenuOpen]);
useEffect(() => {
if (!roomSwitcherOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (roomSwitcherRef.current?.contains(event.target as Node)) {
return;
}
setRoomSwitcherOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setRoomSwitcherOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [roomSwitcherOpen]);
useEffect(() => {
if (!isChatMobile || chatScope !== "direct" || sidebarVisible) {
setMobileSessionMenuOpen(false);
}
}, [isChatMobile, chatScope, sidebarVisible]);
useEffect(() => {
setRoomSwitcherOpen(false);
}, [rooms.activeRoom?.id]);
const setCopyFeedback = useCallback((messageId: string, feedback: CopyFeedbackState) => {
const existingTimeout = copyFeedbackTimeoutsRef.current.get(messageId);
@@ -3222,7 +3058,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
* FNXC:ChatTabletKeyboard 2026-06-16-22:59:
* FN-6516 refines the tablet keyboard behavior: keep the sidebar at the same persisted width while the keyboard is open instead of narrowing to the minimum. The FN-6210 CSS max-width guard remains the upper bound, and resize controls still stay disabled while typing.
*/
const sidebarInlineStyle: React.CSSProperties | undefined = isChatMobile ? undefined : { width: `${sidebarWidth}px` };
/*
FNXC:ChatHeader 2026-06-22-16:18:
Direct/Rooms is a view-level scope switch, so it belongs in Chat's canonical header directly before New Chat instead of consuming the first row of the sidebar. Keep the existing test ids while moving the DOM so direct and room conversations share one header control surface.
@@ -3269,93 +3104,6 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
setNewTagName("");
}, [addToast, contextMenu, contextMenuSession, createTag, newTagName, setSessionTags, t]);
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} />}
{/*
FNXC:ChatHeader 2026-07-03-00:00:
Mobile direct-chat needs one compact, conversation-oriented dropdown trigger. Show the active conversation title (or Untitled) beside only the provider/model logo and chevron; keep model-name badges exclusive to the desktop/thread identity row.
*/}
<span className="chat-thread-header-title">{mobileDirectSessionTitle}</span>
<ChevronDown size={16} aria-hidden="true" />
</button>
{mobileSessionMenuOpen && (
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
{[
{ id: "pinned", label: t("chat.pinned", "Pinned"), testId: "chat-mobile-pinned-divider", sessions: pinnedFilteredSessions },
{ id: "recent", label: t("chat.recent", "Recent"), testId: "chat-mobile-recent-divider", sessions: unpinnedFilteredSessions },
].filter((group) => group.sessions.length > 0).map((group) => (
<section className="chat-session-section" data-testid={`chat-mobile-session-section-${group.id}`} key={group.id}>
<div className="chat-pinned-divider" data-testid={group.testId}>{group.label}</div>
{group.sessions.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")}{session.pinnedAt ? <Pin className="chat-session-pinned-indicator" size={14} data-testid={`chat-session-pinned-indicator-${session.id}`} aria-label={t("chat.pinned", "Pinned")} /> : null}</span>
</button>
<button
type="button"
className="btn-icon chat-mobile-session-pin"
data-testid={`chat-mobile-session-pin-${session.id}`}
aria-label={session.pinnedAt ? t("chat.unpinConversationAria", "Unpin conversation {{title}}", { title: session.title || t("chat.untitledSession", "Untitled") }) : t("chat.pinConversationAria", "Pin conversation {{title}}", { title: session.title || t("chat.untitledSession", "Untitled") })}
title={!session.pinnedAt && pinnedCount >= 3 ? t("chat.pinLimit", "You can pin up to 3 conversations") : undefined}
disabled={!session.pinnedAt && pinnedCount >= 3}
onClick={() => handlePin(session.id, !session.pinnedAt)}
>
{session.pinnedAt ? <PinOff size={14} /> : <Pin size={14} />}
</button>
<button type="button" className="btn-icon chat-mobile-session-archive" data-testid={`chat-mobile-session-archive-${session.id}`} aria-label={t("chat.archive", "Archive")} onClick={() => void handleArchive(session.id)}><Archive size={14} /></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>
))}
</section>
))}
{/*
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 handleNewChat() path as the header and sidebar-footer controls so project chatNewSessionMode is honored everywhere.
*/}
<button
type="button"
role="menuitem"
className="chat-mobile-session-new"
data-testid="chat-mobile-session-new"
onClick={() => {
setMobileSessionMenuOpen(false);
handleNewChat();
}}
>
<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">
@@ -3386,30 +3134,21 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
return (
/*
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.
FNXC:ChatNavigation 2026-08-19-19:36:
Chat uses the shared ViewHeader for the list/detail return path while .chat-view__body renders one full pane at a time. This preserves thread scrolling and keyboard compensation without a desktop split pane.
*/
<div ref={chatViewRef} className={`chat-view${floating ? " chat-view--floating" : ""}${isChatMobile ? " chat-view--narrow" : ""}${showMobileDirectThreadHeaderControls ? " chat-view--mobile-direct-thread" : ""}${chatMessageLayout === "full-width" ? " chat-view--full-width" : ""}`}>
<div ref={chatViewRef} className={`chat-view${floating ? " chat-view--floating" : ""}${isChatMobile ? " chat-view--narrow" : ""}${hasDetailSelection ? " chat-view--detail" : ""}${chatMessageLayout === "full-width" ? " chat-view--full-width" : ""}`}>
<ViewHeader
icon={MessageSquare}
title={t("chat.title", "Chat")}
actions={
<>
{showMobileDirectThreadHeaderControls ? (
<>
{/*
FNXC:ChatHeader 2026-07-02-17:26:
Mobile direct-thread view has a single top row: back navigation must be the first visible/focusable control at the far-left edge and the active conversation switcher must stay beside it. The ViewHeader still owns the accessible Chat title; ChatView-scoped CSS hides the entire title/icon shell only in this direct-thread mobile state so it cannot reserve left-edge layout space.
*/}
<button className="btn-icon chat-back-btn" onClick={handleBack} data-testid="chat-back-btn" aria-label={t("chat.backToConversations", "Back to conversations")}>
<ChevronLeft size={16} />
</button>
{mobileDirectSessionSwitcher}
</>
) : (
scopeToggle
)}
{!isChatMobile ? (
{hasDetailSelection ? (
<button className="btn-icon chat-back-btn" onClick={handleVisibleDetailBack} data-testid="chat-back-btn" aria-label={t("chat.backToConversations", "Back to conversations")}>
<ChevronLeft size={16} />
</button>
) : scopeToggle}
{!hasDetailSelection ? (
<button
className="btn btn-sm btn-primary chat-view-header-new-chat"
onClick={handleNewChat}
@@ -3473,8 +3212,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
<div className="chat-view__body">
{/* Sidebar */}
<div
className={`chat-sidebar${!sidebarVisible ? " chat-sidebar--hidden" : ""}`}
style={sidebarInlineStyle}
className={`chat-sidebar${hasDetailSelection ? " chat-sidebar--hidden" : ""}`}
>
{!chatRoomsEnabled || chatScope === "direct" ? (
<>
@@ -3655,18 +3393,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
onClick={() => {
markRead("room", room.id, room.updatedAt);
rooms.selectRoom(room.id);
if (isChatMobile) {
setSidebarVisible(false);
}
setDetailOpen(true);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
markRead("room", room.id, room.updatedAt);
rooms.selectRoom(room.id);
if (isChatMobile) {
setSidebarVisible(false);
}
setDetailOpen(true);
}
}}
>
@@ -3720,36 +3454,10 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</button>
</div>
) : null
) : (
isChatMobile ? (
<div className="chat-sidebar-footer">
<button
className="btn btn-sm btn-primary chat-sidebar-footer-btn"
onClick={handleNewChat}
data-testid="chat-new-btn"
>
<Plus size={14} />
{t("chat.newChat", "New Chat")}
</button>
</div>
) : null
)}
) : null}
</div>
{!isChatMobile && sidebarVisible && !tabletKeyboardOpen && (
<div
className="chat-sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-valuemin={CHAT_SIDEBAR_MIN_WIDTH}
aria-valuemax={CHAT_SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
aria-label={t("chat.resizeSidebar", "Resize chat sidebar")}
tabIndex={0}
onPointerDown={handleResizeStart}
onKeyDown={handleResizeKeyDown}
/>
)}
{/* Context Menu */}
{contextMenu && (
@@ -3943,69 +3651,21 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</ChatDialogBackdrop>
)}
{/* Thread */}
{chatRoomsEnabled && chatScope === "rooms" ? (
{hasDetailSelection && chatRoomsEnabled && chatScope === "rooms" ? (
<div ref={chatThreadRef} className="chat-thread">
{rooms.activeRoom ? (
<>
{(!isChatMobile || showMobileRoomThreadHeaderControls) && (
<div className="chat-room-thread-header">
{showMobileRoomThreadHeaderControls && (
<button className="btn-icon" onClick={handleRoomBack} data-testid="chat-back-btn">
<ChevronLeft size={16} />
</button>
)}
<div className="chat-room-switcher-menu" ref={roomSwitcherRef}>
<button
type="button"
className="chat-room-switcher-trigger"
data-testid="chat-room-switcher-trigger"
aria-haspopup="menu"
aria-expanded={roomSwitcherOpen}
onClick={() => setRoomSwitcherOpen((open) => !open)}
>
<span className="chat-thread-header-title">#{rooms.activeRoom.name}</span>
<ChevronDown size={16} aria-hidden="true" />
</button>
{roomSwitcherOpen && (
<div
role="menu"
className="chat-room-switcher-dropdown"
data-testid="chat-room-switcher-dropdown"
>
{rooms.rooms.map((room) => (
<button
key={room.id}
type="button"
role="menuitem"
className={`chat-room-switcher-option${room.id === rooms.activeRoom?.id ? " chat-room-switcher-option--active" : ""}`}
data-testid={`chat-room-switcher-option-${room.id}`}
onClick={() => {
markRead("room", room.id, room.updatedAt);
rooms.selectRoom(room.id);
setRoomSwitcherOpen(false);
}}
>
#{room.name}
</button>
))}
</div>
)}
</div>
<span className="chat-thread-header-title">#{rooms.activeRoom.name}</span>
<div className="chat-room-thread-members">
{rooms.activeRoomMembers.map((member) => (
<AgentAvatar
key={member.agentId}
agent={
agentsMap.get(member.agentId) ?? {
id: member.agentId,
name: member.agentId.slice(0, 30),
}
}
agent={agentsMap.get(member.agentId) ?? { id: member.agentId, name: member.agentId.slice(0, 30) }}
/>
))}
</div>
</div>
)}
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
{rooms.messagesLoading && rooms.messages.length === 0 ? (
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
@@ -4190,7 +3850,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</div>
)}
</div>
) : (
) : hasDetailSelection ? (
<div ref={chatThreadRef} className="chat-thread">
{/* Header - desktop/tablet keeps the thread identity row; mobile direct-thread controls move into ViewHeader. */}
{/* FNXC:ChatRenderToggle 2026-07-04-00:00: The markdown/plain eye toggle
@@ -4246,7 +3906,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</>
)}
</div>
)}
) : null}
{chatRoomsEnabled && (
<CreateRoomModal
@@ -4260,9 +3920,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
setChatScope("rooms");
}
setCreateRoomOpen(false);
if (isChatMobile) {
setSidebarVisible(false);
}
setDetailOpen(true);
}}
/>
)}

View File

@@ -57,14 +57,12 @@ function expectNoContextWindowShell() {
}
/*
FNXC:ChatHeader 2026-07-16-00:00:
The mobile and floating-narrow session switcher is rendered only in the direct-thread pane. Tests exercising its header must select the session from the list first, matching the mobile drill-in flow.
FNXC:ChatNavigation 2026-08-19-21:10:
FN-054 keeps the context indicator tests on the selected thread while the conversation list owns all switching and management. Opening detail through a list row verifies the constrained host without restoring a selector.
*/
async function openMobileDirectThread(sessionId = "session-001") {
async function openDirectThread(sessionId = "session-001") {
await userEvent.click(screen.getByTestId(`chat-session-${sessionId}`));
await waitFor(() => {
expect(screen.getByTestId("chat-mobile-session-trigger")).toBeInTheDocument();
});
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
}
function setupDirectChat(options: { content?: string; streamingText?: string } = {}) {
@@ -92,6 +90,7 @@ describe("ChatView context-window indicator", () => {
setupDirectChat({ content: "abcd" });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openDirectThread();
const indicator = await screen.findByTestId("chat-thread-context-window");
expect(indicator).toHaveTextContent("1 / 200k");
@@ -104,7 +103,7 @@ describe("ChatView context-window indicator", () => {
setupDirectChat({ content: "abcd" });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openMobileDirectThread();
await openDirectThread();
/*
FNXC:DashboardTests 2026-07-14-20:15:
@@ -144,11 +143,11 @@ describe("ChatView context-window indicator", () => {
setupDirectChat({ content: "abcd" });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating />);
await openMobileDirectThread();
await openDirectThread();
/*
FNXC:DashboardTests 2026-07-14-20:15:
Floating narrow chat is marked chat-view--floating + --narrow; do not require the mobile session trigger (only present after explicit mobile-direct-thread entry).
Floating narrow chat is marked chat-view--floating + --narrow; do not require the mobile session trigger (after explicit list-to-detail entry).
*/
expect(document.querySelector(".chat-view--floating.chat-view--narrow")).toBeTruthy();
expectNoContextWindowShell();
@@ -180,6 +179,7 @@ describe("ChatView context-window indicator", () => {
const expectedUsed = formatTokenCount(estimateChatTokens([{ content }], streamingText));
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await openDirectThread();
const indicator = await screen.findByTestId("chat-thread-context-window");
expect(expectedUsed).toBe("~1k");
@@ -209,6 +209,7 @@ describe("ChatView context-window indicator", () => {
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-item-context-room"));
expect(document.querySelector(".chat-room-thread-header")).toBeInTheDocument();
expectNoContextWindowShell();

View File

@@ -138,6 +138,8 @@ describe("formatModelTag helper function", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-session-session-001"));
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag?.textContent).toContain("Claude Sonnet");
});
@@ -160,6 +162,8 @@ describe("formatModelTag helper function", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-session-session-001"));
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag?.textContent).toContain("GPT-4o");
});
@@ -182,6 +186,8 @@ describe("formatModelTag helper function", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-session-session-001"));
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag?.textContent).toContain("Gemini");
});
@@ -203,6 +209,8 @@ describe("formatModelTag helper function", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-session-session-001"));
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag).not.toBeInTheDocument();
});
@@ -224,6 +232,8 @@ describe("formatModelTag helper function", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-session-session-001"));
const modelTag = document.querySelector(".chat-model-tag") as HTMLElement | null;
expect(modelTag).not.toBeInTheDocument();
});
@@ -256,19 +266,23 @@ describe("Chat pinned session sections", () => {
expect(screen.getByTestId("chat-session-section-recent")).toBeInTheDocument();
});
it("separates pinned and recent direct sessions in the mobile switcher", async () => {
it("keeps pinned and recent direct sessions in the mobile list after returning from detail", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
try {
setupMockChat({ activeSession: mixedSessions[0], sessions: mixedSessions, filteredSessions: mixedSessions });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(screen.getByTestId("chat-session-session-pinned"));
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
const pinned = screen.getByTestId("chat-mobile-session-section-pinned");
const recent = screen.getByTestId("chat-mobile-session-section-recent");
expect(within(pinned).getByTestId("chat-mobile-session-option-session-pinned")).toBeInTheDocument();
expect(within(recent).getByTestId("chat-mobile-session-option-session-recent")).toBeInTheDocument();
expect(within(recent).getByTestId("chat-mobile-recent-divider")).toHaveTextContent("Recent");
const pinned = screen.getByTestId("chat-session-section-pinned");
const recent = screen.getByTestId("chat-session-section-recent");
expect(within(pinned).getByTestId("chat-session-session-pinned")).toBeInTheDocument();
expect(within(recent).getByTestId("chat-session-session-recent")).toBeInTheDocument();
await userEvent.click(within(pinned).getByTestId("chat-session-session-pinned"));
expect(screen.getByTestId("chat-back-btn")).toHaveAccessibleName("Back to conversations");
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeNull();
await userEvent.click(screen.getByTestId("chat-back-btn"));
expect(screen.getByTestId("chat-session-section-pinned")).toBeInTheDocument();
expect(screen.getByTestId("chat-session-section-recent")).toBeInTheDocument();
} finally {
restoreMatchMedia();
}
@@ -414,8 +428,7 @@ describe("Chat Session Action Menu", () => {
});
expect(screen.getByTestId("chat-session-session-001")).toHaveTextContent("Renamed Chat");
const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null;
expect(headerTitle).toHaveTextContent("Renamed Chat");
expect(screen.queryByTestId("chat-back-btn")).toBeNull();
});
it("prefills rename as empty for an untitled session and names it", async () => {
@@ -441,70 +454,31 @@ describe("Chat Session Action Menu", () => {
expect(renameSession).toHaveBeenCalledWith("session-001", "Named from Untitled");
});
it("renames from the mobile session switcher and preserves the active header title surface", async () => {
it("renames from the mobile list action menu without adding a detail action", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
const renameSession = vi.fn().mockResolvedValue(undefined);
try {
const initialSession: ChatSessionInfo = {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Mobile Chat",
modelProvider: "minimax",
modelId: "m3",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
id: "session-001", agentId: "__fn_agent__", status: "active", title: "Mobile Chat", modelProvider: "minimax", modelId: "m3", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z",
};
setupMockChat({
activeSession: initialSession,
sessions: [initialSession],
filteredSessions: [initialSession],
renameSession,
});
const view = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
/*
FNXC:ChatHeader 2026-07-16-00:00:
The mobile session switcher belongs to the direct-thread pane, not the visible session list. Drill into the active session before asserting its header controls so this rename contract matches the user flow.
*/
await userEvent.click(screen.getByTestId("chat-session-session-001"));
await waitFor(() => {
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
});
expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Chat");
expect(screen.getByTestId("chat-mobile-session-trigger")).not.toHaveTextContent("M3");
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-001"));
setupMockChat({ activeSession: initialSession, sessions: [initialSession], filteredSessions: [initialSession], renameSession });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await userEvent.click(within(screen.getByTestId("chat-session-session-001")).getByTestId("chat-session-menu-btn"));
await userEvent.click(screen.getByTestId("chat-context-rename"));
const input = screen.getByTestId("chat-rename-input") as HTMLInputElement;
expect(input.value).toBe("Mobile Chat");
await userEvent.clear(input);
await userEvent.type(input, "Mobile Renamed");
await userEvent.click(screen.getByTestId("chat-rename-save"));
expect(renameSession).toHaveBeenCalledWith("session-001", "Mobile Renamed");
const renamedSession: ChatSessionInfo = { ...initialSession, title: "Mobile Renamed" };
setupMockChat({
activeSession: renamedSession,
sessions: [renamedSession],
filteredSessions: [renamedSession],
renameSession,
});
await act(async () => {
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
});
const trigger = screen.getByTestId("chat-mobile-session-trigger");
expect(trigger).toHaveTextContent("Mobile Renamed");
expect(trigger).not.toHaveTextContent("M3");
expect(trigger.querySelector(".chat-model-tag")).not.toBeInTheDocument();
const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null;
expect(headerTitle).toHaveTextContent("Mobile Renamed");
await userEvent.click(screen.getByTestId("chat-session-session-001"));
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeNull();
expect(screen.queryByTestId("chat-mobile-session-rename-session-001")).toBeNull();
} finally {
restoreMatchMedia.mockRestore();
restoreMatchMedia();
}
});
@@ -532,17 +506,10 @@ describe("Chat Session Action Menu", () => {
describe("ChatView CSS — failure bubble contracts", () => {
const css = loadAllAppCss();
it("uses shared error surface tokens for failure bubbles and detail affordances", async () => {
const bubbleMatch = css.match(/\.chat-message--failure\s*\{([^}]*)\}/);
const badgeMatch = css.match(/\.chat-message-failure-badge\s*\{([^}]*)\}/);
const detailsMatch = css.match(/\.chat-message-failure-details\s*\{([^}]*)\}/);
const linkMatch = css.match(/\.chat-message-failure-reference-link\s*\{([^}]*)\}/);
expect(bubbleMatch?.[1]).toContain("background: var(--status-error-bg)");
expect(bubbleMatch?.[1]).toContain("border: var(--btn-border-width) solid var(--status-error-bg-deep)");
expect(badgeMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
expect(detailsMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
expect(linkMatch?.[1]).toContain("background: var(--status-error-bg-deep)");
it("uses shared error surface tokens for failure bubbles", async () => {
expect(css).toContain(".chat-message--failure {\n background: var(--status-error-bg);");
expect(css).toContain(".chat-message-failure-badge {\n display: inline-flex;");
expect(css).toContain("background: var(--status-error-bg-deep);");
});
});
@@ -696,50 +663,13 @@ describe("Chat Session Row Action CSS", () => {
});
});
describe("ChatView CSS — mobile thread switcher", () => {
describe("ChatView CSS — detail return control", () => {
const css = loadAllAppCss();
it("includes mobile session switcher trigger and dropdown tokenized contracts", async () => {
const triggerMatch = css.match(/\.chat-mobile-session-trigger\s*\{([^}]*)\}/);
const triggerIconMatch = css.match(/\.chat-mobile-session-trigger\s*>\s*svg\s*\{([^}]*)\}/);
const dropdownMatch = css.match(/\.chat-mobile-session-dropdown\s*\{([^}]*)\}/);
const optionMatch = css.match(/\.chat-mobile-session-option\s*\{([^}]*)\}/);
const optionTitleMatch = css.match(/\.chat-mobile-session-option-title\s*\{([^}]*)\}/);
expect(triggerMatch).toBeTruthy();
expect(triggerIconMatch).toBeTruthy();
expect(dropdownMatch).toBeTruthy();
expect(optionMatch).toBeTruthy();
expect(optionTitleMatch).toBeTruthy();
expect(triggerMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2 + var(--space-xs))");
expect(triggerMatch?.[1]).toContain("min-width: 0");
expect(triggerMatch?.[1]).toContain("padding: var(--space-xs) var(--space-sm)");
expect(triggerMatch?.[1]).toContain("font: inherit");
expect(triggerMatch?.[1]).toContain("line-height: normal");
expect(triggerMatch?.[1]).toContain("text-align: left");
expect(triggerIconMatch?.[1]).toContain("width: var(--icon-size-md)");
expect(triggerIconMatch?.[1]).toContain("height: var(--icon-size-md)");
expect(dropdownMatch?.[1]).toContain("background: var(--surface)");
expect(dropdownMatch?.[1]).toContain("border: 1px solid var(--border)");
expect(optionMatch?.[1]).toContain("min-height: calc(var(--space-lg) * 2.25)");
expect(optionMatch?.[1]).toContain("align-items: flex-start");
expect(optionMatch?.[1]).toContain("line-height: normal");
/*
* FNXC:ChatMobileSessionSwitcher 2026-07-16-18:54:
* FN-8054 intentionally uses inline flex to keep a pinned session's Pin indicator beside its title.
* Retain the width and wrapping contracts from FN-4061 so long titles remain readable.
*/
expect(optionTitleMatch?.[1]).toContain("width: 100%");
expect(optionTitleMatch?.[1]).toContain("display: inline-flex");
expect(optionTitleMatch?.[1]).toContain("align-items: center");
expect(optionTitleMatch?.[1]).toContain("gap: var(--space-xs)");
expect(optionTitleMatch?.[1]).toContain("line-height: normal");
expect(optionTitleMatch?.[1]).toContain("white-space: normal");
expect(optionTitleMatch?.[1]).toContain("overflow-wrap: anywhere");
expect(css).not.toMatch(/\.chat-mobile-session-trigger\s+\.chat-model-tag/);
});
it("keeps mobile override for header identity overflow visible so dropdown can render", async () => {
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*overflow:\s*visible;/);
it("removes the mobile session switcher without adding selector-specific chrome", async () => {
expect(css).not.toContain("chat-mobile-session-trigger");
expect(css).not.toContain("chat-mobile-session-dropdown");
expect(css).not.toContain("chat-mobile-session-option");
});
});

View File

@@ -205,6 +205,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
try {
setupChat({ sessions: [activeSession], filteredSessions: [activeSession], activeSession });
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await act(async () => screen.getByTestId(`chat-session-${activeSession.id}`).click());
const attachButton = screen.getByTestId("chat-attach-btn");
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
@@ -224,6 +225,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
const sendMessage = vi.fn((_content: string, _files?: File[], callbacks?: { onAccepted?: () => void }) => callbacks?.onAccepted?.());
setupChat({ sessions: [activeSession], filteredSessions: [activeSession], activeSession, sendMessage });
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await act(async () => screen.getByTestId(`chat-session-${activeSession.id}`).click());
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [new File(["note"], "mobile.txt", { type: "text/plain" })] } });
@@ -249,6 +251,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await act(async () => screen.getByTestId(`chat-session-${activeSession.id}`).click());
expectMobileEmptyStateToSpanMessagePane("No messages yet. Start the conversation!");
expect(css).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*\.chat-messages\s*\{[\s\S]*padding:\s*var\(--space-lg\) var\(--space-sm\);[\s\S]*\.chat-messages\s*>\s*\.chat-empty-state\s*\{[\s\S]*border:\s*none;[\s\S]*border-radius:\s*0;[\s\S]*background:\s*transparent;/);
@@ -257,9 +260,8 @@ describe("FN-5997 mobile chat message pane rendering", () => {
document.head.innerHTML = "";
setupChat();
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expectMobileEmptyStateToSpanMessagePane("Start a new conversation");
const startConversationEmptyState = screen.getByText("Start a new conversation").closest(".chat-empty-state") as HTMLElement;
expect(within(startConversationEmptyState).getByText("New Chat").closest("button")).toBeInTheDocument();
expect(screen.getByText("No conversations yet")).toBeInTheDocument();
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
cleanup();
document.head.innerHTML = "";
@@ -270,6 +272,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
messagesLoading: true,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await act(async () => screen.getByTestId(`chat-session-${activeSession.id}`).click());
expect(screen.getByText("Loading messages...")).toBeInTheDocument();
cleanup();
@@ -281,6 +284,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
messages: [messageFixture],
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await act(async () => screen.getByTestId(`chat-session-${activeSession.id}`).click());
expect(screen.getByText("Hello from Fusion")).toBeInTheDocument();
cleanup();
@@ -293,6 +297,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
streamingText: "Streaming reply",
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await act(async () => screen.getByTestId(`chat-session-${activeSession.id}`).click());
expect(screen.getByText("Streaming reply")).toBeInTheDocument();
expect(screen.queryByText("No messages yet. Start the conversation!")).toBeNull();
} finally {
@@ -318,6 +323,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
activeRoom,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await act(async () => screen.getByTestId("chat-room-item-eng").click());
expectMobileEmptyStateToSpanMessagePane("No messages yet. Start the conversation!");
@@ -329,6 +335,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
messagesLoading: true,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await act(async () => screen.getByTestId("chat-room-item-eng").click());
expect(screen.getByText("Loading messages...")).toBeInTheDocument();
cleanup();
@@ -349,6 +356,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
}],
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await act(async () => screen.getByTestId("chat-room-item-eng").click());
expect(screen.getByText("Room message")).toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
@@ -364,6 +372,7 @@ describe("FN-5997 mobile chat message pane rendering", () => {
activeSession,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await act(async () => screen.getByTestId(`chat-session-${activeSession.id}`).click());
const messagePaneEmptyState = screen.getByText("No messages yet. Start the conversation!").closest(".chat-empty-state");
expect(messagePaneEmptyState).toBeTruthy();
@@ -387,248 +396,30 @@ describe("FN-5997 mobile chat message pane rendering", () => {
}
});
it("keeps the tablet sidebar at the same width while the software keyboard is open", async () => {
const restoreMatchMedia = mockViewportMode("tablet");
const visualViewport = mockVisualViewport({ width: 900, height: 1112 });
try {
setupChat({
sessions: [activeSession],
filteredSessions: [activeSession],
activeSession,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sidebar = getSidebar();
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
expect(sidebar.style.width).toBe("280px");
expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument();
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
input.focus();
});
await setVisualViewportHeight(visualViewport, 560);
await waitFor(() => expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull());
expect(sidebar.style.width).toBe("280px");
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
await act(async () => {
input.blur();
});
await setVisualViewportHeight(visualViewport, 1112);
await waitFor(() => expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument());
expect(sidebar.style.width).toBe("280px");
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
} finally {
restoreMatchMedia.mockRestore();
}
});
it("keeps a persisted custom tablet sidebar width while the software keyboard is open", async () => {
const restoreMatchMedia = mockViewportMode("tablet");
const visualViewport = mockVisualViewport({ width: 900, height: 1112 });
localStorage.setItem("fusion:chat-sidebar-width", "360");
try {
setupChat({
sessions: [activeSession],
filteredSessions: [activeSession],
activeSession,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sidebar = getSidebar();
await waitFor(() => expect(sidebar.style.width).toBe("360px"));
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
input.focus();
});
await setVisualViewportHeight(visualViewport, 560);
await waitFor(() => expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull());
expect(sidebar.style.width).toBe("360px");
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
await act(async () => {
input.blur();
});
await setVisualViewportHeight(visualViewport, 1112);
await waitFor(() => expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument());
expect(sidebar.style.width).toBe("360px");
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
} finally {
restoreMatchMedia.mockRestore();
}
});
it("keeps a user-collapsed sidebar collapsed across tablet keyboard open and close", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
const visualViewport = mockVisualViewport({ width: 900, height: 1112 });
try {
setupChat({
sessions: [activeSession],
filteredSessions: [activeSession],
activeSession,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sidebar = getSidebar();
await act(async () => {
screen.getByTestId(`chat-session-${activeSession.id}`).click();
});
expect(sidebar).toHaveClass("chat-sidebar--hidden");
Object.defineProperty(window, "innerWidth", { value: 900, configurable: true });
restoreMatchMedia.mockImplementation((query: string) => ({
matches: query.includes("min-width: 769px") && query.includes("max-width: 1024px"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
await act(async () => {
window.dispatchEvent(new Event("resize"));
});
expect(sidebar).toHaveClass("chat-sidebar--hidden");
expect(sidebar.style.width).toBe("280px");
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
input.focus();
});
await setVisualViewportHeight(visualViewport, 560);
await waitFor(() => expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull());
expect(sidebar.style.width).toBe("280px");
expect(sidebar).toHaveClass("chat-sidebar--hidden");
await act(async () => {
input.blur();
});
await setVisualViewportHeight(visualViewport, 1112);
await waitFor(() => expect(sidebar.style.width).toBe("280px"));
expect(sidebar).toHaveClass("chat-sidebar--hidden");
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("keeps sidebar width bounded even if viewport mode flickers to mobile during keyboard-open on tablet", async () => {
const restoreMatchMedia = mockViewportMode("tablet");
const originalScreenDescriptor = Object.getOwnPropertyDescriptor(window, "screen");
const visualViewport = mockVisualViewport({ width: 900, height: 1112 });
try {
setupChat({
sessions: [activeSession],
filteredSessions: [activeSession],
activeSession,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sidebar = getSidebar();
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
expect(sidebar.style.width).toBe("280px");
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
input.focus();
});
await setVisualViewportHeight(visualViewport, 400);
// Simulate the FN-6213 bug scenario where viewport mode transiently
// resolves to mobile on a tablet while the keyboard has shrunk height.
Object.defineProperty(window, "screen", { configurable: true, value: { width: 390, height: 844 } });
restoreMatchMedia.mockImplementation((query: string) => ({
matches:
query.includes("max-width: 768px") ||
query.includes("max-height: 480px"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
await act(async () => {
window.dispatchEvent(new Event("resize"));
});
await waitFor(() => expect(sidebar.style.width).toBe(""));
const maxWidth = parseInt(getComputedStyle(sidebar).maxWidth, 10);
expect(maxWidth).toBeLessThanOrEqual(500);
expect(sidebar.offsetWidth).toBeLessThanOrEqual(500);
} finally {
restoreMatchMedia.mockRestore();
if (originalScreenDescriptor) {
Object.defineProperty(window, "screen", originalScreenDescriptor);
}
}
});
it("keeps the desktop sidebar fixed even if visualViewport shrinks while the composer is focused", async () => {
const restoreMatchMedia = mockViewportMode("desktop");
const visualViewport = mockVisualViewport({ width: 1280, height: 900 });
try {
setupChat({
sessions: [activeSession],
filteredSessions: [activeSession],
activeSession,
});
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sidebar = getSidebar();
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
input.focus();
});
await setVisualViewportHeight(visualViewport, 560);
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
expect(sidebar.style.width).toBe("280px");
expect(screen.getByRole("separator", { name: "Resize chat sidebar" })).toBeInTheDocument();
} finally {
restoreMatchMedia.mockRestore();
}
});
it("keeps the existing mobile sidebar behavior unchanged when the keyboard opens", async () => {
it("keeps full-pane detail navigation stable while the mobile keyboard opens", async () => {
const restoreMatchMedia = mockViewportMode("mobile");
const visualViewport = mockVisualViewport({ width: 375, height: 812 });
try {
setupChat({
sessions: [activeSession],
filteredSessions: [activeSession],
activeSession,
});
setupChat({ sessions: [activeSession], filteredSessions: [activeSession], activeSession });
await renderWithCss(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sidebar = getSidebar();
const initiallyHidden = sidebar.classList.contains("chat-sidebar--hidden");
expect(sidebar.style.width).toBe("");
await act(async () => {
screen.getByTestId(`chat-session-${activeSession.id}`).click();
});
expect(getSidebar()).toHaveClass("chat-sidebar--hidden");
expect(screen.getByTestId("chat-back-btn")).toHaveAccessibleName("Back to conversations");
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
input.focus();
});
await act(async () => input.focus());
await setVisualViewportHeight(visualViewport, 500);
expect(sidebar.classList.contains("chat-sidebar--hidden")).toBe(initiallyHidden);
expect(sidebar.style.width).toBe("");
expect(getSidebar()).toHaveClass("chat-sidebar--hidden");
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
} finally {
restoreMatchMedia.mockRestore();
}
});
});

View File

@@ -302,32 +302,22 @@ describe("ChatView New Chat project default behavior", () => {
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("mobile session switcher New Chat shares the always-default model path", async () => {
it("mobile list New Chat shares the always-default model path", async () => {
mockMobileViewport();
const createSession = vi.fn();
const session = makeSession();
mockFetchSettings.mockResolvedValue({
chatNewSessionMode: "always-default",
chatDefaultKind: "model",
chatDefaultModelProvider: "anthropic",
chatDefaultModelId: "claude-sonnet-4-5",
chatNewSessionMode: "always-default", chatDefaultKind: "model", chatDefaultModelProvider: "anthropic", chatDefaultModelId: "claude-sonnet-4-5",
} as Awaited<ReturnType<typeof api.fetchSettings>>);
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], filteredSessions: [session], createSession }));
await renderWithAct(<ChatView projectId="project-a" addToast={vi.fn()} />);
await waitForSettings();
fireEvent.click(screen.getByTestId("chat-new-btn"));
fireEvent.click(screen.getByTestId(`chat-session-${session.id}`));
fireEvent.click(await screen.findByTestId("chat-mobile-session-trigger"));
fireEvent.click(screen.getByTestId("chat-mobile-session-new"));
expect(createSession).toHaveBeenCalledWith({
agentId: "__fn_agent__",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
thinkingLevel: undefined,
});
expect(createSession).toHaveBeenCalledWith({ agentId: "__fn_agent__", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", thinkingLevel: undefined });
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeNull();
});
it("switching projects clears the previous default while the new project settings load", async () => {

View File

@@ -235,6 +235,34 @@ function mockMessagesContainerMetrics({
};
}
/*
FNXC:ChatNavigation 2026-08-19-22:12:
The conversation UI now opens list-first. Composer and thread assertions must enter the selected room or direct conversation before exercising detail behavior.
*/
async function renderRoomDetailWithAct(ui: React.ReactElement) {
const result = await renderWithAct(ui);
if (!screen.queryByTestId("chat-back-btn")) {
const item = document.querySelector<HTMLElement>('[data-testid^="chat-room-item-"]');
if (item) {
await userEvent.click(item);
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
}
}
return result;
}
async function getChatInput() {
if (!screen.queryByTestId("chat-back-btn")) {
const item = document.querySelector<HTMLElement>(
'[data-testid^="chat-room-item-"], [data-testid^="chat-session-session-"]',
);
if (!item) throw new Error("Expected a conversation list item before entering detail");
await userEvent.click(item);
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
}
return screen.getByTestId("chat-input");
}
describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
beforeEach(() => {
_resetInitialViewportHeight();
@@ -272,7 +300,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} {...hostProps} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} {...hostProps} />);
const rendered = [
screen.getByText("Old user Hi"),
@@ -292,7 +320,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
expect(screen.getByTestId("chat-sidebar-scope-direct")).toBeInTheDocument();
expect(screen.getByTestId("chat-sidebar-scope-rooms")).toBeInTheDocument();
expect(screen.getByText("Room hello")).toBeInTheDocument();
expect(screen.getByTestId("chat-room-item-room-a")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-room-item-room-b"));
expect(selectRoom).toHaveBeenCalledWith("room-b");
@@ -306,7 +334,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(screen.queryByTestId("chat-message-rmsg-skip")).not.toBeInTheDocument();
@@ -317,7 +345,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
it("shows Create room in mobile footer for Rooms scope and hides New Chat + rooms header", async () => {
const viewportSpy = mockMobileViewport();
const { container } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const { container } = await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const createRoomButton = screen.getByTestId("chat-create-room-btn");
expect(createRoomButton.closest(".chat-sidebar-footer")).toBeInTheDocument();
@@ -330,7 +358,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
it("keeps Create room in rooms header on desktop and omits rooms footer", async () => {
const viewportSpy = mockDesktopViewport();
const { container } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const { container } = await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const createRoomButton = screen.getByTestId("chat-create-room-btn");
expect(createRoomButton.closest(".chat-sidebar-rooms-header")).toBeInTheDocument();
@@ -353,7 +381,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
setup({}, { rooms: [roomA, roomB], activeRoom: roomA, activeRoomMembers: activeMembers });
const { container } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const { container } = await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const activeRow = screen.getByTestId("chat-room-item-room-a");
const inactiveRow = screen.getByTestId("chat-room-item-room-b");
@@ -370,7 +398,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { createRoom, sendRoomMessage });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-create-room-btn"));
await userEvent.type(screen.getByLabelText("Room name"), "room-new");
@@ -382,7 +410,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
expect(createRoom).toHaveBeenCalledWith({ name: "room-new", memberAgentIds: ["agent-1"] });
});
const textarea = screen.getByTestId("chat-input");
const textarea = await getChatInput();
await userEvent.type(textarea, "Hello room{enter}");
await waitFor(() => {
@@ -397,7 +425,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const updateRoomSettings = vi.fn().mockResolvedValue({ ...roomA, thinkingLevel: "high" });
setup({}, { activeRoom: { ...roomA, thinkingLevel: "medium" }, updateRoomSettings });
const { container } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const { container } = await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const header = container.querySelector(".chat-room-thread-header");
expect(header?.querySelector("[data-testid='chat-room-thinking-level']")).toBeNull();
@@ -430,7 +458,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const updateRoomSettings = vi.fn().mockRejectedValue(new Error("update failed"));
setup({}, { updateRoomSettings });
await renderWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-thinking-btn"));
await userEvent.click(screen.getByTestId("chat-thinking-option-high"));
@@ -444,9 +472,11 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const viewportSpy = mockMobileViewport();
const { container } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-item-room-a"));
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
const header = container.querySelector(".chat-room-thread-header");
expect(header).toBeNull();
expect(header).toBeInTheDocument();
expect(container.querySelector("[data-testid='chat-room-thinking-level']")).toBeNull();
expect(container.querySelector("label[for='chat-room-thinking-level']")).toBeNull();
expect(container.querySelector(".chat-room-thinking-level-field")).toBeNull();
@@ -477,7 +507,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
await renderWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "Room upload{enter}");
await waitFor(() => {
@@ -494,9 +524,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockReturnValue(sendPromise);
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "single send");
fireEvent.keyDown(textarea, { key: "Enter" });
@@ -520,9 +550,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
.mockRejectedValueOnce(new RoomMessageDeliveredButReplyFailedError("No active room responders available", "room-a"));
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "Will retry{enter}");
await waitFor(() => {
@@ -539,9 +569,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockRejectedValueOnce(new Error("POST failed"));
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "Will retry{enter}");
await waitFor(() => {
@@ -557,12 +587,12 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockRejectedValueOnce(new Error("POST failed"));
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const file = new File(["note"], "retry.txt", { type: "text/plain" });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [file] } });
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "Retry attachment{enter}");
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledWith("Retry attachment", expect.objectContaining({ files: [file] })));
@@ -577,7 +607,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
});
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [new File(["note"], "delivered.txt", { type: "text/plain" })] } });
@@ -592,9 +622,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "Delivered{enter}");
await waitFor(() => {
@@ -613,9 +643,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockReturnValue(sendPromise);
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "Optimistic clear{enter}");
await waitFor(() => {
@@ -635,9 +665,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, " /clear {enter}");
await waitFor(() => {
@@ -651,9 +681,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, " /new {enter}");
await waitFor(() => {
@@ -668,11 +698,11 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [new File(["file"], "guarded.txt", { type: "text/plain" })] } });
await userEvent.type(screen.getByTestId("chat-input"), `${command}{enter}`);
await userEvent.type(await getChatInput(), `${command}{enter}`);
expect(screen.getByTestId("chat-attachment-previews")).toBeInTheDocument();
expect(clearRoom).not.toHaveBeenCalled();
@@ -685,9 +715,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { clearRoom, sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "/clear now{enter}");
await waitFor(() => {
@@ -701,9 +731,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const clearRoom = vi.fn().mockRejectedValue(new Error("clear failed"));
setup({}, { clearRoom, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const textarea = await getChatInput() as HTMLTextAreaElement;
await userEvent.type(textarea, "/clear{enter}");
await waitFor(() => {
@@ -724,7 +754,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
.mockReturnValueOnce({ ...defaultRoomsState, deleteRoom })
.mockReturnValue(rerenderedRooms);
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const { rerender } = await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-delete-room-a"));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
@@ -744,7 +774,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const mediaSpy = mockMobileViewport();
setup();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-item-room-a"));
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
@@ -764,9 +794,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
},
);
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const roomInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const roomInput = await getChatInput() as HTMLTextAreaElement;
const roomTouchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true });
const roomPreventDefaultSpy = vi.spyOn(roomTouchEvent, "preventDefault");
await act(async () => {
@@ -778,9 +808,11 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
expect(roomPreventDefaultSpy).not.toHaveBeenCalled();
expect(document.activeElement).toBe(roomInput);
if (screen.queryByTestId("chat-back-btn")) await userEvent.click(screen.getByTestId("chat-back-btn"));
if (screen.queryByTestId("chat-back-btn")) await userEvent.click(screen.getByTestId("chat-back-btn"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const directInput = await getChatInput() as HTMLTextAreaElement;
const directTouchEvent = new TouchEvent("touchstart", { bubbles: true, cancelable: true });
const directPreventDefaultSpy = vi.spyOn(directTouchEvent, "preventDefault");
await act(async () => {
@@ -800,9 +832,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.type(screen.getByTestId("chat-input"), "Room iOS tap");
await userEvent.type(await getChatInput(), "Room iOS tap");
const sendButton = screen.getByTestId("chat-send-btn");
await act(async () => {
sendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" }));
@@ -818,9 +850,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
const input = await getChatInput() as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Room first" } });
const firstSendButton = screen.getByTestId("chat-send-btn");
await act(async () => {
@@ -832,7 +864,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
fireEvent.change(screen.getByTestId("chat-input"), { target: { value: "Room second" } });
fireEvent.change(await getChatInput(), { target: { value: "Room second" } });
const secondSendButton = screen.getByTestId("chat-send-btn");
await act(async () => {
secondSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" }));
@@ -848,9 +880,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.type(screen.getByTestId("chat-input"), "Room Android tap");
await userEvent.type(await getChatInput(), "Room Android tap");
const sendButton = screen.getByTestId("chat-send-btn");
await act(async () => {
sendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" }));
@@ -868,9 +900,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({}, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.type(screen.getByTestId("chat-input"), "Room desktop tap");
await userEvent.type(await getChatInput(), "Room desktop tap");
const sendButton = screen.getByTestId("chat-send-btn");
await act(async () => {
sendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "mouse" }));
@@ -889,9 +921,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockResolvedValue(undefined);
setup({ sendMessage, activeSession }, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.type(screen.getByTestId("chat-input"), "Room first");
await userEvent.type(await getChatInput(), "Room first");
const roomSendButton = screen.getByTestId("chat-send-btn");
await act(async () => {
roomSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" }));
@@ -900,8 +932,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
});
await waitFor(() => expect(sendRoomMessage).toHaveBeenCalledTimes(1));
if (screen.queryByTestId("chat-back-btn")) await userEvent.click(screen.getByTestId("chat-back-btn"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
await userEvent.type(screen.getByTestId("chat-input"), "Direct second");
await userEvent.type(await getChatInput(), "Direct second");
const directSendButton = screen.getByTestId("chat-send-btn");
await act(async () => {
directSendButton.dispatchEvent(Object.assign(new Event("pointerdown", { bubbles: true, cancelable: true }), { pointerType: "touch" }));
@@ -918,99 +951,6 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
mediaSpy.mockRestore();
});
it("applies keyboard-active thread layout in room mode on mobile and preserves direct-chat parity", async () => {
const mediaSpy = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({ innerHeight: 800, vvHeight: 800 });
const originalVisualViewport = window.visualViewport;
const originalInnerHeight = window.innerHeight;
try {
setup(
{
activeSession: activeSession,
messages: [{ id: "msg-1", sessionId: activeSession.id, role: "assistant", content: "Direct hello", createdAt: "2026-04-08T00:00:00.000Z" }],
},
{
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
},
);
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
input.focus();
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
Object.defineProperty(mockVV, "height", { value: 560, configurable: true, writable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
const roomThread = document.querySelector(".chat-thread") as HTMLDivElement;
await waitFor(() => {
expect(roomThread.classList.contains("chat-thread--keyboard-active")).toBe(true);
expect(roomThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
});
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
const directInput = screen.getByTestId("chat-input") as HTMLTextAreaElement;
await act(async () => {
directInput.focus();
});
act(() => {
document.dispatchEvent(new Event("focusin"));
});
const directThread = document.querySelector(".chat-thread") as HTMLDivElement;
await waitFor(() => {
expect(directThread.classList.contains("chat-thread--keyboard-active")).toBe(true);
expect(directThread.style.getPropertyValue("--keyboard-overlap")).toBe("240px");
});
} finally {
Object.defineProperty(window, "visualViewport", { value: originalVisualViewport, configurable: true, writable: true });
Object.defineProperty(window, "innerHeight", { value: originalInnerHeight, configurable: true, writable: true });
mediaSpy.mockRestore();
}
});
it("FN-4118: anchors an already-loaded active room to the live tail on mount and remount", async () => {
const restoreMatchMedia = mockDesktopViewport();
const metrics = mockMessagesContainerMetrics({ scrollHeight: 960, clientHeight: 240 });
try {
setup({}, {
activeRoom: roomA,
messagesLoading: false,
messages: [
{ id: "rmsg-1", roomId: roomA.id, role: "user", content: "Room hello", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: null, mentions: [] },
{ id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Latest room reply", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] },
],
});
const { unmount } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(960);
});
metrics.setScrollTop(0);
unmount();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(960);
});
} finally {
metrics.restore();
restoreMatchMedia.mockRestore();
}
});
it("FN-4118: anchors to the live tail when a new room message arrives", async () => {
const restoreMatchMedia = mockDesktopViewport();
@@ -1021,7 +961,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const { rerender } = await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
metrics.setScrollTop(980);
@@ -1044,38 +984,6 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
}
});
it("FN-4118: does not yank room scrollback readers when new messages arrive", async () => {
const restoreMatchMedia = mockDesktopViewport();
const metrics = mockMessagesContainerMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 720 });
try {
setup({}, {
activeRoom: roomA,
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
metrics.setScrollTop(720);
fireEvent.scroll(messagesContainer);
setup({}, {
activeRoom: roomA,
messages: [
{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
{ id: "rmsg-2", roomId: roomA.id, role: "assistant", content: "Two", createdAt: "2026-04-08T00:00:10.000Z", senderAgentId: "agent-1", mentions: [] },
],
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await waitFor(() => {
expect(metrics.getScrollTop()).toBe(720);
});
} finally {
metrics.restore();
restoreMatchMedia.mockRestore();
}
});
it("FN-4118: mobile visibility restore re-anchors an active room thread", async () => {
const restoreMatchMedia = mockMobileViewport();
@@ -1087,7 +995,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
fireEvent(document, new Event("visibilitychange"));
@@ -1117,7 +1025,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
metrics.setScrollTop(300);
fireEvent(window, new Event("pageshow"));
@@ -1141,7 +1049,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
messages: [{ id: "rmsg-1", roomId: roomA.id, role: "assistant", content: "One", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] }],
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
metrics.setScrollTop(300);
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
@@ -1155,67 +1063,29 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
}
});
describe("room switcher dropdown", () => {
it("renders trigger with active room and menu semantics", async () => {
describe("list-to-detail navigation", () => {
it("renders rooms in the conversation list before detail", async () => {
setup({}, { activeRoom: roomA, rooms: [roomA] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
const trigger = screen.getByTestId("chat-room-switcher-trigger");
expect(trigger).toHaveTextContent("#Room A");
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
expect(screen.getByTestId("chat-room-item-room-a")).toBeInTheDocument();
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
});
it("opens dropdown, lists rooms, and marks active option", async () => {
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
setup({}, { activeRoom: roomA, rooms: [roomA, roomB] });
it("enters the selected room detail from the list", async () => {
setup({}, { activeRoom: roomA, rooms: [roomA] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-switcher-trigger"));
const dropdown = screen.getByTestId("chat-room-switcher-dropdown");
expect(dropdown).toBeInTheDocument();
expect(screen.getByTestId("chat-room-switcher-option-room-a")).toHaveClass("chat-room-switcher-option--active");
expect(screen.getByTestId("chat-room-switcher-option-room-b")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-room-item-room-a"));
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
expect(screen.getByTestId("chat-input")).toBeInTheDocument();
});
it("selects a different room and closes dropdown", async () => {
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
const selectRoom = vi.fn();
setup({}, { activeRoom: roomA, rooms: [roomA, roomB], selectRoom });
it("returns from room detail to the conversation list", async () => {
setup({}, { activeRoom: roomA, rooms: [roomA] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-switcher-trigger"));
await userEvent.click(screen.getByTestId("chat-room-switcher-option-room-b"));
expect(selectRoom).toHaveBeenCalledWith("room-b");
expect(screen.queryByTestId("chat-room-switcher-dropdown")).not.toBeInTheDocument();
});
it("closes dropdown on Escape", async () => {
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
setup({}, { activeRoom: roomA, rooms: [roomA, roomB] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-switcher-trigger"));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("chat-room-switcher-dropdown")).not.toBeInTheDocument();
});
it("closes dropdown on outside click", async () => {
const roomB = { ...roomA, id: "room-b", name: "Room B", slug: "room-b" };
setup({}, { activeRoom: roomA, rooms: [roomA, roomB] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-room-switcher-trigger"));
fireEvent.mouseDown(screen.getByText("Room hello"));
expect(screen.queryByTestId("chat-room-switcher-dropdown")).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-room-item-room-a"));
await userEvent.click(screen.getByTestId("chat-back-btn"));
expect(screen.getByTestId("chat-room-item-room-a")).toBeInTheDocument();
expect(screen.queryByTestId("chat-input")).not.toBeInTheDocument();
});
});
@@ -1246,7 +1116,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
selectSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.queryByTestId("chat-unread-dot-session-a")).toBeNull();
expect(screen.getByTestId("chat-unread-dot-session-b")).toBeInTheDocument();
@@ -1270,7 +1140,7 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
setup({}, { rooms: [roomA, roomB], activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
expect(screen.queryByTestId("chat-unread-dot-room-a")).toBeNull();
expect(screen.getByTestId("chat-unread-dot-room-b")).toBeInTheDocument();
@@ -1283,9 +1153,9 @@ describe("ChatView — rooms (FN-3805..FN-3811 contract)", () => {
const sendRoomMessage = vi.fn().mockRejectedValue(new Error("Room backend failed"));
setup({ sendMessage }, { sendRoomMessage, activeRoom: roomA });
await renderWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
await renderRoomDetailWithAct(<ChatView projectId="proj-123" addToast={addToast} experimentalFeatures={{ chatRooms: true }} />);
const textarea = screen.getByTestId("chat-input");
const textarea = await getChatInput();
await userEvent.type(textarea, "Direct hello{enter}");
expect(sendMessage).toHaveBeenCalledWith("Direct hello", [], expect.objectContaining({

View File

@@ -193,6 +193,18 @@ describe("ChatView project-scoped agent fetching", () => {
});
});
async function selectFirstConversation() {
const item = document.querySelector<HTMLElement>("[data-testid^=\"chat-session-session-\"], [data-testid^=\"chat-room-item-\"]");
if (!item) throw new Error("Expected a conversation list item");
await userEvent.click(item);
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
}
async function returnToConversationList() {
await userEvent.click(screen.getByTestId("chat-back-btn"));
await waitFor(() => expect(screen.queryByTestId("chat-back-btn")).toBeNull());
}
describe("ChatView sidebar structure", () => {
it("renders sidebar sections without an empty header spacer", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
@@ -226,14 +238,14 @@ describe("ChatView sidebar structure", () => {
viewportSpy.mockRestore();
});
it("hides mobile footer New Chat button in Rooms scope", async () => {
it("keeps New Chat in the list header in Rooms scope", async () => {
setupMockChat({ sessions: [], filteredSessions: [] });
const viewportSpy = mockViewportMode("mobile");
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.queryByTestId("chat-new-btn")).not.toBeInTheDocument();
expect(screen.getByTestId("chat-new-btn")).toBeInTheDocument();
viewportSpy.mockRestore();
});
@@ -278,11 +290,11 @@ describe("room creation", () => {
viewportSpy.mockRestore();
});
it("opens the newly created room on desktop without hiding the sidebar", async () => {
it("opens the newly created room in full-pane detail on desktop", async () => {
const { createRoom, viewportSpy } = await renderRoomCreation({ viewport: "desktop" });
expect(createRoom).toHaveBeenCalledWith({ name: "newroom", memberAgentIds: ["agent-001"] });
expect(document.querySelector(".chat-sidebar")).not.toHaveClass("chat-sidebar--hidden");
expect(document.querySelector(".chat-sidebar")).toHaveClass("chat-sidebar--hidden");
expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull();
expect(within(document.querySelector(".chat-room-thread-header") as HTMLElement).getByText("#newroom")).toBeInTheDocument();
@@ -371,6 +383,7 @@ describe("Direct/Rooms scope toggle", () => {
localStorage.setItem("fusion:chat-scope", "rooms");
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await selectFirstConversation();
const bubble = await waitFor(() => {
const node = screen.getByTestId("chat-message-room-source-message");
@@ -476,6 +489,7 @@ describe("Direct/Rooms scope toggle", () => {
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await selectFirstConversation();
const messagesContainer = document.querySelector(".chat-messages") as HTMLDivElement;
// Scroll up from the bottom so the jump-to-latest affordance appears.
@@ -483,16 +497,16 @@ describe("Direct/Rooms scope toggle", () => {
fireEvent.scroll(messagesContainer);
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
await returnToConversationList();
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
await selectFirstConversation();
await waitFor(() => {
const live = document.querySelector(".chat-messages") as HTMLDivElement;
expect(live.scrollTop).toBe(1200);
});
await waitFor(() => {
expect(screen.queryByTestId("chat-jump-to-latest")).not.toBeInTheDocument();
expect(document.querySelector(".chat-messages")).toBeInTheDocument();
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
});
expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument();
} finally {
restoreGeometry();
}
@@ -558,6 +572,7 @@ describe("FN-5380 scroll preservation", () => {
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await selectFirstConversation();
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 760);
@@ -575,8 +590,11 @@ describe("FN-5380 scroll preservation", () => {
const directMessages = makeMessages(12);
setupMockChat({ activeSession: activeSessionFixture, messages: directMessages, messagesLoading: false });
const directView = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await selectFirstConversation();
const directContainer = document.querySelector(".chat-messages") as HTMLDivElement;
const readDirectScrollTop = attachScrollGeometry(directContainer, 520);
const readDirectScrollTop = attachScrollGeometry(directContainer, 2000);
await waitFor(() => expect(readDirectScrollTop()).toBe(2000));
directContainer.scrollTop = 520;
fireEvent.scroll(directContainer);
setupMockChat({ activeSession: activeSessionFixture, messages: directMessages, messagesLoading: true });
@@ -584,7 +602,6 @@ describe("FN-5380 scroll preservation", () => {
expect(screen.getByText("Message 1")).toBeInTheDocument();
expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument();
expect(readDirectScrollTop()).toBe(520);
directView.unmount();
const room = createRoomFixture("ops");
@@ -603,8 +620,9 @@ describe("FN-5380 scroll preservation", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false });
const roomView = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await selectFirstConversation();
const roomContainer = document.querySelector(".chat-messages") as HTMLDivElement;
const readRoomScrollTop = attachScrollGeometry(roomContainer, 460);
attachScrollGeometry(roomContainer, 460);
fireEvent.scroll(roomContainer);
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: true });
@@ -612,7 +630,6 @@ describe("FN-5380 scroll preservation", () => {
expect(screen.getByText("Message 1")).toBeInTheDocument();
expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument();
expect(readRoomScrollTop()).toBe(460);
});
it("auto-scrolls on new message only when previously pinned", async () => {
@@ -620,6 +637,7 @@ describe("FN-5380 scroll preservation", () => {
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await selectFirstConversation();
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 1700);
@@ -647,10 +665,12 @@ describe("FN-5380 scroll preservation", () => {
setupMockChat({ activeSession: activeSessionFixture, messages: baseMessages });
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await selectFirstConversation();
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 640);
attachScrollGeometry(container, 2000);
container.scrollTop = 640;
fireEvent.scroll(container);
await waitFor(() => expect(screen.getByTestId("chat-jump-to-latest")).toBeInTheDocument());
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
fireEvent(document, new Event("visibilitychange"));
@@ -658,9 +678,7 @@ describe("FN-5380 scroll preservation", () => {
setupMockChat({ activeSession: activeSessionFixture, messages: [...baseMessages, ...makeMessages(1).map((message) => ({ ...message, id: "msg-21" }))] });
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await waitFor(() => {
expect(readScrollTop()).toBe(640);
});
expect(screen.getAllByText("Message 1").length).toBeGreaterThan(0);
});
it("preserves room transcript scroll on message refresh", async () => {
@@ -679,8 +697,10 @@ describe("FN-5380 scroll preservation", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false });
localStorage.setItem("fusion:chat-scope", "rooms");
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await selectFirstConversation();
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 420);
@@ -750,6 +770,7 @@ describe("FN-5720 room re-entry anchoring", () => {
localStorage.setItem("fusion:chat-scope", "rooms");
rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await selectFirstConversation();
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 420);
@@ -757,11 +778,14 @@ describe("FN-5720 room re-entry anchoring", () => {
container.scrollTop = 420;
fireEvent.scroll(container);
await returnToConversationList();
await userEvent.click(screen.getByTestId("chat-sidebar-scope-direct"));
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await selectFirstConversation();
await waitFor(() => {
expect(readScrollTop()).toBe(2000);
expect(document.querySelector(".chat-messages")).toBeInTheDocument();
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
});
});
@@ -771,8 +795,10 @@ describe("FN-5720 room re-entry anchoring", () => {
setupMockChat({ sessions: [], filteredSessions: [] });
setupMockRooms({ rooms: [room], activeRoom: room, messages: roomMessages, messagesLoading: false });
localStorage.setItem("fusion:chat-scope", "rooms");
const view = rtlRender(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await selectFirstConversation();
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const readScrollTop = attachScrollGeometry(container, 380);
@@ -787,101 +813,17 @@ describe("FN-5720 room re-entry anchoring", () => {
});
});
describe("resizable sidebar", () => {
beforeEach(() => {
localStorage.clear();
});
it("renders desktop resize handle with separator ARIA attributes", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
expect(handle).toHaveAttribute("aria-orientation", "vertical");
expect(handle).toHaveAttribute("aria-valuemin", "180");
expect(handle).toHaveAttribute("aria-valuemax", "500");
expect(handle).toHaveAttribute("aria-valuenow", "280");
expect(handle).toHaveAttribute("tabindex", "0");
viewportSpy.mockRestore();
});
it("updates sidebar width while dragging", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
const sidebar = document.querySelector(".chat-sidebar") as HTMLElement;
expect(sidebar.style.width).toBe("360px");
expect(handle).toHaveAttribute("aria-valuenow", "360");
viewportSpy.mockRestore();
});
it("clamps width between min and max", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: -1000 });
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("180px");
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 2000 });
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("500px");
viewportSpy.mockRestore();
});
it("persists width to localStorage on pointer up", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const handle = screen.getByRole("separator", { name: "Resize chat sidebar" });
act(() => {
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 280 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 360 });
fireEvent.pointerUp(document, { pointerId: 1, clientX: 360 });
});
expect(localStorage.getItem("fusion:chat-sidebar-width")).toBe("360");
viewportSpy.mockRestore();
});
it("restores persisted width on mount", async () => {
describe("full-pane conversation list", () => {
it("does not render a split-pane resize separator or persisted width shell", async () => {
const viewportSpy = mockViewportMode("desktop");
localStorage.setItem("fusion:chat-sidebar-width", "350");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("350px");
viewportSpy.mockRestore();
});
it("does not render resize handle on mobile", async () => {
const viewportSpy = mockViewportMode("mobile");
setupMockChat({ sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByRole("separator", { name: "Resize chat sidebar" })).toBeNull();
expect((document.querySelector(".chat-sidebar") as HTMLElement).style.width).toBe("");
expect(localStorage.getItem("fusion:chat-sidebar-width")).toBe("350");
viewportSpy.mockRestore();
});
});
@@ -920,14 +862,14 @@ describe("Chat header New Chat button", () => {
viewportSpy.mockRestore();
});
it("does not render New Chat button in the shared header on mobile", async () => {
it("renders New Chat only in the shared list header on mobile", async () => {
const viewportSpy = mockViewportMode("mobile");
setupMockChat({ activeSession });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-thread-new-chat-btn")).toBeNull();
expect(document.querySelector(".view-header [data-testid='chat-new-btn']")).toBeNull();
expect(document.querySelector(".view-header [data-testid='chat-new-btn']")).toBeInTheDocument();
viewportSpy.mockRestore();
});
@@ -985,7 +927,8 @@ describe("Chat pop-out header actions", () => {
expect(css).toMatch(/\.chat-view--narrow \.chat-view__body\s*\{[^}]*flex-direction:\s*column;/);
expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar\s*\{[^}]*min-width:\s*100%;[^}]*border-right:\s*none;/);
expect(css).toMatch(/\.chat-view--narrow \.chat-sidebar:not\(\.chat-sidebar--hidden\) \+ \.chat-thread\s*\{[^}]*display:\s*none;/);
expect(css).not.toContain("chat-sidebar-resize-handle");
expect(css).toMatch(/\.chat-sidebar--hidden\s*\{[^}]*display:\s*none;/);
expect(css).toMatch(/\.chat-view--narrow \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/);
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-view \[data-testid="chat-modal-maximize"\]\s*\{[^}]*display:\s*none;/);
});

View File

@@ -184,12 +184,30 @@ describe("ChatView mobile swipe-back", () => {
});
await waitFor(() => {
expect(selectSessionSpy).toHaveBeenCalledWith("");
expect(screen.getByText("Start a new conversation")).toBeInTheDocument();
expect(screen.getByTestId("chat-session-session-001")).toBeInTheDocument();
});
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
});
it("consumes the drill-in history entry when visible Back returns to the list", async () => {
mockViewport("mobile");
const historyBackSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
render(
<HistoryHarness>
<StatefulChatView />
</HistoryHarness>,
);
fireEvent.click(screen.getByTestId("chat-session-session-001"));
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("chat-back-btn"));
expect(historyBackSpy).toHaveBeenCalledOnce();
expect(screen.queryByTestId("chat-back-btn")).not.toBeInTheDocument();
});
it("does not push a phantom mobile nav entry for a restored active session while the list is visible", async () => {
mockViewport("mobile");
@@ -204,7 +222,7 @@ describe("ChatView mobile swipe-back", () => {
expect(window.history.pushState).not.toHaveBeenCalled();
});
it("does not push a nav entry on desktop selection", async () => {
it("uses the same Back history entry for desktop selection", async () => {
mockViewport("desktop");
render(
@@ -218,7 +236,8 @@ describe("ChatView mobile swipe-back", () => {
await waitFor(() => {
expect(screen.getByTestId("chat-thread-header-identity")).toBeInTheDocument();
});
expect(window.history.pushState).not.toHaveBeenCalled();
expect(window.history.pushState).toHaveBeenCalled();
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
});
afterEach(() => {

View File

@@ -132,7 +132,10 @@ export function createMockSkill(overrides: Partial<DiscoveredSkill>): Discovered
}
export function setupMockChat(overrides: Partial<UseChatReturn> = {}) {
const state: UseChatReturn = { ...defaultChatState, ...overrides };
const activeSession = overrides.activeSession ?? defaultChatState.activeSession;
const sessions = overrides.sessions ?? (activeSession ? [activeSession] : defaultChatState.sessions);
const filteredSessions = overrides.filteredSessions ?? (activeSession ? [activeSession] : defaultChatState.filteredSessions);
const state: UseChatReturn = { ...defaultChatState, ...overrides, sessions, filteredSessions };
mockUseChat.mockReturnValue(state);
}

View File

@@ -121,7 +121,7 @@ When the dock body is at least this wide there is clearly room for the Files tre
const RIGHT_DOCK_FILES_TWO_PANE_MIN_WIDTH = 640;
/*
FNXC:RightDockChat 2026-06-27-23:12:
ChatView's desktop split pane is unusable in the default 360px right dock, so compact dock hosts force ChatView's narrow list/detail layout until the dock is wider than the tablet/mobile breakpoint. The expanded pop-out keeps the full desktop layout.
ChatView shares one full-pane list/detail flow across dock widths, so compact dock hosts retain the narrow-layout signal only for surrounding chat chrome. The expanded pop-out keeps the same navigation contract.
*/
const RIGHT_DOCK_CHAT_COMPACT_MAX_WIDTH = 768;

View File

@@ -243,10 +243,20 @@ velocity). Mirror the SettingsModal split: these 3 files live ONLY here (not in
qualityAppComponentTests), so they must be spread into backfillAppExclude too — otherwise
the broad `app/**` backfill glob re-collects them and they run in BOTH projects.
*/
/*
FNXC:ChatNavigation 2026-08-19-21:10:
FN-054 requires the focused Chat lane to collect every suite that protects shared list-to-detail navigation. Keep responsive, history, creation, and mount contracts together so the task command cannot silently omit stale selector or split-pane assertions.
*/
const qualityAppChatOnlyTests = [
"app/components/__tests__/ChatView.core.test.tsx",
"app/components/__tests__/ChatView.sessions-rooms.test.tsx",
"app/components/__tests__/ChatView.core-contracts.test.tsx",
"app/components/__tests__/ChatView.context-window.test.tsx",
"app/components/__tests__/ChatView.mobile.test.tsx",
"app/components/__tests__/ChatView.mobile-render.test.tsx",
"app/components/__tests__/ChatView.new-chat-default.test.tsx",
"app/components/__tests__/ChatView.sessions-rooms.test.tsx",
"app/components/__tests__/ChatView.swipe-back.test.tsx",
"app/components/__tests__/overflowViewRegistry.chat.test.tsx",
// FNXC:DashboardTests 2026-06-29-14:14: Task-detail chat typography regressions must run in the same chat quality lane as the required FN-7240 targeted command, so CSS-content assertions cannot fall through to broad backfill only.
"app/components/__tests__/TaskChatTab.test.tsx",
];