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.
|
- 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, 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
- 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
|
- 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)
|
- 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
|
- 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)`.
|
- 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`
|
- 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) */
|
/** Update a chat session (title, status) */
|
||||||
export function updateChatSession(
|
export function updateChatSession(
|
||||||
id: string,
|
id: string,
|
||||||
updates: { title?: string; status?: string },
|
updates: { title?: string | null; status?: string },
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
): Promise<ChatSessionResponse> {
|
): Promise<ChatSessionResponse> {
|
||||||
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), {
|
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), {
|
||||||
|
|||||||
@@ -573,6 +573,17 @@
|
|||||||
overflow-y: auto;
|
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 {
|
.chat-mobile-session-option {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -588,6 +599,17 @@
|
|||||||
line-height: normal;
|
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 {
|
.chat-mobile-session-option:hover {
|
||||||
background: var(--card-hover);
|
background: var(--card-hover);
|
||||||
}
|
}
|
||||||
@@ -613,6 +635,28 @@
|
|||||||
flex-shrink: 0;
|
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
|
/* 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
|
* the header next to "New Chat". Replaces the per-message eye toggle that
|
||||||
* used to live inside every assistant bubble. */
|
* used to live inside every assistant bubble. */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
|
Pencil,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Bot,
|
Bot,
|
||||||
Square,
|
Square,
|
||||||
@@ -1009,6 +1010,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
selectSession,
|
selectSession,
|
||||||
createSession,
|
createSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
renameSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
@@ -1048,6 +1050,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
return getPersistedChatDraft(initialDraftKey);
|
return getPersistedChatDraft(initialDraftKey);
|
||||||
});
|
});
|
||||||
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null);
|
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 [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState<string | null>(null);
|
const [confirmDeleteRoomId, setConfirmDeleteRoomId] = useState<string | null>(null);
|
||||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||||
@@ -2466,6 +2470,33 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
[archiveSession, addToast],
|
[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
|
// Handle delete
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
@@ -3357,6 +3388,13 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => openRenameDialog(contextMenu.sessionId)}
|
||||||
|
data-testid="chat-context-rename"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
{t("chat.rename", "Rename")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleArchive(contextMenu.sessionId)}
|
onClick={() => handleArchive(contextMenu.sessionId)}
|
||||||
data-testid="chat-context-archive"
|
data-testid="chat-context-archive"
|
||||||
@@ -3377,6 +3415,49 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
|||||||
</div>
|
</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 */}
|
{/* Confirm Delete Dialog */}
|
||||||
{confirmDelete && (
|
{confirmDelete && (
|
||||||
<div className="chat-new-dialog-backdrop chat-view-dialog-backdrop" onClick={() => setConfirmDelete(null)}>
|
<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 && (
|
{mobileSessionMenuOpen && (
|
||||||
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
|
<div className="chat-mobile-session-dropdown" role="menu" data-testid="chat-mobile-session-dropdown">
|
||||||
{filteredSessions.map((session) => (
|
{filteredSessions.map((session) => (
|
||||||
<button
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
type="button"
|
className={`chat-mobile-session-option-row${activeSession?.id === session.id ? " chat-mobile-session-option-row--active" : ""}`}
|
||||||
role="menuitem"
|
role="none"
|
||||||
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"
|
||||||
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -218,6 +218,21 @@
|
|||||||
min-width: 0;
|
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 {
|
.quick-chat-model-tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -346,6 +361,33 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
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 {
|
.quick-chat-new-session-chooser {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -442,6 +484,17 @@
|
|||||||
text-transform: uppercase;
|
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 {
|
.quick-chat-session-option {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -461,6 +514,17 @@
|
|||||||
margin-inline-start: auto;
|
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 {
|
.quick-chat-session-option:hover {
|
||||||
background: var(--card-hover);
|
background: var(--card-hover);
|
||||||
}
|
}
|
||||||
@@ -974,11 +1038,16 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-chat-session-title-tag,
|
||||||
.quick-chat-model-tag {
|
.quick-chat-model-tag {
|
||||||
max-width: 12ch;
|
max-width: 12ch;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-chat-session-rename {
|
||||||
|
min-width: calc(var(--space-lg) * 2.25);
|
||||||
|
}
|
||||||
|
|
||||||
.quick-chat-panel-header-actions {
|
.quick-chat-panel-header-actions {
|
||||||
--quick-chat-header-control-size: calc(var(--space-xl) + var(--space-md));
|
--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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Components } from "react-markdown";
|
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 { attachmentBaseUrlForRoom, type Agent, type ModelInfo } from "../api";
|
||||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
@@ -1001,6 +1001,8 @@ export function QuickChatFAB({
|
|||||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||||
const [newSessionChooserOpen, setNewSessionChooserOpen] = useState(false);
|
const [newSessionChooserOpen, setNewSessionChooserOpen] = useState(false);
|
||||||
const [sessionMenuOpen, setSessionMenuOpen] = 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 [newSessionMode, setNewSessionMode] = useState<"agent" | "model">("model");
|
||||||
const [newSessionAgentId, setNewSessionAgentId] = useState<string>("");
|
const [newSessionAgentId, setNewSessionAgentId] = useState<string>("");
|
||||||
const [newSessionModel, setNewSessionModel] = useState<string>("");
|
const [newSessionModel, setNewSessionModel] = useState<string>("");
|
||||||
@@ -1094,6 +1096,7 @@ export function QuickChatFAB({
|
|||||||
selectSession,
|
selectSession,
|
||||||
startModelChat,
|
startModelChat,
|
||||||
startFreshSession,
|
startFreshSession,
|
||||||
|
renameSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
skipNextSessionInitRef,
|
skipNextSessionInitRef,
|
||||||
} = useQuickChat(projectId, addToast);
|
} = useQuickChat(projectId, addToast);
|
||||||
@@ -1939,6 +1942,32 @@ export function QuickChatFAB({
|
|||||||
setSessionMenuOpen(false);
|
setSessionMenuOpen(false);
|
||||||
}, [markRead, roomThreadActive, roomsState, selectSession, sessions]);
|
}, [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 handleRoomSwitch = useCallback((roomId: string) => {
|
||||||
const selectedRoom = roomsState.rooms.find((room) => room.id === roomId);
|
const selectedRoom = roomsState.rooms.find((room) => room.id === roomId);
|
||||||
markRead("room", roomId, selectedRoom?.updatedAt);
|
markRead("room", roomId, selectedRoom?.updatedAt);
|
||||||
@@ -2759,6 +2788,11 @@ export function QuickChatFAB({
|
|||||||
<div className="quick-chat-panel-header">
|
<div className="quick-chat-panel-header">
|
||||||
<div className="quick-chat-panel-title-wrap">
|
<div className="quick-chat-panel-title-wrap">
|
||||||
<h3>{t("chat.quickChatTitle", "Quick Chat")}</h3>
|
<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 ? (
|
{roomThreadActive && roomsState.activeRoom ? (
|
||||||
<span className="quick-chat-model-tag" data-testid="quick-chat-room-tag" title={`#${roomsState.activeRoom.name}`}>
|
<span className="quick-chat-model-tag" data-testid="quick-chat-room-tag" title={`#${roomsState.activeRoom.name}`}>
|
||||||
#{roomsState.activeRoom.name}
|
#{roomsState.activeRoom.name}
|
||||||
@@ -2879,23 +2913,37 @@ export function QuickChatFAB({
|
|||||||
const session = sessions.find((item) => item.id === sessionOption.id);
|
const session = sessions.find((item) => item.id === sessionOption.id);
|
||||||
const showUnreadDot = !isActiveSession && isUnread("direct", sessionOption.id, session?.lastMessageAt ?? session?.updatedAt);
|
const showUnreadDot = !isActiveSession && isUnread("direct", sessionOption.id, session?.lastMessageAt ?? session?.updatedAt);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={sessionOption.id}
|
key={sessionOption.id}
|
||||||
type="button"
|
className={`quick-chat-session-option-row${isActiveSession ? " quick-chat-session-option-row--active" : ""}`}
|
||||||
role="menuitem"
|
role="none"
|
||||||
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>
|
<button
|
||||||
{showUnreadDot ? (
|
type="button"
|
||||||
<span
|
role="menuitem"
|
||||||
className="chat-unread-dot quick-chat-session-unread-dot"
|
data-testid={`quick-chat-session-option-${sessionOption.id}`}
|
||||||
data-testid={`quick-chat-unread-dot-${sessionOption.id}`}
|
className={`quick-chat-session-option${isActiveSession ? " quick-chat-session-option--active" : ""}`}
|
||||||
aria-label={t("chat.unreadMessages", "Unread messages")}
|
onClick={() => handleSessionSwitch(sessionOption.id)}
|
||||||
/>
|
>
|
||||||
) : null}
|
<span>{sessionOption.label}</span>
|
||||||
</button>
|
{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>
|
</div>
|
||||||
@@ -2903,6 +2951,43 @@ export function QuickChatFAB({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{newSessionChooserOpen && (
|
||||||
<div className="quick-chat-new-session-chooser" data-testid="quick-chat-new-session-chooser">
|
<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">
|
<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} />,
|
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} />,
|
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} />,
|
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} />,
|
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} />,
|
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} />,
|
Square: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-square"} {...props} />,
|
||||||
@@ -137,6 +138,7 @@ const defaultChatState: UseChatReturn = {
|
|||||||
selectSession: vi.fn(),
|
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),
|
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(),
|
archiveSession: vi.fn(),
|
||||||
|
renameSession: vi.fn(),
|
||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stopStreaming: vi.fn(),
|
stopStreaming: vi.fn(),
|
||||||
@@ -2877,6 +2879,112 @@ describe("Chat Session Delete Button", () => {
|
|||||||
expect(selectSession).not.toHaveBeenCalled();
|
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 () => {
|
it("confirming delete calls deleteSession", async () => {
|
||||||
const deleteSession = vi.fn();
|
const deleteSession = vi.fn();
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ vi.mock("../../api", () => ({
|
|||||||
fetchChatSessions: vi.fn(),
|
fetchChatSessions: vi.fn(),
|
||||||
createChatSession: vi.fn(),
|
createChatSession: vi.fn(),
|
||||||
fetchChatMessages: vi.fn(),
|
fetchChatMessages: vi.fn(),
|
||||||
|
updateChatSession: vi.fn(),
|
||||||
streamChatResponse: vi.fn(),
|
streamChatResponse: vi.fn(),
|
||||||
cancelChatResponse: vi.fn(),
|
cancelChatResponse: vi.fn(),
|
||||||
fetchModels: vi.fn(),
|
fetchModels: vi.fn(),
|
||||||
@@ -46,6 +47,7 @@ const mockFetchResumeChatSession = vi.mocked(apiModule.fetchResumeChatSession);
|
|||||||
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
||||||
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
||||||
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||||
|
const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession);
|
||||||
const mockFetchModels = vi.mocked(apiModule.fetchModels);
|
const mockFetchModels = vi.mocked(apiModule.fetchModels);
|
||||||
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
||||||
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
||||||
@@ -188,6 +190,7 @@ describe("QuickChatFAB session-first UX", () => {
|
|||||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||||
mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] });
|
mockFetchChatSessions.mockResolvedValue({ sessions: [modelSession, agentSession] });
|
||||||
mockCreateChatSession.mockResolvedValue({ session: { ...modelSession, id: "session-new" } });
|
mockCreateChatSession.mockResolvedValue({ session: { ...modelSession, id: "session-new" } });
|
||||||
|
mockUpdateChatSession.mockResolvedValue({ session: { ...modelSession, title: "Renamed model thread" } });
|
||||||
mockCancelChatResponse.mockResolvedValue({ success: true });
|
mockCancelChatResponse.mockResolvedValue({ success: true });
|
||||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
handlers.onDone?.({ messageId: "msg-stream" });
|
handlers.onDone?.({ messageId: "msg-stream" });
|
||||||
@@ -284,6 +287,28 @@ describe("QuickChatFAB session-first UX", () => {
|
|||||||
expect(screen.getByTestId("quick-chat-session-option-session-agent")).toBeInTheDocument();
|
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 () => {
|
it("renders unread dots for unread sessions and hides active session dot", async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"kb:proj-1:fusion:chat-unread:direct",
|
"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) => {
|
const setDocumentVisibilityState = (state: DocumentVisibilityState) => {
|
||||||
Object.defineProperty(document, "visibilityState", {
|
Object.defineProperty(document, "visibilityState", {
|
||||||
configurable: true,
|
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 () => {
|
it("deletes a session", async () => {
|
||||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ vi.mock("../../api", () => ({
|
|||||||
fetchChatSession: vi.fn(),
|
fetchChatSession: vi.fn(),
|
||||||
createChatSession: vi.fn(),
|
createChatSession: vi.fn(),
|
||||||
fetchChatMessages: vi.fn(),
|
fetchChatMessages: vi.fn(),
|
||||||
|
updateChatSession: vi.fn(),
|
||||||
streamChatResponse: vi.fn(),
|
streamChatResponse: vi.fn(),
|
||||||
attachChatStream: vi.fn(),
|
attachChatStream: vi.fn(),
|
||||||
cancelChatResponse: vi.fn(),
|
cancelChatResponse: vi.fn(),
|
||||||
@@ -22,6 +23,7 @@ const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions);
|
|||||||
const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
|
const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession);
|
||||||
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
const mockCreateChatSession = vi.mocked(apiModule.createChatSession);
|
||||||
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages);
|
||||||
|
const mockUpdateChatSession = vi.mocked(apiModule.updateChatSession);
|
||||||
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
const mockStreamChatResponse = vi.mocked(apiModule.streamChatResponse);
|
||||||
const mockAttachChatStream = vi.mocked(apiModule.attachChatStream);
|
const mockAttachChatStream = vi.mocked(apiModule.attachChatStream);
|
||||||
const mockCancelChatResponse = vi.mocked(apiModule.cancelChatResponse);
|
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 {
|
function makeMessage(overrides: Partial<ChatMessage> & Pick<ChatMessage, "id" | "sessionId" | "role" | "content">): ChatMessage {
|
||||||
return {
|
return {
|
||||||
id: overrides.id,
|
id: overrides.id,
|
||||||
@@ -73,6 +85,9 @@ describe("useQuickChat", () => {
|
|||||||
mockFetchChatSession.mockResolvedValue({
|
mockFetchChatSession.mockResolvedValue({
|
||||||
session: { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false },
|
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 });
|
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||||
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
mockAttachChatStream.mockReturnValue({ close: vi.fn(), isConnected: () => true });
|
||||||
mockCancelChatResponse.mockResolvedValue({ success: true });
|
mockCancelChatResponse.mockResolvedValue({ success: true });
|
||||||
@@ -83,6 +98,100 @@ describe("useQuickChat", () => {
|
|||||||
vi.useRealTimers();
|
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 () => {
|
it("queues first send made before session init completes and streams once ready", async () => {
|
||||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface UseChatReturn {
|
|||||||
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string },
|
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string },
|
||||||
) => Promise<ChatSessionInfo>;
|
) => Promise<ChatSessionInfo>;
|
||||||
archiveSession: (id: string) => Promise<void>;
|
archiveSession: (id: string) => Promise<void>;
|
||||||
|
renameSession: (id: string, title: string) => Promise<void>;
|
||||||
deleteSession: (id: string) => Promise<void>;
|
deleteSession: (id: string) => Promise<void>;
|
||||||
|
|
||||||
// Message operations
|
// Message operations
|
||||||
@@ -797,6 +798,52 @@ export function useChat(
|
|||||||
[activeSession, projectId],
|
[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
|
// Delete a session
|
||||||
const deleteSession = useCallback(
|
const deleteSession = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
@@ -1320,6 +1367,7 @@ export function useChat(
|
|||||||
selectSession,
|
selectSession,
|
||||||
createSession,
|
createSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
renameSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
fetchChatSession,
|
fetchChatSession,
|
||||||
createChatSession,
|
createChatSession,
|
||||||
fetchChatMessages,
|
fetchChatMessages,
|
||||||
|
updateChatSession,
|
||||||
attachChatStream,
|
attachChatStream,
|
||||||
streamChatResponse,
|
streamChatResponse,
|
||||||
cancelChatResponse,
|
cancelChatResponse,
|
||||||
@@ -64,6 +65,7 @@ export interface UseQuickChatReturn {
|
|||||||
selectSession: (session: EnrichedChatSession) => Promise<void>;
|
selectSession: (session: EnrichedChatSession) => Promise<void>;
|
||||||
startModelChat: (modelProvider: string, modelId: string) => Promise<void>;
|
startModelChat: (modelProvider: string, modelId: string) => Promise<void>;
|
||||||
startFreshSession: (agentId?: string, 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>;
|
refreshSessions: () => Promise<void>;
|
||||||
loadMessages: () => Promise<void>;
|
loadMessages: () => Promise<void>;
|
||||||
reloadMessages: () => Promise<void>;
|
reloadMessages: () => Promise<void>;
|
||||||
@@ -1176,6 +1178,52 @@ export function useQuickChat(
|
|||||||
};
|
};
|
||||||
}, [activeSession?.id, pendingMessage, projectId, flushPendingMessage]);
|
}, [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
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1205,6 +1253,7 @@ export function useQuickChat(
|
|||||||
selectSession,
|
selectSession,
|
||||||
startModelChat,
|
startModelChat,
|
||||||
startFreshSession,
|
startFreshSession,
|
||||||
|
renameSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
reloadMessages,
|
reloadMessages,
|
||||||
@@ -1227,6 +1276,7 @@ export function useQuickChat(
|
|||||||
selectSession,
|
selectSession,
|
||||||
startModelChat,
|
startModelChat,
|
||||||
startFreshSession,
|
startFreshSession,
|
||||||
|
renameSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
reloadMessages,
|
reloadMessages,
|
||||||
|
|||||||
Reference in New Issue
Block a user