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:
7
.changeset/fn-9192-chat-title-switcher.md
Normal file
7
.changeset/fn-9192-chat-title-switcher.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
139
packages/dashboard/app/components/ChatThreadTitleSwitcher.css
Normal file
139
packages/dashboard/app/components/ChatThreadTitleSwitcher.css
Normal 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);
|
||||
}
|
||||
}
|
||||
191
packages/dashboard/app/components/ChatThreadTitleSwitcher.tsx
Normal file
191
packages/dashboard/app/components/ChatThreadTitleSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user