diff --git a/.changeset/fn-9192-chat-title-switcher.md b/.changeset/fn-9192-chat-title-switcher.md new file mode 100644 index 0000000000..00a33d1c34 --- /dev/null +++ b/.changeset/fn-9192-chat-title-switcher.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7ba1b1f04d..9d2204958e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -116,6 +116,10 @@ Thinking panes split titled reasoning traces into independently expandable secti In an active visible Chat list, Ctrl+F or Cmd+F 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. + 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. diff --git a/packages/dashboard/app/components/ChatThreadTitleSwitcher.css b/packages/dashboard/app/components/ChatThreadTitleSwitcher.css new file mode 100644 index 0000000000..a45aca3245 --- /dev/null +++ b/packages/dashboard/app/components/ChatThreadTitleSwitcher.css @@ -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); + } +} diff --git a/packages/dashboard/app/components/ChatThreadTitleSwitcher.tsx b/packages/dashboard/app/components/ChatThreadTitleSwitcher.tsx new file mode 100644 index 0000000000..994addc984 --- /dev/null +++ b/packages/dashboard/app/components/ChatThreadTitleSwitcher.tsx @@ -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(null); + const triggerRef = useRef(null); + const itemRefs = useRef>([]); + 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) => { + 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, 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 ( +
+ + + {open ? ( +
+ {displayedSessions.length === 0 ? ( +
+ {t("chat.noOtherConversations", "No other conversations")} +
+ ) : displayedSessions.map((session, index) => { + const label = session.title?.trim() || t("chat.untitledConversation", "Untitled conversation"); + const active = session.id === activeSessionId; + return ( + + ); + })} + +
+ ) : null} +
+ ); +} diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 7536c273be..15711686cd 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -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
{activeModelProvider ? : } - {threadHeaderTitle} + {/* + 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. + */} + isUnread("direct", session.id, session.lastMessageAt ?? session.updatedAt)} + /> {showThreadHeaderModelTag && {activeModelTag}} {showThreadHeaderContextWindow && threadHeaderContextValue && threadHeaderContextLabel && chatContextUsage ? ( ({ 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()), + 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(); + 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(); + 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;/); + }); +});