diff --git a/.changeset/chat-session-rename.md b/.changeset/chat-session-rename.md new file mode 100644 index 0000000000..102c0ca0bb --- /dev/null +++ b/.changeset/chat-session-rename.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Add dashboard controls for renaming regular Chat and Quick Chat sessions. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ab2ee06873..d11d9c225d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -239,6 +239,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 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. - On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters. - Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged. - Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only. @@ -284,6 +285,7 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv - 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 - The panel header uses a session-first flow: the main dropdown lists persisted sessions (preferring `session.title`, then falling back to deterministic `Session N` labels) +- Quick Chat sessions can be renamed from the session dropdown, and the active title is shown in the header so custom names remain visible after the dropdown closes. - Selecting a session from that dropdown resumes the persisted conversation; this keeps `switchSession()` resume-oriented rather than forcing a new thread - 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)`. - The `+` action opens an inline new-session chooser (inside the panel, not a modal) with `Model` selected by default and optional switch to `Agent` diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 1b28288ad4..a7a22f6d80 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -9485,7 +9485,7 @@ export function fetchChatSession(id: string, projectId?: string): Promise { return api(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), { diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 4e8b0c9ed5..ae437dc1b2 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -573,6 +573,17 @@ overflow-y: auto; } +/* +FNXC:Chat 2026-06-16-22:12: +Mobile chat session switching needs a dedicated rename tap target beside each session without nesting buttons, so the row owns layout while the title and rename controls remain independently keyboard accessible. +*/ +.chat-mobile-session-option-row { + display: flex; + align-items: stretch; + gap: var(--space-xs); + border-radius: var(--radius-sm); +} + .chat-mobile-session-option { width: 100%; display: flex; @@ -588,6 +599,17 @@ line-height: normal; } +.chat-mobile-session-rename { + flex-shrink: 0; + align-self: stretch; + color: var(--text-muted); +} + +.chat-mobile-session-rename:hover { + color: var(--text); + background: var(--card-hover); +} + .chat-mobile-session-option:hover { background: var(--card-hover); } @@ -613,6 +635,28 @@ flex-shrink: 0; } +.chat-rename-label { + display: block; + margin-bottom: var(--space-xs); + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.chat-rename-input { + width: 100%; + margin-bottom: var(--space-md); +} + +@media (max-width: 768px) { + .chat-mobile-session-option-row { + align-items: stretch; + } + + .chat-mobile-session-rename { + min-width: calc(var(--space-lg) * 2.25); + } +} + /* Single thread-wide markdown / plain-text toggle, anchored to the right of * the header next to "New Chat". Replaces the per-message eye toggle that * used to live inside every assistant bubble. */ diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 43f7de9b61..ce29f5a1a9 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -11,6 +11,7 @@ import { Search, Trash2, Archive, + Pencil, ChevronLeft, Bot, Square, @@ -1009,6 +1010,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView selectSession, createSession, archiveSession, + renameSession, deleteSession, sendMessage, stopStreaming, @@ -1048,6 +1050,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView return getPersistedChatDraft(initialDraftKey); }); const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null); + const [renameDialog, setRenameDialog] = useState<{ sessionId: string; title: string } | null>(null); + const [renameTitle, setRenameTitle] = useState(""); const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState(null); const [sidebarVisible, setSidebarVisible] = useState(true); @@ -2466,6 +2470,33 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView [archiveSession, addToast], ); + const openRenameDialog = useCallback( + (id: string) => { + const session = filteredSessions.find((item) => item.id === id) ?? (activeSession?.id === id ? activeSession : null); + setContextMenu(null); + setMobileSessionMenuOpen(false); + setRenameTitle(session?.title ?? ""); + setRenameDialog({ sessionId: id, title: session?.title ?? "" }); + }, + [activeSession, filteredSessions], + ); + + /** + * FNXC:Chat 2026-06-16-22:08: + * Regular chat exposes rename from the desktop context menu and mobile session switcher; saving delegates to the shared hook so the sidebar list and active thread header update from one optimistic state path. + */ + const handleRename = useCallback(async () => { + if (!renameDialog) return; + try { + await renameSession(renameDialog.sessionId, renameTitle); + setRenameDialog(null); + setRenameTitle(""); + addToast(t("chat.conversationRenamed", "Conversation renamed"), "success"); + } catch { + // useChat owns rollback and error toast so both regular-chat rename surfaces share failure behavior. + } + }, [addToast, renameDialog, renameSession, renameTitle, t]); + // Handle delete const handleDelete = useCallback( async (id: string) => { @@ -3357,6 +3388,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView style={{ top: contextMenu.y, left: contextMenu.x }} onClick={(e) => e.stopPropagation()} > + + + + + + )} + {/* Confirm Delete Dialog */} {confirmDelete && (
setConfirmDelete(null)}> @@ -3650,16 +3731,30 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView {mobileSessionMenuOpen && (
{filteredSessions.map((session) => ( - + + +
))}
)} diff --git a/packages/dashboard/app/components/QuickChatFAB.css b/packages/dashboard/app/components/QuickChatFAB.css index 9388fccbf4..57416f421e 100644 --- a/packages/dashboard/app/components/QuickChatFAB.css +++ b/packages/dashboard/app/components/QuickChatFAB.css @@ -218,6 +218,21 @@ min-width: 0; } +.quick-chat-session-title-tag { + display: inline-flex; + align-items: center; + max-width: 18ch; + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--card); + color: var(--text); + font-size: calc(var(--space-sm) + var(--space-xs)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .quick-chat-model-tag { display: inline-flex; align-items: center; @@ -346,6 +361,33 @@ border-bottom: 1px solid var(--border); } +.quick-chat-rename-dialog { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin: var(--space-sm) var(--space-md) 0; + padding: var(--space-sm); + border: 1px solid color-mix(in srgb, var(--todo) 25%, var(--border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--surface) 80%, var(--card)); +} + +.quick-chat-rename-label { + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.quick-chat-rename-input { + width: 100%; +} + +.quick-chat-rename-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--space-sm); +} + .quick-chat-new-session-chooser { display: flex; flex-direction: column; @@ -442,6 +484,17 @@ text-transform: uppercase; } +/* +FNXC:Chat 2026-06-16-22:28: +Quick chat session rows include a separate rename button so selecting a session, unread status, and rename remain distinct accessible targets in both desktop and mobile panel widths. +*/ +.quick-chat-session-option-row { + display: flex; + align-items: stretch; + gap: var(--space-xs); + border-radius: var(--radius-sm); +} + .quick-chat-session-option { width: 100%; border: none; @@ -461,6 +514,17 @@ margin-inline-start: auto; } +.quick-chat-session-rename { + flex-shrink: 0; + align-self: stretch; + color: var(--text-muted); +} + +.quick-chat-session-rename:hover { + color: var(--text); + background: var(--card-hover); +} + .quick-chat-session-option:hover { background: var(--card-hover); } @@ -974,11 +1038,16 @@ white-space: nowrap; } + .quick-chat-session-title-tag, .quick-chat-model-tag { max-width: 12ch; flex-shrink: 1; } + .quick-chat-session-rename { + min-width: calc(var(--space-lg) * 2.25); + } + .quick-chat-panel-header-actions { --quick-chat-header-control-size: calc(var(--space-xl) + var(--space-md)); diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index 0f3a944835..6cb115b625 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -15,7 +15,7 @@ import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Components } from "react-markdown"; -import { ChevronDown, Eye, EyeOff, Hash, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react"; +import { ChevronDown, Eye, EyeOff, Hash, MessageSquare, Paperclip, Pencil, Plus, Send, Square, Wrench, X } from "lucide-react"; import { attachmentBaseUrlForRoom, type Agent, type ModelInfo } from "../api"; import type { DiscoveredSkill } from "@fusion/dashboard"; import { CustomModelDropdown } from "./CustomModelDropdown"; @@ -1001,6 +1001,8 @@ export function QuickChatFAB({ const [selectedAgentId, setSelectedAgentId] = useState(""); const [newSessionChooserOpen, setNewSessionChooserOpen] = useState(false); const [sessionMenuOpen, setSessionMenuOpen] = useState(false); + const [renameDialog, setRenameDialog] = useState<{ sessionId: string; title: string } | null>(null); + const [renameTitle, setRenameTitle] = useState(""); const [newSessionMode, setNewSessionMode] = useState<"agent" | "model">("model"); const [newSessionAgentId, setNewSessionAgentId] = useState(""); const [newSessionModel, setNewSessionModel] = useState(""); @@ -1094,6 +1096,7 @@ export function QuickChatFAB({ selectSession, startModelChat, startFreshSession, + renameSession, refreshSessions, skipNextSessionInitRef, } = useQuickChat(projectId, addToast); @@ -1939,6 +1942,32 @@ export function QuickChatFAB({ setSessionMenuOpen(false); }, [markRead, roomThreadActive, roomsState, selectSession, sessions]); + const openRenameDialog = useCallback( + (sessionId: string) => { + const selectedSession = sessions.find((session) => session.id === sessionId) ?? (activeSession?.id === sessionId ? activeSession : null); + setRenameTitle(selectedSession?.title ?? ""); + setRenameDialog({ sessionId, title: selectedSession?.title ?? "" }); + setSessionMenuOpen(false); + }, + [activeSession, sessions], + ); + + /** + * FNXC:Chat 2026-06-16-22:24: + * Quick chat session rows need an inline rename affordance that preserves unread-dot layout and updates the active panel title through the hook's optimistic session-title state. + */ + const handleRenameSession = useCallback(async () => { + if (!renameDialog) return; + try { + await renameSession(renameDialog.sessionId, renameTitle); + setRenameDialog(null); + setRenameTitle(""); + addToast(t("chat.conversationRenamed", "Conversation renamed"), "success"); + } catch { + // The hook rolls back and reports the failure so regular and quick chat share error behavior. + } + }, [addToast, renameDialog, renameSession, renameTitle, t]); + const handleRoomSwitch = useCallback((roomId: string) => { const selectedRoom = roomsState.rooms.find((room) => room.id === roomId); markRead("room", roomId, selectedRoom?.updatedAt); @@ -2759,6 +2788,11 @@ export function QuickChatFAB({

{t("chat.quickChatTitle", "Quick Chat")}

+ {!roomThreadActive && activeSession ? ( + + {activeSessionLabel} + + ) : null} {roomThreadActive && roomsState.activeRoom ? ( #{roomsState.activeRoom.name} @@ -2879,23 +2913,37 @@ export function QuickChatFAB({ const session = sessions.find((item) => item.id === sessionOption.id); const showUnreadDot = !isActiveSession && isUnread("direct", sessionOption.id, session?.lastMessageAt ?? session?.updatedAt); return ( - + + +
); })}
@@ -2903,6 +2951,43 @@ export function QuickChatFAB({ + {renameDialog && ( +
+ + setRenameTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleRenameSession(); + } + }} + autoFocus + /> +
+ + +
+
+ )} + {newSessionChooserOpen && (
diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index 89f2482bd2..c19a840881 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -56,6 +56,7 @@ vi.mock("lucide-react", async (importOriginal) => { Search: ({ "data-testid": testId, ...props }: any) => , Trash2: ({ "data-testid": testId, ...props }: any) => , Archive: ({ "data-testid": testId, ...props }: any) => , + Pencil: ({ "data-testid": testId, ...props }: any) => , ChevronLeft: ({ "data-testid": testId, ...props }: any) => , Bot: ({ "data-testid": testId, ...props }: any) => , Square: ({ "data-testid": testId, ...props }: any) => , @@ -137,6 +138,7 @@ const defaultChatState: UseChatReturn = { selectSession: vi.fn(), createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__", status: "active", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" } satisfies ChatSessionInfo), archiveSession: vi.fn(), + renameSession: vi.fn(), deleteSession: vi.fn(), sendMessage: vi.fn(), stopStreaming: vi.fn(), @@ -2877,6 +2879,112 @@ describe("Chat Session Delete Button", () => { expect(selectSession).not.toHaveBeenCalled(); }); + it("renames from the desktop context menu with the current title prefilled", async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Renamed Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + renameSession, + }); + + const view = await renderWithAct(); + + fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); + expect(screen.getByTestId("chat-context-rename")).toBeInTheDocument(); + await userEvent.click(screen.getByTestId("chat-context-rename")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe("Test Chat"); + await userEvent.clear(input); + await userEvent.type(input, "Renamed Chat"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Renamed Chat"); + + setupMockChat({ + activeSession: renamedSession, + sessions: [renamedSession], + filteredSessions: [renamedSession], + renameSession, + }); + await act(async () => { + view.rerender(); + }); + + expect(screen.getByTestId("chat-session-session-001")).toHaveTextContent("Renamed Chat"); + const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(headerTitle).toHaveTextContent("Renamed Chat"); + }); + + it("prefills rename as empty for an untitled session and names it", async () => { + const renameSession = vi.fn().mockResolvedValue(undefined); + const untitledSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: null, createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + activeSession: untitledSession, + sessions: [untitledSession], + filteredSessions: [untitledSession], + renameSession, + }); + + await renderWithAct(); + + fireEvent.contextMenu(screen.getByTestId("chat-session-session-001")); + await userEvent.click(screen.getByTestId("chat-context-rename")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe(""); + await userEvent.type(input, "Named from Untitled"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Named from Untitled"); + }); + + it("renames from the mobile session switcher and preserves the active header title surface", async () => { + const restoreMatchMedia = mockViewportMode("mobile"); + const renameSession = vi.fn().mockResolvedValue(undefined); + try { + setupMockChat({ + activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, + sessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + filteredSessions: [{ id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }], + renameSession, + }); + + const view = await renderWithAct(); + + expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Chat"); + await userEvent.click(screen.getByTestId("chat-mobile-session-trigger")); + await userEvent.click(screen.getByTestId("chat-mobile-session-rename-session-001")); + + const input = screen.getByTestId("chat-rename-input") as HTMLInputElement; + expect(input.value).toBe("Mobile Chat"); + await userEvent.clear(input); + await userEvent.type(input, "Mobile Renamed"); + await userEvent.click(screen.getByTestId("chat-rename-save")); + + expect(renameSession).toHaveBeenCalledWith("session-001", "Mobile Renamed"); + + const renamedSession: ChatSessionInfo = { id: "session-001", agentId: "agent-001", status: "active", title: "Mobile Renamed", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }; + setupMockChat({ + activeSession: renamedSession, + sessions: [renamedSession], + filteredSessions: [renamedSession], + renameSession, + }); + await act(async () => { + view.rerender(); + }); + + expect(screen.getByTestId("chat-mobile-session-trigger")).toHaveTextContent("Mobile Renamed"); + const headerTitle = document.querySelector(".chat-thread-header-title") as HTMLElement | null; + expect(headerTitle).toHaveTextContent("Mobile Renamed"); + } finally { + restoreMatchMedia.mockRestore(); + } + }); + it("confirming delete calls deleteSession", async () => { const deleteSession = vi.fn(); setupMockChat({ diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 9686786578..59f4c4aafc 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -19,6 +19,7 @@ vi.mock("../../api", () => ({ fetchChatSessions: vi.fn(), createChatSession: vi.fn(), fetchChatMessages: vi.fn(), + updateChatSession: vi.fn(), streamChatResponse: vi.fn(), cancelChatResponse: vi.fn(), fetchModels: vi.fn(), @@ -46,6 +47,7 @@ const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession); const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions); const mockCreateChatSession = vi.mocked(apiModule.createChatSession); const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages); +const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession); const mockFetchModels = vi.mocked(apiModule.fetchModels); const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills); const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse); @@ -188,6 +190,7 @@ describe("QuickChatFAB session-first UX", () => { mockFetchChatMessages.mockResolvedValue({ messages: [] }); mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] }); mockCreateChatSession.mockResolvedValue({ session: { ...modelSession, id: "session-new" } }); + mockUpdateChatSession.mockResolvedValue({ session: { ...modelSession, title: "Renamed model thread" } }); mockCancelChatResponse.mockResolvedValue({ success: true }); mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => { handlers.onDone?.({ messageId: "msg-stream" }); @@ -284,6 +287,28 @@ describe("QuickChatFAB session-first UX", () => { expect(screen.getByTestId("quick-chat-session-option-session-agent")).toBeInTheDocument(); }); + it("renames a quick chat session from the dropdown and updates the panel title", async () => { + mockUpdateChatSession.mockResolvedValueOnce({ session: { ...modelSession, title: "Renamed model thread" } }); + + render(); + fireEvent.click(screen.getByTestId("quick-chat-fab")); + + expect(await screen.findByTestId("quick-chat-active-session-title")).toHaveTextContent("Model thread"); + fireEvent.click(screen.getByTestId("quick-chat-session-dropdown-trigger")); + expect(screen.getByTestId("quick-chat-session-rename-session-model")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("quick-chat-session-rename-session-model")); + + const input = screen.getByTestId("quick-chat-rename-input") as HTMLInputElement; + expect(input.value).toBe("Model thread"); + fireEvent.change(input, { target: { value: "Renamed model thread" } }); + fireEvent.click(screen.getByTestId("quick-chat-rename-save")); + + await waitFor(() => { + expect(mockUpdateChatSession).toHaveBeenCalledWith("session-model", { title: "Renamed model thread" }, "proj-1"); + expect(screen.getByTestId("quick-chat-active-session-title")).toHaveTextContent("Renamed model thread"); + }); + }); + it("renders unread dots for unread sessions and hides active session dot", async () => { localStorage.setItem( "kb:proj-1:fusion:chat-unread:direct", diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 11f43e2723..8beef603b3 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -85,6 +85,16 @@ function makeMessage(overrides: Partial & Pick() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + const setDocumentVisibilityState = (state: DocumentVisibilityState) => { Object.defineProperty(document, "visibilityState", { configurable: true, @@ -751,6 +761,104 @@ describe("useChat", () => { }); }); + it("renames a session optimistically, trims the API title, and updates the active header state", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Old title" }); + const renamedSession = makeSession({ + id: "session-001", + agentId: "agent-001", + title: "New title", + updatedAt: "2026-04-09T00:00:00.000Z", + }); + const deferred = createDeferredPromise<{ session: ChatSession }>(); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockUpdateChatSession.mockReturnValueOnce(deferred.promise); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + act(() => { + result.current.selectSession("session-001", session); + }); + + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + await act(async () => { + void result.current.renameSession("session-001", " New title "); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: "New title" }, "proj-123"); + expect(result.current.sessions[0]?.title).toBe("New title"); + expect(result.current.activeSession?.title).toBe("New title"); + + await act(async () => { + deferred.resolve({ session: renamedSession }); + await deferred.promise; + }); + + expect(result.current.sessions[0]?.updatedAt).toBe("2026-04-09T00:00:00.000Z"); + expect(result.current.activeSession?.updatedAt).toBe("2026-04-09T00:00:00.000Z"); + }); + + it("renames an untitled session to a named title optimistically", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", title: null }); + const deferred = createDeferredPromise<{ session: ChatSession }>(); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockUpdateChatSession.mockReturnValueOnce(deferred.promise); + + const { result } = renderHook(() => useChat("proj-123")); + + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + act(() => { + result.current.selectSession("session-001", session); + }); + + await waitFor(() => expect(result.current.activeSession?.title).toBeNull()); + + await act(async () => { + void result.current.renameSession("session-001", "Named title"); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: "Named title" }, "proj-123"); + expect(result.current.sessions[0]?.title).toBe("Named title"); + expect(result.current.activeSession?.title).toBe("Named title"); + + await act(async () => { + deferred.resolve({ session: makeSession({ ...session, title: "Named title" }) }); + await deferred.promise; + }); + }); + + it("renames a session to Untitled for whitespace and rolls back with a toast on failure", async () => { + const addToast = vi.fn(); + const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Keep me" }); + mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); + mockFetchChatMessages.mockResolvedValue({ messages: [] }); + mockUpdateChatSession.mockRejectedValueOnce(new Error("rename failed")); + + const { result } = renderHook(() => useChat("proj-123", addToast)); + + await waitFor(() => expect(result.current.sessions).toHaveLength(1)); + + act(() => { + result.current.selectSession("session-001", session); + }); + + await waitFor(() => expect(result.current.activeSession?.title).toBe("Keep me")); + + await act(async () => { + await expect(result.current.renameSession("session-001", " ")).rejects.toThrow("rename failed"); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: null }, "proj-123"); + expect(result.current.sessions[0]?.title).toBe("Keep me"); + expect(result.current.activeSession?.title).toBe("Keep me"); + expect(addToast).toHaveBeenCalledWith("Failed to rename conversation", "error"); + }); + it("deletes a session", async () => { const session = makeSession({ id: "session-001", agentId: "agent-001" }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); diff --git a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts index 9ae125b839..1904a5be33 100644 --- a/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useQuickChat.test.ts @@ -12,6 +12,7 @@ vi.mock("../../api", () => ({ fetchChatSession: vi.fn(), createChatSession: vi.fn(), fetchChatMessages: vi.fn(), + updateChatSession: vi.fn(), streamChatResponse: vi.fn(), attachChatStream: vi.fn(), cancelChatResponse: vi.fn(), @@ -22,6 +23,7 @@ const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions); const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession); const mockCreateChatSession = vi.mocked(apiModule.createChatSession); const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages); +const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession); const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse); const mockAttachChatStream = vi.mocked(apiModule.attachChatStream); const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse); @@ -40,6 +42,16 @@ function makeSession(overrides: Partial & Pick() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function makeMessage(overrides: Partial & Pick): ChatMessage { return { id: overrides.id, @@ -73,6 +85,9 @@ describe("useQuickChat", () => { mockFetchChatSession.mockResolvedValue({ session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false }, }); + mockUpdateChatSession.mockResolvedValue({ + session: makeSession({ id: "session-001", agentId: "agent-001", title: "Renamed" }), + }); mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true }); mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true }); mockCancelChatResponse.mockResolvedValue({ success: true }); @@ -83,6 +98,100 @@ describe("useQuickChat", () => { vi.useRealTimers(); }); + it("renames the active quick chat session optimistically and trims the API title", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Old quick title" }); + const renamedSession = makeSession({ + id: "session-001", + agentId: "agent-001", + title: "New quick title", + updatedAt: "2026-04-09T00:00:00.000Z", + }); + const deferred = createDeferredPromise<{ session: ChatSession }>(); + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatSessions.mockResolvedValue({ sessions: [session] }); + mockUpdateChatSession.mockReturnValueOnce(deferred.promise); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.refreshSessions(); + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => expect(result.current.activeSession?.id).toBe("session-001")); + + await act(async () => { + void result.current.renameSession("session-001", " New quick title "); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: "New quick title" }, "proj-123"); + expect(result.current.sessions.find((item) => item.id === "session-001")?.title).toBe("New quick title"); + expect(result.current.activeSession?.title).toBe("New quick title"); + + await act(async () => { + deferred.resolve({ session: renamedSession }); + await deferred.promise; + }); + + expect(result.current.activeSession?.updatedAt).toBe("2026-04-09T00:00:00.000Z"); + }); + + it("renames an untitled quick chat session to a named title optimistically", async () => { + const session = makeSession({ id: "session-001", agentId: "agent-001", title: null }); + const deferred = createDeferredPromise<{ session: ChatSession }>(); + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatSessions.mockResolvedValue({ sessions: [session] }); + mockUpdateChatSession.mockReturnValueOnce(deferred.promise); + + const { result } = renderHook(() => useQuickChat("proj-123")); + + await act(async () => { + await result.current.refreshSessions(); + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => expect(result.current.activeSession?.title).toBeNull()); + + await act(async () => { + void result.current.renameSession("session-001", "Named quick title"); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: "Named quick title" }, "proj-123"); + expect(result.current.sessions.find((item) => item.id === "session-001")?.title).toBe("Named quick title"); + expect(result.current.activeSession?.title).toBe("Named quick title"); + + await act(async () => { + deferred.resolve({ session: makeSession({ ...session, title: "Named quick title" }) }); + await deferred.promise; + }); + }); + + it("renames a quick chat session to Untitled for whitespace and rolls back with a toast on failure", async () => { + const addToast = vi.fn(); + const session = makeSession({ id: "session-001", agentId: "agent-001", title: "Keep quick title" }); + mockFetchResumeChatSession.mockResolvedValue({ session }); + mockFetchChatSessions.mockResolvedValue({ sessions: [session] }); + mockUpdateChatSession.mockRejectedValueOnce(new Error("rename failed")); + + const { result } = renderHook(() => useQuickChat("proj-123", addToast)); + + await act(async () => { + await result.current.refreshSessions(); + await result.current.switchSession("agent-001"); + }); + + await waitFor(() => expect(result.current.activeSession?.title).toBe("Keep quick title")); + + await act(async () => { + await expect(result.current.renameSession("session-001", " ")).rejects.toThrow("rename failed"); + }); + + expect(mockUpdateChatSession).toHaveBeenCalledWith("session-001", { title: null }, "proj-123"); + expect(result.current.sessions.find((item) => item.id === "session-001")?.title).toBe("Keep quick title"); + expect(result.current.activeSession?.title).toBe("Keep quick title"); + expect(addToast).toHaveBeenCalledWith("Failed to rename conversation", "error"); + }); + it("queues first send made before session init completes and streams once ready", async () => { const session = makeSession({ id: "session-001", agentId: "agent-001" }); mockFetchResumeChatSession.mockResolvedValue({ session }); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 27902a8d72..c097a753f2 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -77,6 +77,7 @@ export interface UseChatReturn { input: { agentId: string; title?: string; modelProvider?: string; modelId?: string }, ) => Promise; archiveSession: (id: string) => Promise; + renameSession: (id: string, title: string) => Promise; deleteSession: (id: string) => Promise; // Message operations @@ -797,6 +798,52 @@ export function useChat( [activeSession, projectId], ); + /** + * FNXC:Chat 2026-06-16-22:01: + * Users can rename regular and quick chat sessions through existing PATCH title plumbing; update the list and active header optimistically so every visible session title reflects the new value immediately while rolling back on API failure. + */ + const renameSession = useCallback( + async (id: string, title: string) => { + const normalizedTitle = title.trim() || null; + const previousSessions = sessions; + const previousActiveSession = activeSession; + + setSessions((prev) => prev.map((session) => (session.id === id ? { ...session, title: normalizedTitle } : session))); + setActiveSession((prev) => (prev?.id === id ? { ...prev, title: normalizedTitle } : prev)); + + try { + const data = await updateChatSession(id, { title: normalizedTitle }, projectId); + const updatedSession = data.session; + setSessions((prev) => + prev.map((session) => + session.id === id + ? { + ...session, + title: updatedSession.title, + updatedAt: updatedSession.updatedAt, + } + : session, + ), + ); + setActiveSession((prev) => + prev?.id === id + ? { + ...prev, + title: updatedSession.title, + updatedAt: updatedSession.updatedAt, + } + : prev, + ); + } catch (error) { + setSessions(previousSessions); + setActiveSession(previousActiveSession); + addToast?.("Failed to rename conversation", "error"); + throw error; + } + }, + [activeSession, addToast, projectId, sessions], + ); + // Delete a session const deleteSession = useCallback( async (id: string) => { @@ -1320,6 +1367,7 @@ export function useChat( selectSession, createSession, archiveSession, + renameSession, deleteSession, sendMessage, stopStreaming, diff --git a/packages/dashboard/app/hooks/useQuickChat.ts b/packages/dashboard/app/hooks/useQuickChat.ts index 8a151d5c77..adcc6873d7 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -7,6 +7,7 @@ import { fetchChatSession, createChatSession, fetchChatMessages, + updateChatSession, attachChatStream, streamChatResponse, cancelChatResponse, @@ -64,6 +65,7 @@ export interface UseQuickChatReturn { selectSession: (session: EnrichedChatSession) => Promise; startModelChat: (modelProvider: string, modelId: string) => Promise; startFreshSession: (agentId?: string, modelProvider?: string, modelId?: string) => Promise; + renameSession: (id: string, title: string) => Promise; refreshSessions: () => Promise; loadMessages: () => Promise; reloadMessages: () => Promise; @@ -1176,6 +1178,52 @@ export function useQuickChat( }; }, [activeSession?.id, pendingMessage, projectId, flushPendingMessage]); + /** + * FNXC:Chat 2026-06-16-22:20: + * Quick chat shares the backend session-title PATCH path with regular chat; optimistic session-list and active-session updates keep the dropdown trigger and panel title synchronized immediately after rename. + */ + const renameSession = useCallback( + async (id: string, title: string) => { + const normalizedTitle = title.trim() || null; + const previousSessions = sessions; + const previousActiveSession = activeSession; + + setSessions((prev) => prev.map((session) => (session.id === id ? { ...session, title: normalizedTitle } : session))); + setActiveSession((prev) => (prev?.id === id ? { ...prev, title: normalizedTitle } : prev)); + + try { + const response = await updateChatSession(id, { title: normalizedTitle }, projectId); + const updatedSession = response.session; + setSessions((prev) => + prev.map((session) => + session.id === id + ? { + ...session, + title: updatedSession.title, + updatedAt: updatedSession.updatedAt, + } + : session, + ), + ); + setActiveSession((prev) => + prev?.id === id + ? { + ...prev, + title: updatedSession.title, + updatedAt: updatedSession.updatedAt, + } + : prev, + ); + } catch (error) { + setSessions(previousSessions); + setActiveSession(previousActiveSession); + addToast?.(t("chat.failedToRenameConversation", "Failed to rename conversation"), "error"); + throw error; + } + }, + [activeSession, addToast, projectId, sessions, t], + ); + // Cleanup on unmount useEffect(() => { return () => { @@ -1205,6 +1253,7 @@ export function useQuickChat( selectSession, startModelChat, startFreshSession, + renameSession, refreshSessions, loadMessages, reloadMessages, @@ -1227,6 +1276,7 @@ export function useQuickChat( selectSession, startModelChat, startFreshSession, + renameSession, refreshSessions, loadMessages, reloadMessages,