FN-7120: add mobile New Chat switcher action

Adds a mobile direct-chat shortcut for starting a new chat from the session switcher.

- Add a New Chat menuitem to the mobile direct-chat session dropdown using the existing NewChatDialog flow.
- Style the new mobile switcher action with touch-friendly spacing and focus handling.
- Extend mobile ChatView tests and docs, and add a changeset for the published CLI package.

Files changed:
 .changeset/fn-7120-mobile-new-chat.md              |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/components/ChatView.css     |  31 ++++++
 packages/dashboard/app/components/ChatView.tsx     |  17 +++
 .../components/__tests__/ChatView.mobile.test.tsx  | 115 +++++++++++++++++++--
 5 files changed, 162 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-7120

Fusion-Task-Lineage: 5d066667-20c1-4015-9b84-a9e558eecd48

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 10:45:31 -07:00
parent e909d41fed
commit 92436d031b
5 changed files with 162 additions and 10 deletions

View File

@@ -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.

View File

@@ -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.
<!-- FNXC:ChatViewDocs 2026-06-23-11:48: Chat responsive docs need to distinguish the mobile all-bubble readability cap from the tablet main Chat assistant-only widening; Quick Chat remains governed by its narrow container so floating desktop hosts do not inherit the tablet measure. -->
- 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.

View File

@@ -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;

View File

@@ -3992,6 +3992,23 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
</button>
</div>
))}
{/*
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.
*/}
<button
type="button"
role="menuitem"
className="chat-mobile-session-new"
data-testid="chat-mobile-session-new"
onClick={() => {
setMobileSessionMenuOpen(false);
setShowNewDialog(true);
}}
>
<Plus size={16} aria-hidden="true" />
<span>{t("chat.newChat", "New Chat")}</span>
</button>
</div>
)}
</div>

View File

@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} floating />);
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);