diff --git a/.changeset/fn-7120-mobile-new-chat.md b/.changeset/fn-7120-mobile-new-chat.md new file mode 100644 index 0000000000..c14e197ee9 --- /dev/null +++ b/.changeset/fn-7120-mobile-new-chat.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a New Chat button to the mobile chat quick-switch dropdown. +category: feature +dev: New `chat-mobile-session-new` menuitem in ChatView's mobile session switcher reuses the existing setShowNewDialog/NewChatDialog path; Direct-scope only. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 012c3bac78..2657ccbf84 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -349,7 +349,7 @@ Chat view provides project-scoped conversations with agents. - If you queue a follow-up user message while the assistant is still streaming, Chat persists that queued text per session, shows the queued preview above the input box with a divider, and restores/sends it once the active response finishes if you leave and return. - Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail. - On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail. -- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows. +- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session or start a New Chat without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows. - Direct chat sessions can be renamed from the desktop conversation context menu and from the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again. - On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters. On tablet-width main Chat containers, assistant/agent, streaming, and failure bubbles use a wider reading measure for long responses while user bubbles and narrow floating Quick Chat hosts stay bounded. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index dfa29353f6..38dca12c68 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -700,6 +700,37 @@ Mobile chat session switching needs a dedicated rename tap target beside each se 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: block; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index c579778cf4..55b66b0a88 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -3992,6 +3992,23 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa ))} + {/* + FNXC:Chat 2026-06-27-00:00: + Mobile Direct-scope quick session switching must let users start a new chat without leaving the open thread. Route this affordance through the same setShowNewDialog(true) / NewChatDialog path as the header and sidebar-footer controls. + */} + )} diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx index e320fc349d..f76088d051 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx @@ -177,15 +177,6 @@ describe("ChatView mobile behavior", () => { return { listeners, mockVV }; } - function ensureMatchMedia() { - if (!window.matchMedia) { - Object.defineProperty(window, "matchMedia", { - writable: true, - value: vi.fn(), - }); - } - } - function mockMobileViewport() { ensureMatchMedia(); Object.defineProperty(window, "innerWidth", { value: 375, configurable: true }); @@ -342,6 +333,7 @@ describe("ChatView mobile behavior", () => { await renderWithAct(); expect(screen.queryByTestId("chat-mobile-session-trigger")).not.toBeInTheDocument(); + expect(screen.queryByTestId("chat-mobile-session-new")).not.toBeInTheDocument(); expect(screen.getByText("#backend")).toBeInTheDocument(); } finally { localStorage.setItem("fusion:chat-scope", "direct"); @@ -349,6 +341,111 @@ describe("ChatView mobile behavior", () => { } }); + it("mobile mode: quick session switcher New Chat opens the dialog and closes the menu", async () => { + const restoreMatchMedia = mockMobileViewport(); + try { + setupMockChat({ + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + activeSession: activeSessionFixture, + }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); + const dropdown = screen.getByTestId("chat-mobile-session-dropdown"); + const newChatItem = within(dropdown).getByRole("menuitem", { name: /new chat/i }); + + expect(newChatItem).toBe(screen.getByTestId("chat-mobile-session-new")); + await userEvent.click(newChatItem); + + expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("mobile mode: quick session switcher New Chat renders for single and multiple session lists", async () => { + const restoreMatchMedia = mockMobileViewport(); + try { + setupMockChat({ + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + activeSession: activeSessionFixture, + }); + const singleRender = await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); + expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: /new chat/i })).toBeInTheDocument(); + + singleRender.unmount(); + + const anotherSession = { + id: "session-002", + agentId: "agent-002", + status: "active" as const, + title: "Another Chat", + createdAt: "2026-04-07T00:00:00.000Z", + updatedAt: "2026-04-07T00:00:00.000Z", + }; + setupMockChat({ + sessions: [activeSessionFixture, anotherSession], + filteredSessions: [activeSessionFixture, anotherSession], + activeSession: activeSessionFixture, + }); + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); + expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); + expect(screen.getAllByRole("menuitem")).toHaveLength(3); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("floating narrow mode: quick session switcher includes New Chat", async () => { + const restoreMatchMedia = mockDesktopViewport(); + const originalResizeObserver = globalThis.ResizeObserver; + const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + width: 560, + height: 640, + top: 0, + right: 560, + bottom: 640, + left: 0, + toJSON: () => ({}), + }); + + class MockResizeObserver implements ResizeObserver { + readonly observe = vi.fn(); + readonly unobserve = vi.fn(); + readonly disconnect = vi.fn(); + constructor(_callback: ResizeObserverCallback) {} + } + + globalThis.ResizeObserver = MockResizeObserver; + try { + setupMockChat({ + sessions: [activeSessionFixture], + filteredSessions: [activeSessionFixture], + activeSession: activeSessionFixture, + }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); + expect(screen.getByTestId("chat-mobile-session-new")).toBeInTheDocument(); + } finally { + globalThis.ResizeObserver = originalResizeObserver; + rectSpy.mockRestore(); + restoreMatchMedia.mockRestore(); + } + }); + it("mobile mode: iOS first tap focuses direct composer without blocking native focus, then sends", async () => { const restoreMatchMedia = mockMobileViewport(); const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true);