FN-096: add New Chat to active conversation headers

Expose the shared New Chat action wherever an active conversation is open.

- Keep New Chat available in list and selected-detail headers across Chat hosts.
- Reset room launches to a persisted Direct session and preserve existing navigation behavior.
- Update responsive styling, documentation, regression coverage, screenshots, and release metadata.

Files changed:
 .changeset/fn-096-chat-header-new-chat.md          |   7 +++
 docs/dashboard-guide.md                            |   6 +--
 packages/dashboard/app/components/ChatView.css     |   3 ++
 packages/dashboard/app/components/ChatView.tsx     |  23 +++++----
 .../components/__tests__/ChatView.mobile.test.tsx  |  15 +++++-
 .../__tests__/ChatView.new-chat-default.test.tsx   |  53 +++++++++++++++++++--
 .../__tests__/ChatView.sessions-rooms.test.tsx     |  36 +++++++++++---
 screenshots/fn-096-chat-header-new-desktop.png      | Bin 0 -> 7476 bytes
 screenshots/fn-096-chat-header-new-mobile.png       | Bin 0 -> 4881 bytes
 9 files changed, 120 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-096

Fusion-Task-Lineage: b63ddd12-87bb-470e-9cc9-a80817270257

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-21 00:16:26 +00:00
parent f714e45bda
commit 3903d3c383
9 changed files with 120 additions and 23 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Start a new chat directly from an active conversation header.
category: feature
dev: Keeps the existing project New Chat default and prompt behavior across Chat hosts.

View File

@@ -734,7 +734,7 @@ The full **New Task** dialog includes a compact **GitHub issue or PR** picker ne
Chat view provides project-scoped conversations with agents. Every host—embedded Chat, Quick Chat, and the dock—starts at the conversation list. Select a conversation to open its thread as the full pane, then use the thread-row **< BACK** action to return to the loaded list without interrupting the thread.
The list owns **New Chat**, search and tag filters, archived/restore, and each row's rename, pin, archive, and delete actions. Detail intentionally contains the saved conversation title, secondary model metadata when available, and its one **< BACK** action: it does not include a conversation selector or duplicate management controls. The default list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action.
The shared Chat header owns **New Chat** in both the list and selected detail across embedded Chat, Quick Chat, mobile, and dock hosts. Search and tag filters, archived/restore, and each row's rename, pin, archive, and delete actions remain list-only. Detail intentionally contains the saved conversation title, secondary model metadata when available, and its one **< BACK** action: it does not include a conversation selector or duplicate management controls. The default list contains only active sessions; use **Archived conversations** to view archived sessions, restore one to the active list, or explicitly delete it. Archive is the default removal action, while delete remains a separate destructive action.
### Conversation layout
@@ -854,9 +854,9 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio
- Uses the same model/provider infrastructure as full Chat view
- On small screens, compact tool-call summaries in the floating panel intentionally stay single-line (count + tool names + status) to preserve message density
- Quick Chat uses the same list-first flow as embedded and dock Chat: its conversation list shows persisted sessions (preferring `session.title`, then falling back to deterministic `Session N` labels).
- Select a session to resume it in the full panel, then use the thread-row **< BACK** action to return to the list. Rename, pin, archive, delete, and New Chat stay in that list rather than appearing in the selected thread.
- Select a session to resume it in the full panel, then use the thread-row **< BACK** action to return to the list. Rename, pin, archive, and delete stay list-only; the shared header keeps **New Chat** available from either list or selected detail.
- Entering `/new` or `/clear` (exact match after trimming) in the Quick Chat composer clears the active thread target: direct/model targets use `startFreshSession(...)`, while room targets call `rooms.clearRoom(activeRoom.id)`.
- **New Chat** is available from the Quick Chat conversation list. Creating a session selects its new full-pane thread immediately; return with **Back to conversations** to browse or manage sessions.
- Creating a session through **New Chat** selects its new full-pane Direct thread immediately, including when launched from a selected Room; return with **Back to conversations** to browse or manage sessions.
- On first open for a project, Quick Chat restores the last opened non-archived session from per-project local storage; if that saved session is missing, it falls back to the most recently touched non-archived session by latest activity (`max(lastMessageAt, updatedAt)`), and only falls back to the first agent / configured default model when no prior session exists.
<!-- FNXC:ChatNavigationDocs 2026-08-20-05:25: FN-068 keeps floating Quick Chat's right-edge header group limited to Open in Chat view and Close, while the selected conversation row owns its saved-title identity and only Back action across responsive hosts. -->
- Closing Quick Chat keeps the active conversation, panel geometry, and message-list scroll position warm in memory. Reopening is an instant visibility restore with no conversation reload, layout reflow, or "Loading conversation…" flash.

View File

@@ -510,6 +510,9 @@ The Direct-chat context budget indicator is a secondary desktop/tablet header af
/*
FNXC:ChatNavigation 2026-08-20-05:25:
FN-068 puts the only Back action beside the selected conversation title. Do not override ViewHeader actions for detail mode: floating Open in Chat view and Close must retain ViewHeader's canonical right alignment.
FNXC:ChatNavigation 2026-08-20-23:57:
FN-096 keeps the shared-header New Chat action visible in selected detail as well as list mode. Its shrinkable token-based sizing preserves the floating Close and the thread-row Back action on narrow embedded, floating, and dock hosts.
*/
.chat-thread-header-back {
flex: 0 0 auto;

View File

@@ -1700,6 +1700,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
async (input: { agentId: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }) => {
try {
await createSession(input);
// FNXC:ChatNavigation 2026-08-20-23:57: New Chat always creates a Direct session, so switch scopes only after persistence succeeds and let useChat select the new thread.
setChatScope("direct");
setShowNewDialog(false);
setDetailOpen(true);
return true;
@@ -3131,6 +3133,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
/*
FNXC:ChatNavigation 2026-08-20-05:25:
FN-068 reserves the shared ViewHeader for view-level actions. A selected conversation owns its sole textual Back action in the thread row, preserving one list/detail state machine across desktop, floating, compact, and mobile hosts.
FNXC:ChatNavigation 2026-08-20-23:57:
FN-096 keeps the canonical New Chat action in this shared header for both list and selected-detail states. Embedded, floating, and dock hosts must reuse this one creation entry point while the thread row retains the sole Back action.
*/
<div ref={chatViewRef} className={`chat-view${floating ? " chat-view--floating" : ""}${isChatMobile ? " chat-view--narrow" : ""}${hasDetailSelection ? " chat-view--detail" : ""}${chatMessageLayout === "full-width" ? " chat-view--full-width" : ""}`}>
<ViewHeader
@@ -3139,16 +3144,14 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
actions={
<>
{!hasDetailSelection ? scopeToggle : null}
{!hasDetailSelection ? (
<button
className="btn btn-sm btn-primary chat-view-header-new-chat"
onClick={handleNewChat}
data-testid="chat-new-btn"
>
<Plus size={14} />
{t("chat.newChat", "New Chat")}
</button>
) : null}
<button
className="btn btn-sm btn-primary chat-view-header-new-chat"
onClick={handleNewChat}
data-testid="chat-new-btn"
>
<Plus size={14} />
{t("chat.newChat", "New Chat")}
</button>
{!floating && onPopOut ? (
<button
type="button"

View File

@@ -59,6 +59,8 @@ describe("ChatView mobile list/detail navigation", () => {
expect(back.closest(".view-header")).toBeNull();
expect(screen.getAllByTestId("chat-back-btn")).toHaveLength(1);
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeNull();
expect(document.querySelectorAll(".view-header [data-testid='chat-new-btn']")).toHaveLength(1);
expect(document.querySelector(".chat-thread-new-chat-btn")).toBeNull();
await userEvent.click(back);
expect(sidebar).not.toHaveClass("chat-sidebar--hidden");
@@ -78,6 +80,13 @@ describe("ChatView mobile list/detail navigation", () => {
await userEvent.click(screen.getByTestId("chat-room-item-ops"));
expect(screen.getByTestId("chat-back-btn")).toHaveAccessibleName("Back to conversations");
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeNull();
expect(document.querySelectorAll(".view-header [data-testid='chat-new-btn']")).toHaveLength(1);
await userEvent.click(screen.getByTestId("chat-new-btn"));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await userEvent.click(document.querySelector(".chat-new-dialog-backdrop") as HTMLElement);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-back-btn"));
expect(screen.getByTestId("chat-room-item-ops")).toBeInTheDocument();
@@ -93,7 +102,8 @@ describe("ChatView mobile list/detail navigation", () => {
});
setupMockRooms();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating />);
const onClose = vi.fn();
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} floating onClose={onClose} />);
await userEvent.click(screen.getByTestId("chat-session-session-001"));
expect(document.querySelector(".chat-view--narrow .chat-sidebar")).toHaveClass("chat-sidebar--hidden");
@@ -101,6 +111,9 @@ describe("ChatView mobile list/detail navigation", () => {
expect(back.closest(".chat-thread-header")).toBeInTheDocument();
expect(screen.getAllByTestId("chat-back-btn")).toHaveLength(1);
expect(screen.queryByTestId("chat-mobile-session-trigger")).toBeNull();
expect(document.querySelectorAll(".view-header [data-testid='chat-new-btn']")).toHaveLength(1);
expect(screen.getByTestId("chat-modal-close")).toBeInTheDocument();
expect(document.querySelector(".chat-thread-new-chat-btn")).toBeNull();
viewportSpy.mockRestore();
});
});

View File

@@ -188,6 +188,11 @@ async function waitForSettings() {
await act(async () => undefined);
}
async function selectDirectSession(sessionId = "sess-1") {
fireEvent.click(screen.getByTestId(`chat-session-${sessionId}`));
await waitFor(() => expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument());
}
describe("ChatView New Chat project default behavior", () => {
beforeEach(() => {
_resetInitialViewportHeight();
@@ -198,7 +203,25 @@ describe("ChatView New Chat project default behavior", () => {
mockFetchSettings.mockResolvedValue({ defaultThinkingLevel: "medium" } as Awaited<ReturnType<typeof api.fetchSettings>>);
});
it("always-default model creates directly from the desktop New Chat entry without opening the dialog", async () => {
it("starts prompt-mode New Chat from a selected untitled Direct thread without leaving detail", async () => {
const session = makeSession({ title: "", lastMessageAt: undefined });
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], filteredSessions: [session] }));
await renderWithAct(<ChatView projectId="project-a" addToast={vi.fn()} />);
await waitForSettings();
await selectDirectSession();
const header = document.querySelector(".view-header");
expect(header?.querySelectorAll("[data-testid='chat-new-btn']")).toHaveLength(1);
expect(screen.getByTestId("chat-new-btn").closest(".view-header")).toBe(header);
expect(screen.getByTestId("chat-back-btn").closest(".chat-thread-header")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("chat-new-btn"));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
});
it("always-default model creates directly from selected desktop detail without opening the dialog", async () => {
const createSession = vi.fn();
mockFetchSettings.mockResolvedValue({
chatNewSessionMode: "always-default",
@@ -208,13 +231,16 @@ describe("ChatView New Chat project default behavior", () => {
chatDefaultThinkingLevel: "high",
defaultThinkingLevel: "medium",
} as Awaited<ReturnType<typeof api.fetchSettings>>);
mockUseChat.mockReturnValue(chatState({ createSession }));
const session = makeSession({ title: "Populated chat" });
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], filteredSessions: [session], createSession }));
await renderWithAct(<ChatView projectId="project-a" addToast={vi.fn()} />);
await waitForSettings();
await selectDirectSession();
fireEvent.click(screen.getAllByTestId("chat-new-btn")[0]);
fireEvent.click(screen.getByTestId("chat-new-btn"));
expect(createSession).toHaveBeenCalledTimes(1);
expect(createSession).toHaveBeenCalledWith({
agentId: "__fn_agent__",
modelProvider: "anthropic",
@@ -224,6 +250,27 @@ describe("ChatView New Chat project default behavior", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
it("keeps selected detail visible and reports the existing toast when default creation rejects", async () => {
const addToast = vi.fn();
const session = makeSession({ title: "Existing thread" });
const createSession = vi.fn().mockRejectedValue(new Error("create failed"));
mockFetchSettings.mockResolvedValue({
chatNewSessionMode: "always-default",
chatDefaultKind: "agent",
chatDefaultAgentId: "agent-beta",
} as Awaited<ReturnType<typeof api.fetchSettings>>);
mockUseChat.mockReturnValue(chatState({ activeSession: session, sessions: [session], filteredSessions: [session], createSession }));
await renderWithAct(<ChatView projectId="project-a" addToast={addToast} />);
await waitForSettings();
await selectDirectSession();
fireEvent.click(screen.getByTestId("chat-new-btn"));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Failed to create chat session", "error"));
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
expect(document.querySelector(".chat-view")).toHaveClass("chat-view--detail");
});
it("always-default agent creates directly without opening the dialog", async () => {
const createSession = vi.fn();
mockFetchSettings.mockResolvedValue({

View File

@@ -862,17 +862,41 @@ describe("Chat header New Chat button", () => {
viewportSpy.mockRestore();
});
it("renders New Chat only in the shared list header on mobile", async () => {
const viewportSpy = mockViewportMode("mobile");
setupMockChat({ activeSession });
it("renders one shared-header New Chat action after entering desktop detail from the list", async () => {
const viewportSpy = mockViewportMode("desktop");
setupMockChat({ activeSession, sessions: [activeSession], filteredSessions: [activeSession] });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
await selectFirstConversation();
expect(screen.queryByTestId("chat-thread-new-chat-btn")).toBeNull();
expect(document.querySelector(".view-header [data-testid='chat-new-btn']")).toBeInTheDocument();
expect(document.querySelectorAll(".view-header [data-testid='chat-new-btn']")).toHaveLength(1);
expect(screen.getByTestId("chat-back-btn")).toBeInTheDocument();
expect(document.querySelector(".chat-thread-new-chat-btn")).toBeNull();
viewportSpy.mockRestore();
});
it("creates a Direct chat from selected Room detail without treating it as room creation", async () => {
const room = createRoomFixture("ops");
const createSession = vi.fn().mockResolvedValue(activeSession);
localStorage.setItem("fusion:chat-scope", "rooms");
vi.mocked(apiModule.fetchSettings).mockResolvedValue({
chatNewSessionMode: "always-default",
chatDefaultKind: "agent",
chatDefaultAgentId: "agent-001",
});
setupMockChat({ activeSession, sessions: [activeSession], filteredSessions: [activeSession], createSession });
setupMockRooms({ rooms: [room], activeRoom: room });
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
await selectFirstConversation();
await waitFor(() => expect(apiModule.fetchSettings).toHaveBeenCalled());
await userEvent.click(screen.getByTestId("chat-new-btn"));
await waitFor(() => expect(createSession).toHaveBeenCalledWith({ agentId: "agent-001" }));
expect(screen.queryByRole("dialog", { name: "Create room" })).toBeNull();
expect(document.querySelector(".chat-room-thread-header")).toBeNull();
expect(within(document.querySelector(".chat-thread-header") as HTMLElement).getByText("Test Chat")).toBeInTheDocument();
});
});
describe("Chat pop-out header actions", () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB