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:
7
.changeset/fn-7120-mobile-new-chat.md
Normal file
7
.changeset/fn-7120-mobile-new-chat.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user