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.
|
- 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.
|
- 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, 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.
|
- 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. -->
|
<!-- 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.
|
- 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);
|
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 {
|
.chat-mobile-session-option-title {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -3992,6 +3992,23 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -177,15 +177,6 @@ describe("ChatView mobile behavior", () => {
|
|||||||
|
|
||||||
return { listeners, mockVV };
|
return { listeners, mockVV };
|
||||||
}
|
}
|
||||||
function ensureMatchMedia() {
|
|
||||||
if (!window.matchMedia) {
|
|
||||||
Object.defineProperty(window, "matchMedia", {
|
|
||||||
writable: true,
|
|
||||||
value: vi.fn(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockMobileViewport() {
|
function mockMobileViewport() {
|
||||||
ensureMatchMedia();
|
ensureMatchMedia();
|
||||||
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
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 }} />);
|
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-trigger")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("chat-mobile-session-new")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("#backend")).toBeInTheDocument();
|
expect(screen.getByText("#backend")).toBeInTheDocument();
|
||||||
} finally {
|
} finally {
|
||||||
localStorage.setItem("fusion:chat-scope", "direct");
|
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 () => {
|
it("mobile mode: iOS first tap focuses direct composer without blocking native focus, then sends", async () => {
|
||||||
const restoreMatchMedia = mockMobileViewport();
|
const restoreMatchMedia = mockMobileViewport();
|
||||||
const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true);
|
const isIOSSpy = vi.spyOn(mobileScrollLock, "isIOS").mockReturnValue(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user