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:
7
.changeset/fn-096-chat-header-new-chat.md
Normal file
7
.changeset/fn-096-chat-header-new-chat.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
BIN
screenshots/fn-096-chat-header-new-desktop.png
Normal file
BIN
screenshots/fn-096-chat-header-new-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
screenshots/fn-096-chat-header-new-mobile.png
Normal file
BIN
screenshots/fn-096-chat-header-new-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Reference in New Issue
Block a user