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:
5
.changeset/chat-session-rename.md
Normal file
5
.changeset/chat-session-rename.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add dashboard controls for renaming regular Chat and Quick Chat sessions.
|
||||
@@ -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`
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user