diff --git a/.changeset/fn-7441-chat-conversation-rename.md b/.changeset/fn-7441-chat-conversation-rename.md new file mode 100644 index 0000000000..1a80f2ccbd --- /dev/null +++ b/.changeset/fn-7441-chat-conversation-rename.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Add sidebar rename buttons to direct Chat conversations. +category: feature +dev: Reuses ChatView's existing rename dialog and useChat renameSession path. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 92051a2ff0..847a659883 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -408,7 +408,7 @@ Chat view provides project-scoped conversations with agents. - 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 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 sidebar row edit button, the desktop conversation context menu, and the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again. - Task-detail planner Chat conversations stay available from each task's **Chat** tab, including after the task is `done`. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. On completed tasks, clear follow-up implementation or improvement requests can create a normal refinement task from the completed source task. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 0f10beaab4..104f6af6ef 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -344,7 +344,7 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - padding-right: calc(var(--space-md) * 3); + padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2)); } .chat-session-preview { @@ -353,7 +353,7 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - padding-right: calc(var(--space-md) * 3); + padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2)); } .chat-session-meta { @@ -378,39 +378,37 @@ When the movable chat popup is resized narrow, collapse Direct/Rooms labels to i flex-shrink: 0; } -/* === Chat Session Delete Button === */ -.chat-session-delete-btn { +/* +FNXC:ChatSidebar 2026-07-02-00:00: +Direct conversation rows expose edit and delete as a compact action pair. Keep the pair outside the row text flow so title/preview truncation stays stable across desktop, mobile, and compact right-dock layouts. +*/ +.chat-session-actions { position: absolute; top: var(--space-sm); right: var(--space-sm); - display: flex; + display: inline-flex; align-items: center; - justify-content: center; + gap: var(--space-xs); +} + +.chat-session-action-btn { width: calc(var(--space-md) * 2); + min-width: calc(var(--space-md) * 2); height: calc(var(--space-md) * 2); - padding: 0; - background: transparent; - border: none; + min-height: calc(var(--space-md) * 2); + flex: 0 0 auto; border-radius: var(--radius-sm); color: var(--text-muted); - cursor: pointer; - opacity: 0; - transition: opacity var(--transition-fast), color var(--transition-fast); } -.chat-session-item:hover .chat-session-delete-btn { - opacity: 1; +.chat-session-action-btn:hover, +.chat-session-action-btn:focus-visible { + color: var(--text); } -.chat-session-delete-btn:hover { +.chat-session-delete-btn:hover, +.chat-session-delete-btn:focus-visible { color: var(--color-error); - opacity: 1; -} - -.chat-session-delete-btn:focus { - opacity: 1; - outline: none; - box-shadow: var(--focus-ring-strong); } /* Context menu for session items */ @@ -2200,15 +2198,10 @@ Queued-message banners stack above the composer input with a capped scroll area, min-height: calc(var(--space-lg) * 2.25); } - /* FN-4352: secondary control inside tappable session row; keep compact size (inflated by FN-3628). */ - .chat-session-delete-btn { - opacity: 1; - } - - /* FN-4352/FN-4385: keep row text clearance aligned to compact secondary delete control (matches base compact clearance). */ + /* FN-4352/FN-4385/FN-7441: direct session row action pair stays visible and text keeps clearance for both edit and delete touch targets. */ .chat-session-title, .chat-session-preview { - padding-right: calc(var(--space-md) * 3); + padding-right: calc((var(--space-md) * 4) + (var(--space-xs) * 2)); } .chat-message--assistant .chat-message-render-toggle { diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index d2c1019f5f..0436c64fd2 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -2716,6 +2716,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout defaultModel, ); const sessionModelTag = formatModelTag(sessionResolvedModel?.provider, sessionResolvedModel?.modelId) ?? "Fusion"; + const sessionTitle = session.title || t("chat.untitledSession", "Untitled"); return (
- + {/* + FNXC:ChatSidebar 2026-07-02-00:00: + Direct conversation rows need an always-discoverable rename affordance before delete while preserving row selection. Keep edit/delete as sibling buttons that stop propagation and share the existing rename/delete flows. + */} +
+ + +
- {session.title || t("chat.untitledSession", "Untitled")} + {sessionTitle} {showUnreadDot ? ( setRenameDialog(null)}> -
e.stopPropagation()}> -

{t("chat.renameConversationTitle", "Rename Conversation")}

+
e.stopPropagation()} + > +

{t("chat.renameConversationTitle", "Rename Conversation")}

{t("chat.renameConversationBody", "Choose a new name for this conversation. Leave it blank to show Untitled.")}

diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx index 17b912aad9..b365721e82 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core-interactions.test.tsx @@ -1288,4 +1288,126 @@ describe("ChatView core interactions", () => { const messageBubble = screen.getByTestId("chat-message-msg-001"); expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull(); }); + + describe("conversation rename affordances", () => { + it("renames the clicked sidebar session without selecting it", async () => { + const selectSession = vi.fn(); + const renameSession = vi.fn().mockResolvedValue(undefined); + const sessions: ChatSessionInfo[] = [ + { + id: "session-001", + agentId: "agent-001", + status: "active", + title: "Alpha Chat", + lastMessagePreview: "Alpha preview", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }, + { + id: "session-002", + agentId: "agent-002", + status: "active", + title: "Beta Chat", + lastMessagePreview: "Beta preview", + createdAt: "2026-04-07T00:00:00.000Z", + updatedAt: "2026-04-07T00:00:00.000Z", + }, + ]; + + setupMockChat({ + sessions, + filteredSessions: sessions, + activeSession: sessions[0], + selectSession, + renameSession, + }); + await renderWithAct(); + + const betaRow = screen.getByTestId("chat-session-session-002"); + await userEvent.click(within(betaRow).getByTestId("chat-session-rename-btn")); + + expect(selectSession).not.toHaveBeenCalled(); + const dialog = screen.getByRole("dialog", { name: /rename conversation/i }); + const input = within(dialog).getByTestId("chat-rename-input") as HTMLInputElement; + expect(input).toHaveValue("Beta Chat"); + + await userEvent.clear(input); + await userEvent.type(input, "Renamed Beta"); + await userEvent.click(within(dialog).getByTestId("chat-rename-save")); + + await waitFor(() => { + expect(renameSession).toHaveBeenCalledWith("session-002", "Renamed Beta"); + }); + expect(selectSession).not.toHaveBeenCalled(); + + await userEvent.click(betaRow); + expect(selectSession).toHaveBeenCalledWith("session-002"); + }); + + it("uses Untitled in the sidebar rename button name for empty session titles", async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const untitledSession: ChatSessionInfo = { + id: "session-empty-title", + agentId: "agent-001", + status: "active", + title: undefined, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }; + + setupMockChat({ + sessions: [untitledSession], + filteredSessions: [untitledSession], + activeSession: null, + renameSession, + }); + await renderWithAct(); + + const row = screen.getByTestId("chat-session-session-empty-title"); + const renameButton = within(row).getByRole("button", { name: /rename conversation untitled/i }); + expect(renameButton).toHaveAttribute("data-testid", "chat-session-rename-btn"); + + await userEvent.click(renameButton); + const dialog = screen.getByRole("dialog", { name: /rename conversation/i }); + const input = within(dialog).getByTestId("chat-rename-input") as HTMLInputElement; + expect(input).toHaveValue(""); + expect(input).toHaveAttribute("placeholder", "Untitled"); + }); + + it("keeps the existing context-menu rename path wired to renameSession", async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const session: ChatSessionInfo = { + id: "session-context", + agentId: "agent-001", + status: "active", + title: "Context Rename", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + }; + + setupMockChat({ + sessions: [session], + filteredSessions: [session], + activeSession: session, + renameSession, + }); + await renderWithAct(); + + fireEvent.contextMenu(screen.getByTestId("chat-session-session-context"), { clientX: 12, clientY: 24 }); + await userEvent.click(screen.getByTestId("chat-context-rename")); + + const dialog = screen.getByRole("dialog", { name: /rename conversation/i }); + const input = within(dialog).getByTestId("chat-rename-input") as HTMLInputElement; + expect(input).toHaveValue("Context Rename"); + + await userEvent.clear(input); + await userEvent.type(input, "Context Renamed"); + await userEvent.click(within(dialog).getByTestId("chat-rename-save")); + + await waitFor(() => { + expect(renameSession).toHaveBeenCalledWith("session-context", "Context Renamed"); + }); + }); + }); + }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx index d846516e09..7d97ca66dc 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.mobile.test.tsx @@ -268,6 +268,78 @@ describe("ChatView mobile behavior", () => { } }); + it("mobile mode: direct sidebar rows expose rename and delete buttons", async () => { + const restoreMatchMedia = mockMobileViewport(); + const selectSession = vi.fn(); + try { + const sessions = [ + { id: "session-001", agentId: "agent-001", status: "active" as const, title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + ]; + setupMockChat({ + sessions, + filteredSessions: sessions, + activeSession: null, + selectSession, + }); + + await renderWithAct(); + + const row = screen.getByTestId("chat-session-session-001"); + const renameButton = within(row).getByTestId("chat-session-rename-btn"); + const deleteButton = within(row).getByTestId("chat-session-delete-btn"); + expect(renameButton).toHaveAccessibleName(/rename conversation mobile chat/i); + expect(deleteButton).toHaveAccessibleName(/delete conversation/i); + expect(renameButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(renameButton.closest(".chat-session-actions")).toBe(deleteButton.closest(".chat-session-actions")); + + await userEvent.click(renameButton); + expect(selectSession).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: /rename conversation/i })).toBeInTheDocument(); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + + it("mobile mode: quick session switcher rename affordance remains wired", async () => { + const restoreMatchMedia = mockMobileViewport(); + const selectSession = vi.fn(); + const renameSession = vi.fn().mockResolvedValue(undefined); + try { + const sessions = [ + { id: "session-001", agentId: "agent-001", status: "active" as const, title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + { id: "session-002", agentId: "agent-002", status: "active" as const, title: "Switcher Chat", createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z" }, + ]; + setupMockChat({ + sessions, + filteredSessions: sessions, + activeSession: sessions[0], + selectSession, + renameSession, + }); + + await renderWithAct(); + + await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); + await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-002")); + + expect(selectSession).not.toHaveBeenCalled(); + expect(screen.queryByTestId("chat-mobile-session-dropdown")).not.toBeInTheDocument(); + const dialog = screen.getByRole("dialog", { name: /rename conversation/i }); + const input = within(dialog).getByTestId("chat-rename-input") as HTMLInputElement; + expect(input).toHaveValue("Switcher Chat"); + + await userEvent.clear(input); + await userEvent.type(input, "Switcher Renamed"); + await userEvent.click(within(dialog).getByTestId("chat-rename-save")); + + await waitFor(() => { + expect(renameSession).toHaveBeenCalledWith("session-002", "Switcher Renamed"); + }); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + it("mobile mode: thread header title opens quick session switcher and closes after selection", async () => { const restoreMatchMedia = mockMobileViewport(); const selectSession = vi.fn();