FN-6499: add chat session rename controls

Adds regular Chat and Quick Chat rename flows backed by optimistic session title updates.

- Add rename actions to the regular Chat desktop context menu and mobile session switcher.
- Add Quick Chat session-row rename controls and show custom active titles in the panel header.
- Share PATCH title updates through chat hooks with null titles clearing custom names.
- Cover rename success, clearing, and rollback behavior in hook and component tests.
- Document the rename behavior and add a patch changeset for the published CLI bundle.

Files changed:
 .changeset/chat-session-rename.md                  |   5 +
 docs/dashboard-guide.md                            |   2 +
 packages/dashboard/app/api/legacy.ts               |   2 +-
 packages/dashboard/app/components/ChatView.css     |  44 ++++++++
 packages/dashboard/app/components/ChatView.tsx     | 111 +++++++++++++++++--
 packages/dashboard/app/components/QuickChatFAB.css |  69 ++++++++++++
 packages/dashboard/app/components/QuickChatFAB.tsx | 117 ++++++++++++++++++---
 .../app/components/__tests__/ChatView.test.tsx     | 108 +++++++++++++++++++
 .../app/components/__tests__/QuickChatFAB.test.tsx |  25 +++++
 .../dashboard/app/hooks/__tests__/useChat.test.ts  | 108 +++++++++++++++++++
 .../app/hooks/__tests__/useQuickChat.test.ts       | 109 +++++++++++++++++++
 packages/dashboard/app/hooks/useChat.ts            |  48 +++++++++
 packages/dashboard/app/hooks/useQuickChat.ts       |  50 +++++++++
 13 files changed, 773 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-6499
Fusion-Task-Lineage: f9f15b02-1590-46f4-b6f5-140a9ade30eb
This commit is contained in:
gsxdsm
2026-06-16 22:44:16 -07:00
parent aadc0a0817
commit def4bd9464
13 changed files with 773 additions and 25 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add dashboard controls for renaming regular Chat and Quick Chat sessions.

View File

@@ -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`

View File

@@ -9485,7 +9485,7 @@ export function fetchChatSession(id: string, projectId?: string): Promise<ChatSe
/** Update a chat session (title, status) */
export function updateChatSession(
id: string,
updates: { title?: string; status?: string },
updates: { title?: string | null; status?: string },
projectId?: string,
): Promise<ChatSessionResponse> {
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), {

View File

@@ -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. */

View File

@@ -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<string | null>(null);
const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState<string | null>(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()}
>
<button
onClick={() => openRenameDialog(contextMenu.sessionId)}
data-testid="chat-context-rename"
>
<Pencil size={14} />
{t("chat.rename", "Rename")}
</button>
<button
onClick={() => handleArchive(contextMenu.sessionId)}
data-testid="chat-context-archive"
@@ -3377,6 +3415,49 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
</div>
)}
{/* Rename Dialog */}
{renameDialog && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setRenameDialog(null)}>
<div className="chat-new-dialog chat-view-dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("chat.renameConversationTitle", "Rename Conversation")}</h3>
<p className="chat-view-delete-dialog-copy">
{t("chat.renameConversationBody", "Choose a new name for this conversation. Leave it blank to show Untitled.")}
</p>
<label className="chat-rename-label" htmlFor="chat-rename-input">
{t("chat.conversationName", "Conversation name")}
</label>
<input
id="chat-rename-input"
className="input chat-rename-input"
type="text"
value={renameTitle}
placeholder={t("chat.renamePlaceholder", "Untitled")}
data-testid="chat-rename-input"
onChange={(event) => setRenameTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleRename();
}
}}
autoFocus
/>
<div className="chat-new-dialog-actions">
<button className="btn btn-sm" onClick={() => setRenameDialog(null)}>
{t("chat.cancel", "Cancel")}
</button>
<button
className="btn btn-sm btn-primary"
onClick={() => void handleRename()}
data-testid="chat-rename-save"
>
{t("chat.save", "Save")}
</button>
</div>
</div>
</div>
)}
{/* Confirm Delete Dialog */}
{confirmDelete && (
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDelete(null)}>
@@ -3650,16 +3731,30 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
{mobileSessionMenuOpen && (
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
{filteredSessions.map((session) => (
<button
<div
key={session.id}
type="button"
role="menuitem"
className={`chat-mobile-session-option${activeSession?.id === session.id ? " chat-mobile-session-option--active" : ""}`}
data-testid={`chat-mobile-session-option-${session.id}`}
onClick={() => handleSessionClick(session.id)}
className={`chat-mobile-session-option-row${activeSession?.id === session.id ? " chat-mobile-session-option-row--active" : ""}`}
role="none"
>
<span className="chat-mobile-session-option-title">{session.title || t("chat.untitledSession", "Untitled")}</span>
</button>
<button
type="button"
role="menuitem"
className={`chat-mobile-session-option${activeSession?.id === session.id ? " chat-mobile-session-option--active" : ""}`}
data-testid={`chat-mobile-session-option-${session.id}`}
onClick={() => handleSessionClick(session.id)}
>
<span className="chat-mobile-session-option-title">{session.title || t("chat.untitledSession", "Untitled")}</span>
</button>
<button
type="button"
className="btn-icon chat-mobile-session-rename"
data-testid={`chat-mobile-session-rename-${session.id}`}
aria-label={t("chat.renameConversationAria", "Rename conversation {{title}}", { title: session.title || t("chat.untitledSession", "Untitled") })}
onClick={() => openRenameDialog(session.id)}
>
<Pencil size={14} />
</button>
</div>
))}
</div>
)}

View File

@@ -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));

View File

@@ -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<string>("");
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<string>("");
const [newSessionModel, setNewSessionModel] = useState<string>("");
@@ -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({
<div className="quick-chat-panel-header">
<div className="quick-chat-panel-title-wrap">
<h3>{t("chat.quickChatTitle", "Quick Chat")}</h3>
{!roomThreadActive && activeSession ? (
<span className="quick-chat-session-title-tag" data-testid="quick-chat-active-session-title" title={activeSessionLabel}>
{activeSessionLabel}
</span>
) : null}
{roomThreadActive && roomsState.activeRoom ? (
<span className="quick-chat-model-tag" data-testid="quick-chat-room-tag" title={`#${roomsState.activeRoom.name}`}>
#{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 (
<button
<div
key={sessionOption.id}
type="button"
role="menuitem"
data-testid={`quick-chat-session-option-${sessionOption.id}`}
className={`quick-chat-session-option${isActiveSession ? " quick-chat-session-option--active" : ""}`}
onClick={() => handleSessionSwitch(sessionOption.id)}
className={`quick-chat-session-option-row${isActiveSession ? " quick-chat-session-option-row--active" : ""}`}
role="none"
>
<span>{sessionOption.label}</span>
{showUnreadDot ? (
<span
className="chat-unread-dot quick-chat-session-unread-dot"
data-testid={`quick-chat-unread-dot-${sessionOption.id}`}
aria-label={t("chat.unreadMessages", "Unread messages")}
/>
) : null}
</button>
<button
type="button"
role="menuitem"
data-testid={`quick-chat-session-option-${sessionOption.id}`}
className={`quick-chat-session-option${isActiveSession ? " quick-chat-session-option--active" : ""}`}
onClick={() => handleSessionSwitch(sessionOption.id)}
>
<span>{sessionOption.label}</span>
{showUnreadDot ? (
<span
className="chat-unread-dot quick-chat-session-unread-dot"
data-testid={`quick-chat-unread-dot-${sessionOption.id}`}
aria-label={t("chat.unreadMessages", "Unread messages")}
/>
) : null}
</button>
<button
type="button"
className="btn-icon quick-chat-session-rename"
data-testid={`quick-chat-session-rename-${sessionOption.id}`}
aria-label={t("chat.renameConversationAria", "Rename conversation {{title}}", { title: sessionOption.label })}
onClick={() => openRenameDialog(sessionOption.id)}
>
<Pencil size={14} />
</button>
</div>
);
})}
</div>
@@ -2903,6 +2951,43 @@ export function QuickChatFAB({
</div>
</div>
{renameDialog && (
<div className="quick-chat-rename-dialog" data-testid="quick-chat-rename-dialog">
<label className="quick-chat-rename-label" htmlFor="quick-chat-rename-input">
{t("chat.renameConversationTitle", "Rename Conversation")}
</label>
<input
id="quick-chat-rename-input"
className="input quick-chat-rename-input"
type="text"
value={renameTitle}
placeholder={t("chat.renamePlaceholder", "Untitled")}
data-testid="quick-chat-rename-input"
onChange={(event) => setRenameTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleRenameSession();
}
}}
autoFocus
/>
<div className="quick-chat-rename-actions">
<button type="button" className="btn" onClick={() => setRenameDialog(null)}>
{t("chat.cancelButton", "Cancel")}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="quick-chat-rename-save"
onClick={() => void handleRenameSession()}
>
{t("chat.save", "Save")}
</button>
</div>
</div>
)}
{newSessionChooserOpen && (
<div className="quick-chat-new-session-chooser" data-testid="quick-chat-new-session-chooser">
<div className="quick-chat-inline-mode-toggle" data-testid="quick-chat-inline-mode-toggle">

View File

@@ -56,6 +56,7 @@ vi.mock("lucide-react", async (importOriginal) => {
Search: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-search"} {...props} />,
Trash2: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-trash"} {...props} />,
Archive: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-archive"} {...props} />,
Pencil: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-pencil"} {...props} />,
ChevronLeft: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-chevron-left"} {...props} />,
Bot: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-bot"} {...props} />,
Square: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-square"} {...props} />,
@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
});
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
});
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({

View File

@@ -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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
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",

View File

@@ -85,6 +85,16 @@ function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" |
};
}
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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] });

View File

@@ -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<ChatSession> & Pick<ChatSession, "id" |
};
}
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" | "sessionId" | "role" | "content">): 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 });

View File

@@ -77,6 +77,7 @@ export interface UseChatReturn {
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string },
) => Promise<ChatSessionInfo>;
archiveSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
deleteSession: (id: string) => Promise<void>;
// 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,

View File

@@ -7,6 +7,7 @@ import {
fetchChatSession,
createChatSession,
fetchChatMessages,
updateChatSession,
attachChatStream,
streamChatResponse,
cancelChatResponse,
@@ -64,6 +65,7 @@ export interface UseQuickChatReturn {
selectSession: (session: EnrichedChatSession) => Promise<void>;
startModelChat: (modelProvider: string, modelId: string) => Promise<void>;
startFreshSession: (agentId?: string, modelProvider?: string, modelId?: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
refreshSessions: () => Promise<void>;
loadMessages: () => Promise<void>;
reloadMessages: () => Promise<void>;
@@ -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,