FN-9192: add direct chat title switcher

Make Direct conversation titles open an in-place, keyboard-accessible chat switcher.

- Add a responsive title dropdown with pinned, recent, unread, and active conversation states.
- Reuse existing session-selection and back-navigation paths from ChatView.
- Cover interaction, ordering, limits, keyboard behavior, and Room-title exclusions.
- Document the switcher and add a minor release changeset.

Files changed:
 .changeset/fn-9192-chat-title-switcher.md          |   7 +
 docs/dashboard-guide.md                            |   4 +
 .../app/components/ChatThreadTitleSwitcher.css     | 139 +++++++++++++++
 .../app/components/ChatThreadTitleSwitcher.tsx     | 191 +++++++++++++++++++++
 packages/dashboard/app/components/ChatView.tsx     |  17 +-
 .../__tests__/ChatView.title-switcher.test.tsx     | 171 ++++++++++++++++++
 6 files changed, 528 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-9192

Fusion-Task-Lineage: 1e499595-6d82-4ba3-8599-2470d2e89429

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-22 20:37:46 -07:00
parent 3af54418cf
commit 0fc2c23f24
6 changed files with 528 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Switch Direct chats from the thread title without returning to the conversation list.
category: feature
dev: Adds the ChatThreadTitleSwitcher component and stable menu test IDs.

View File

@@ -116,6 +116,10 @@ Thinking panes split titled reasoning traces into independently expandable secti
In an active visible Chat list, <kbd>Ctrl+F</kbd> or <kbd>Cmd+F</kbd> focuses the existing conversation search without changing its query. In a selected Direct conversation, Room, or native/hybrid CLI transcript, the same chord opens **Find in conversation**. Type a literal case-insensitive query, use Enter (Shift+Enter for previous) or the previous/next controls to move through matching message rows, and use Escape or Close search to dismiss it. Only the activated full, floating, or docked Chat host owns the chord; raw/generic CLI terminals and nested dialogs keep their own browser or terminal behavior.
### Direct conversation switcher
When a Direct conversation is open, click its thread title or activate it with the keyboard to open the conversation switcher. Pick a listed conversation to switch in place, or choose **All conversations** to return to the full list. The menu shows pinned conversations first and then recent conversations, with up to 12 entries; the current conversation is marked. Room titles remain plain text and do not open this switcher.
<!-- FNXC:ModalGeometryPersistenceDocs 2026-07-16-00:40: Full-screen mobile FloatingWindow sheets must preserve, rather than overwrite, the movable desktop geometry record so a later desktop reopen restores the user's chosen location and size. -->
<!-- FNXC:GitHubImport 2026-08-02-02:51: FN-8722 confirms that standalone GitHub Import also uses the canonical width-or-height sheet contract, so the operator guide must not describe short-sheet preservation as Artifact Gallery-only. -->
Movable dashboard pop-outs remember their last desktop location and size, while centered resizable dialogs remember their size. When a pop-out becomes a full-screen sheet at mobile widths or its configured short-height breakpoint, it leaves that desktop record untouched; reopening it on desktop restores the prior floating geometry.

View File

@@ -0,0 +1,139 @@
/*
FNXC:ChatTitleSwitcher 2026-08-23-03:13:
The title trigger stays in the existing flex identity row, while its menu is anchored to that stable
header identity. Token-based sizing keeps the switcher usable in full, narrow, and mobile ChatView
hosts without changing the legacy title truncation rule asserted elsewhere.
*/
.chat-thread-title-switcher {
position: relative;
display: inline-flex;
flex: 1 1 auto;
min-width: 0;
}
.chat-thread-title-trigger {
border: none;
padding: var(--space-3xs, var(--space-xs));
background: transparent;
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
text-align: start;
}
.chat-thread-title-trigger:hover {
background: color-mix(in srgb, var(--surface-hover) 70%, transparent);
}
.chat-thread-title-trigger:focus-visible {
outline: var(--space-3xs) solid var(--accent);
outline-offset: var(--space-3xs);
border-radius: var(--radius-sm);
}
.chat-thread-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-thread-title-caret,
.chat-thread-title-menu-pin {
flex: 0 0 auto;
}
.chat-thread-title-menu {
position: absolute;
inset-block-start: calc(100% + var(--space-xs));
inset-inline-start: 0;
width: min(calc(var(--space-xl) * 15), calc(100vw - (var(--space-lg) * 2)));
max-inline-size: calc(100vw - (var(--space-lg) * 2));
max-block-size: min(calc(var(--space-xl) * 24), calc(100vh - (var(--space-xl) * 4)));
overflow-y: auto;
background: var(--surface);
border: thin solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 50;
}
.chat-thread-title-menu-item,
.chat-thread-title-menu-all,
.chat-thread-title-menu-empty {
box-sizing: border-box;
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
color: var(--text);
text-align: start;
}
.chat-thread-title-menu-item,
.chat-thread-title-menu-all {
border: none;
background: transparent;
cursor: pointer;
}
.chat-thread-title-menu-item:hover,
.chat-thread-title-menu-item:focus-visible,
.chat-thread-title-menu-all:hover,
.chat-thread-title-menu-all:focus-visible {
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
outline: none;
}
.chat-thread-title-menu-item--active {
color: var(--accent);
font-weight: 600;
}
.chat-thread-title-menu-label {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-thread-title-menu-item .status-dot {
flex: 0 0 auto;
}
.chat-thread-title-menu-all {
border-block-start: thin solid var(--border);
font-weight: 600;
}
.chat-thread-title-menu-empty {
color: var(--text-muted);
}
.chat-view--narrow .chat-thread-title-switcher {
position: static;
}
.chat-view--narrow .chat-thread-title-menu {
inset-inline: var(--space-md);
width: auto;
max-inline-size: none;
max-block-size: min(calc(var(--space-xl) * 20), calc(100vh - (var(--space-xl) * 5)));
}
@media (max-width: 768px) {
.chat-thread-title-menu {
inset-inline-start: var(--space-sm);
width: min(calc(var(--space-xl) * 15), calc(100vw - (var(--space-lg) * 2)));
}
.chat-view--narrow .chat-thread-title-menu {
inset-inline: var(--space-sm);
}
}

View File

@@ -0,0 +1,191 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Pin } from "lucide-react";
import type { ChatSessionInfo } from "../hooks/useChat";
import "./ChatThreadTitleSwitcher.css";
export const CHAT_TITLE_SWITCHER_MAX_ITEMS = 12;
export interface ChatThreadTitleSwitcherProps {
title: string;
sessions: ChatSessionInfo[];
activeSessionId: string | null;
onSelect: (sessionId: string) => void;
onViewAll: () => void;
isUnread?: (session: ChatSessionInfo) => boolean;
maxItems?: number;
}
/*
FNXC:ChatTitleSwitcher 2026-08-23-03:13:
FN-9192 makes the Direct thread title own conversation switching because every ChatView host hides
its conversation list while detail is open. Retain the chat-thread-header-title class and title
attribute on the trigger so the established header tooltip and ellipsis-truncation contract remains
unchanged while the title becomes interactive.
*/
export function ChatThreadTitleSwitcher({
title,
sessions,
activeSessionId,
onSelect,
onViewAll,
isUnread,
maxItems = CHAT_TITLE_SWITCHER_MAX_ITEMS,
}: ChatThreadTitleSwitcherProps) {
const { t } = useTranslation("app");
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const previousActiveSessionId = useRef(activeSessionId);
const initialCappedSessions = sessions.slice(0, maxItems);
// FNXC:ChatTitleSwitcher 2026-08-23-03:13: Keep the current thread visible even when it is older than the recency cap.
const activeSession = sessions.find((session) => session.id === activeSessionId);
const cappedSessions = activeSession && !initialCappedSessions.some((session) => session.id === activeSession.id)
? [...initialCappedSessions.slice(0, -1), activeSession]
: initialCappedSessions;
const hasOtherSessions = cappedSessions.some((session) => session.id !== activeSessionId);
const displayedSessions = hasOtherSessions ? cappedSessions : [];
const close = (restoreFocus = false) => {
setOpen(false);
if (restoreFocus) triggerRef.current?.focus();
};
useEffect(() => {
if (!open) return;
const handleOutsidePress = (event: PointerEvent | TouchEvent) => {
const target = event.target;
if (target instanceof Node && !rootRef.current?.contains(target)) close();
};
document.addEventListener("pointerdown", handleOutsidePress);
document.addEventListener("touchstart", handleOutsidePress);
return () => {
document.removeEventListener("pointerdown", handleOutsidePress);
document.removeEventListener("touchstart", handleOutsidePress);
};
}, [open]);
useEffect(() => {
if (previousActiveSessionId.current !== activeSessionId) {
previousActiveSessionId.current = activeSessionId;
close();
}
}, [activeSessionId]);
const selectSession = (sessionId: string) => {
close();
onSelect(sessionId);
};
const viewAll = () => {
close();
onViewAll();
};
const focusItem = (index: number) => {
itemRefs.current[index]?.focus();
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "Escape") {
close(true);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
setOpen(true);
requestAnimationFrame(() => focusItem(event.key === "ArrowDown" ? 0 : displayedSessions.length - 1));
};
const handleMenuItemKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number, sessionId: string) => {
if (event.key === "Escape") {
event.preventDefault();
close(true);
return;
}
if (event.key === "Tab") {
close();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const nextIndex = event.key === "ArrowDown"
? (index + 1) % displayedSessions.length
: (index - 1 + displayedSessions.length) % displayedSessions.length;
focusItem(nextIndex);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectSession(sessionId);
}
};
return (
<div className="chat-thread-title-switcher" ref={rootRef}>
<button
ref={triggerRef}
type="button"
className="chat-thread-header-title chat-thread-title-trigger"
title={title}
aria-haspopup="menu"
aria-expanded={open}
aria-label={t("chat.switchConversation", "Switch conversation")}
data-testid="chat-thread-title-trigger"
onClick={() => setOpen((value) => !value)}
onKeyDown={handleTriggerKeyDown}
>
<span className="chat-thread-title-text">{title}</span>
<ChevronDown size={14} className="chat-thread-title-caret" aria-hidden="true" />
</button>
{open ? (
<div className="chat-thread-title-menu" role="menu" data-testid="chat-thread-title-menu">
{displayedSessions.length === 0 ? (
<div className="chat-thread-title-menu-empty" data-testid="chat-thread-title-menu-empty">
{t("chat.noOtherConversations", "No other conversations")}
</div>
) : displayedSessions.map((session, index) => {
const label = session.title?.trim() || t("chat.untitledConversation", "Untitled conversation");
const active = session.id === activeSessionId;
return (
<button
key={session.id}
ref={(element) => { itemRefs.current[index] = element; }}
type="button"
role="menuitem"
className={`chat-thread-title-menu-item${active ? " chat-thread-title-menu-item--active" : ""}`}
data-testid={`chat-thread-title-menu-item-${session.id}`}
aria-current={active ? "true" : undefined}
onClick={() => selectSession(session.id)}
onKeyDown={(event) => handleMenuItemKeyDown(event, index, session.id)}
>
<span className="chat-thread-title-menu-label">{label}</span>
{session.pinnedAt ? <Pin size={14} className="chat-thread-title-menu-pin" aria-label={t("chat.pinned", "Pinned")} /> : null}
{isUnread?.(session) ? <span className="status-dot" aria-label={t("chat.unread", "Unread")} /> : null}
</button>
);
})}
<button
type="button"
role="menuitem"
className="chat-thread-title-menu-all"
data-testid="chat-thread-title-menu-all"
onClick={viewAll}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
close(true);
} else if (event.key === "Tab") {
close();
}
}}
>
{t("chat.allConversations", "All conversations")}
</button>
</div>
) : null}
</div>
);
}

View File

@@ -34,6 +34,7 @@ import { type Agent, type ChatTag, type Settings } from "@fusion/core";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { MicButton } from "./MicButton";
import { ChatThinkingLevelControl } from "./ChatThinkingLevelControl";
import { ChatThreadTitleSwitcher } from "./ChatThreadTitleSwitcher";
import { PendingChatMessageQueue } from "./PendingChatMessageQueue";
import { AgentMentionPopup } from "./AgentMentionPopup";
import { AgentAvatar } from "./AgentAvatar";
@@ -636,6 +637,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const {
activeSession,
sessions,
sessionsLoading,
messages,
messagesLoading,
@@ -4039,7 +4041,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
</button>
<div className="chat-thread-header-identity" data-testid="chat-thread-header-identity">
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="md" /> : <Bot size={16} />}
<span className="chat-thread-header-title" title={threadHeaderTitle}>{threadHeaderTitle}</span>
{/*
FNXC:ChatTitleSwitcher 2026-08-23-03:13:
FN-9192 makes the Direct title the in-place conversation switcher because the detail
view hides the sidebar on every host. Selection must use handleSessionClick so unread
state, selectSession, and detail-open behavior remain owned by the existing path.
*/}
<ChatThreadTitleSwitcher
title={threadHeaderTitle}
sessions={sessions}
activeSessionId={activeSession?.id ?? null}
onSelect={handleSessionClick}
onViewAll={handleBack}
isUnread={(session) => isUnread("direct", session.id, session.lastMessageAt ?? session.updatedAt)}
/>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
{showThreadHeaderContextWindow && threadHeaderContextValue && threadHeaderContextLabel && chatContextUsage ? (
<span

View File

@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ChatView } from "../ChatView";
import { CHAT_TITLE_SWITCHER_MAX_ITEMS } from "../ChatThreadTitleSwitcher";
import type { ChatSessionInfo } from "../../hooks/useChat";
import { loadAllAppCss } from "../../test/cssFixture";
import {
activeSessionFixture,
defaultChatState,
installChatViewEnv,
mockViewportMode,
renderWithAct,
setupMockChat,
setupMockRooms,
} from "./ChatView.test-harness";
const { markRead } = vi.hoisted(() => ({ markRead: vi.fn() }));
// Factories stay inline: importing the shared harness from a factory creates a TDZ cycle.
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useChatUnread", () => ({
useChatUnread: () => ({ isUnread: () => false, markRead }),
}));
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => ({
...(await importOriginal<typeof import("../../hooks/useNavigationHistory")>()),
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
}));
vi.mock("../CustomModelDropdown", () => ({ CustomModelDropdown: () => null }));
vi.mock("../../api", () => ({
fetchSettings: vi.fn().mockResolvedValue({}),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
}));
installChatViewEnv();
function session(id: string, title: string | null = id): ChatSessionInfo {
return {
...activeSessionFixture,
id,
title,
updatedAt: "2026-08-23T00:00:00.000Z",
};
}
async function renderDirect(options: { sessions?: ChatSessionInfo[]; activeSession?: ChatSessionInfo; floating?: boolean } = {}) {
const activeSession = options.activeSession ?? session("session-001", "Alpha");
const sessions = options.sessions ?? [activeSession, session("session-002", "Beta"), session("session-003", "Gamma")];
const selectSession = vi.fn();
setupMockChat({
...defaultChatState,
activeSession,
sessions,
filteredSessions: sessions,
messages: [{ id: "message-001", sessionId: activeSession.id, role: "assistant", content: "Hello", createdAt: "2026-08-23T00:00:00.000Z" }],
selectSession,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating={options.floating} />);
await userEvent.click(screen.getByTestId(`chat-session-${activeSession.id}`));
return { activeSession, sessions, selectSession };
}
describe("ChatView title switcher", () => {
it("opens ordered direct conversations and marks the active session", async () => {
await renderDirect();
await userEvent.click(screen.getByTestId("chat-thread-title-trigger"));
const menu = await screen.findByTestId("chat-thread-title-menu");
expect(menu).toBeInTheDocument();
expect(screen.getByTestId("chat-thread-title-trigger")).toHaveAttribute("aria-expanded", "true");
expect(within(menu).getByTestId("chat-thread-title-menu-item-session-001")).toHaveAttribute("aria-current", "true");
expect(within(menu).getAllByRole("menuitem")).toHaveLength(4);
});
it("switches by session id without leaving detail view", async () => {
const { selectSession } = await renderDirect();
await userEvent.click(screen.getByTestId("chat-thread-title-trigger"));
await userEvent.click(screen.getByTestId("chat-thread-title-menu-item-session-002"));
expect(selectSession).toHaveBeenCalledWith("session-002");
expect(markRead).toHaveBeenCalledWith("direct", "session-002", expect.any(String));
expect(screen.getByTestId("chat-thread-header-identity")).toBeInTheDocument();
expect(screen.queryByTestId("chat-thread-title-menu")).not.toBeInTheDocument();
});
it("keeps duplicate titles bound to their distinct session ids", async () => {
const active = session("session-001", "Duplicate");
const duplicate = session("session-002", "Duplicate");
const { selectSession } = await renderDirect({ activeSession: active, sessions: [active, duplicate] });
await userEvent.click(screen.getByTestId("chat-thread-title-trigger"));
await userEvent.click(screen.getByTestId("chat-thread-title-menu-item-session-002"));
expect(selectSession).toHaveBeenCalledWith("session-002");
});
it("uses the untitled fallback and shows an empty row for a single session", async () => {
const active = session("session-001", "");
await renderDirect({ activeSession: active, sessions: [active] });
expect(screen.getByTestId("chat-thread-title-trigger")).toHaveTextContent("Untitled conversation");
await userEvent.click(screen.getByTestId("chat-thread-title-trigger"));
expect(screen.getByTestId("chat-thread-title-menu-empty")).toHaveTextContent("No other conversations");
expect(screen.getByTestId("chat-thread-title-menu-all")).toBeInTheDocument();
});
it("caps menu rows and returns to the conversation list from All conversations", async () => {
const sessions = Array.from({ length: CHAT_TITLE_SWITCHER_MAX_ITEMS + 2 }, (_, index) => session(`session-${index}`, `Chat ${index}`));
await renderDirect({ activeSession: sessions[0], sessions });
await userEvent.click(screen.getByTestId("chat-thread-title-trigger"));
expect(screen.getAllByTestId(/chat-thread-title-menu-item-/)).toHaveLength(CHAT_TITLE_SWITCHER_MAX_ITEMS);
await userEvent.click(screen.getByTestId("chat-thread-title-menu-all"));
expect(screen.queryByTestId("chat-thread-header-identity")).not.toBeInTheDocument();
});
it.each([{ name: "mobile", floating: false, viewport: "mobile" as const }, { name: "narrow", floating: true, viewport: "desktop" as const }])(
"switches from the $name host",
async ({ floating, viewport }) => {
const viewportSpy = mockViewportMode(viewport);
const { selectSession } = await renderDirect({ floating });
await userEvent.click(screen.getByTestId("chat-thread-title-trigger"));
await userEvent.click(screen.getByTestId("chat-thread-title-menu-item-session-002"));
expect(selectSession).toHaveBeenCalledWith("session-002");
viewportSpy.mockRestore();
},
);
it("closes on outside press and Escape, and keyboard selects the first row", async () => {
const { selectSession } = await renderDirect();
const trigger = screen.getByTestId("chat-thread-title-trigger");
await userEvent.click(trigger);
fireEvent.pointerDown(document.body);
expect(screen.queryByTestId("chat-thread-title-menu")).not.toBeInTheDocument();
trigger.focus();
await userEvent.keyboard("{ArrowDown}{Enter}");
expect(selectSession).toHaveBeenCalledWith("session-001");
await userEvent.click(trigger);
await userEvent.keyboard("{Escape}");
expect(screen.queryByTestId("chat-thread-title-menu")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it("keeps Rooms titles as plain text without a switcher trigger", async () => {
const activeRoom = { id: "room-001", projectId: "proj-123", slug: "team", name: "Team", createdAt: "2026-08-23T00:00:00.000Z", updatedAt: "2026-08-23T00:00:00.000Z" };
setupMockRooms({ rooms: [activeRoom], activeRoom });
setupMockChat({ ...defaultChatState, sessions: [], filteredSessions: [] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await userEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
await userEvent.click(screen.getByTestId("chat-room-item-team"));
expect(screen.queryByTestId("chat-thread-title-trigger")).not.toBeInTheDocument();
expect(document.querySelector(".chat-room-thread-header .chat-thread-header-title")).toHaveTextContent("#Team");
});
it("preserves title tooltip and both truncation contracts", async () => {
const longTitle = "A deliberately long conversation title that must stay truncated";
await renderDirect({ activeSession: session("session-001", longTitle) });
const trigger = screen.getByTestId("chat-thread-title-trigger");
expect(trigger).toHaveClass("chat-thread-header-title");
expect(trigger).toHaveAttribute("title", longTitle);
expect(document.querySelectorAll("span.chat-thread-header-title")).toHaveLength(0);
const css = await loadAllAppCss();
expect(css).toMatch(/\.chat-thread-header-title\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/);
expect(css).toMatch(/\.chat-thread-title-text\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/);
});
});