FN-8397: separate pinned chats and retain failed edits
Separate direct-chat session groups and make message edits safely retryable.
- Render explicit Pinned and Recent sections across desktop and mobile chat lists.
- Keep inline message corrections open until edit-and-resend succeeds in Direct and Planner Chat.
- Add localized Recent labels, documentation, release metadata, and regression coverage.
Files changed:
.changeset/fn-8397-chat-pin-separation-and-edit-save.md | 7 +++
docs/dashboard-guide.md | 5 +-
packages/dashboard/app/components/ChatView.css | 10 ++++
packages/dashboard/app/components/ChatView.tsx | 31 ++++++++---
packages/dashboard/app/components/StandardChatSurface.tsx | 35 +++++++++----
packages/dashboard/app/components/TaskPlannerChatTab.tsx | 14 +++--
packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx | 46 +++++++++++++++++
packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx | 60 ++++++++++++++++++++--
packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx | 10 ++--
packages/dashboard/app/hooks/__tests__/useChat.test.ts | 4 +-
packages/dashboard/app/hooks/useChat.ts | 13 +++--
packages/i18n/locales/{en,es,fr,ko,zh-CN,zh-TW}/app.json | 6 +++
17 files changed, 205 insertions(+), 36 deletions(-)
Fusion-Task-Id: FN-8397
Fusion-Task-Lineage: 4dbc664a-56a3-48c7-be9a-febd5f411c9e
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8397-chat-pin-separation-and-edit-save.md
Normal file
7
.changeset/fn-8397-chat-pin-separation-and-edit-save.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Separate pinned chat conversations in the list and fix message edit Save.
|
||||||
|
category: fix
|
||||||
|
dev: ChatView now renders named Pinned and Recent sections on desktop and mobile; async message Save prevents duplicate rewind-and-resend operations.
|
||||||
@@ -623,7 +623,8 @@ Chat view provides project-scoped conversations with agents.
|
|||||||
- On mobile direct-chat threads, the single thread-wide Markdown/plain eye toggle floats above the transcript/composer area instead of occupying a second header row; desktop/tablet keeps the toggle in the thread header.
|
- On mobile direct-chat threads, the single thread-wide Markdown/plain eye toggle floats above the transcript/composer area instead of occupying a second header row; desktop/tablet keeps the toggle in the thread header.
|
||||||
- Direct chat sessions can be renamed from the sidebar row edit button, the desktop conversation context menu, and the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
|
- Direct chat sessions can be renamed from the sidebar row edit button, the desktop conversation context menu, and the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again.
|
||||||
<!-- FNXC:ChatPinned 2026-07-16-12:00: Document the Direct-only pin contract across desktop and mobile surfaces, including the durable server-side scope limit. -->
|
<!-- FNXC:ChatPinned 2026-07-16-12:00: Document the Direct-only pin contract across desktop and mobile surfaces, including the durable server-side scope limit. -->
|
||||||
- You can pin up to **3** active Direct conversations per project scope from a sidebar row, desktop right-click menu, or the mobile session switcher. Pinned conversations show an indicator, sort above recent unpinned conversations, and appear under **Pinned** on both desktop and mobile. The server serializes each scope's pin changes (including null-project/default sessions) so concurrent requests cannot exceed the limit. Archiving always removes a pin, whether archived through either archive path; archived conversations cannot be pinned.
|
<!-- FNXC:ChatPinned 2026-07-19-00:00: Pinned and unpinned Direct conversations must remain distinct named groups in every ChatView host, including the mobile switcher. -->
|
||||||
|
- You can pin up to **3** active Direct conversations per project scope from a sidebar row, desktop right-click menu, or the mobile session switcher. Pinned conversations show an indicator, sort above recent unpinned conversations, and appear in separately labeled **Pinned** and **Recent** sections on both desktop and mobile. The server serializes each scope's pin changes (including null-project/default sessions) so concurrent requests cannot exceed the limit. Archiving always removes a pin, whether archived through either archive path; archived conversations cannot be pinned.
|
||||||
<!-- FNXC:ChatViewDocs 2026-07-01-00:00: Task-detail planner chats are intentionally hidden from the common Direct feed by default after issue #1850; Settings keeps an opt-in for operators who want populated task-planner sessions restored without adding a mandatory Tasks tab. -->
|
<!-- FNXC:ChatViewDocs 2026-07-01-00:00: Task-detail planner chats are intentionally hidden from the common Direct feed by default after issue #1850; Settings keeps an opt-in for operators who want populated task-planner sessions restored without adding a mandatory Tasks tab. -->
|
||||||
<!-- FNXC:TaskDetailPlannerChat 2026-07-01-22:02: Done-task planner Chat remains available for retrospective Q&A and can create a task-scoped refinement through the planner tool, while common Chat feed visibility remains opt-in. -->
|
<!-- FNXC:TaskDetailPlannerChat 2026-07-01-22:02: Done-task planner Chat remains available for retrospective Q&A and can create a task-scoped refinement through the planner tool, while common Chat feed visibility remains opt-in. -->
|
||||||
- Task-detail planner Chat conversations stay available from each task's **Chat** tab, including after the task is `done`. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. On completed tasks, clear follow-up implementation or improvement requests can create a normal refinement task from the completed source task.
|
- Task-detail planner Chat conversations stay available from each task's **Chat** tab, including after the task is `done`. They are hidden from the common Direct/common Chat feed by default; enable **Settings → Project General → Show task chats in common Chat feed** to include populated task chats again. Empty task chat sessions stay hidden either way. Planner Chat can answer token-count, estimated-cost, runtime, timing-event, workflow-step duration, and per-model usage questions for the current task through a read-only task-scoped metrics tool; unknown/stale pricing is reported as uncertain instead of `$0`. On completed tasks, clear follow-up implementation or improvement requests can create a normal refinement task from the completed source task.
|
||||||
@@ -643,7 +644,7 @@ Chat view provides project-scoped conversations with agents.
|
|||||||
<!-- FNXC:ChatMessageScrollToTop 2026-07-12-23:20: Chat message go-to-top is contextual; document that it appears only after a message top has scrolled above the chat viewport and sits inline with Thinking/copy actions. -->
|
<!-- FNXC:ChatMessageScrollToTop 2026-07-12-23:20: Chat message go-to-top is contextual; document that it appears only after a message top has scrolled above the chat viewport and sits inline with Thinking/copy actions. -->
|
||||||
- Assistant messages expose a **Scroll message to top** up-arrow only after that message's top has moved above the visible chat viewport. When visible, the control sits inline with the message's **Thinking** row (or the same footer action row when no thinking details are present) instead of adding a separate action line.
|
- Assistant messages expose a **Scroll message to top** up-arrow only after that message's top has moved above the visible chat viewport. When visible, the control sits inline with the message's **Thinking** row (or the same footer action row when no thinking details are present) instead of adding a separate action line.
|
||||||
- Message editing applies to direct/model-loop chat sessions, **including task-detail Planner Chat** (the synthetic `task-planner:<id>` session, a model-loop session under the hood). It is **not** available in **Chat Rooms** (multi-agent, different persistence) or in **CLI-agent-backed sessions** (the transcript is owned by a live terminal, not a rewindable model session). The edit action is also disabled while a response is actively streaming, to avoid racing a live generation, and never renders on optimistic/in-flight rows that have no persisted message id yet.
|
- Message editing applies to direct/model-loop chat sessions, **including task-detail Planner Chat** (the synthetic `task-planner:<id>` session, a model-loop session under the hood). It is **not** available in **Chat Rooms** (multi-agent, different persistence) or in **CLI-agent-backed sessions** (the transcript is owned by a live terminal, not a rewindable model session). The edit action is also disabled while a response is actively streaming, to avoid racing a live generation, and never renders on optimistic/in-flight rows that have no persisted message id yet.
|
||||||
- Editing is truncate-and-resend, not append: the edited message and everything after it are removed first, then the edited text is sent as a new turn through the normal streaming path — so the resulting transcript looks the same as if you had deleted the old messages and typed the correction from scratch, but in one action.
|
- Editing is truncate-and-resend, not append: the edited message and everything after it are removed first, then the edited text is sent as a new turn through the normal streaming path — so the resulting transcript looks the same as if you had deleted the old messages and typed the correction from scratch, but in one action. **Save** remains active until this rewind-and-resend request finishes, preventing duplicate sends; if the request fails, the transcript is restored and Chat shows an error instead of silently dropping the correction.
|
||||||
<!-- FNXC:TaskDetailPlannerChat 2026-07-07-10:15: Document Planner Chat edit-and-resend and the steering/refinement side-effect decision on discard. -->
|
<!-- FNXC:TaskDetailPlannerChat 2026-07-07-10:15: Document Planner Chat edit-and-resend and the steering/refinement side-effect decision on discard. -->
|
||||||
- In task-detail **Planner Chat**, editing an earlier message resumes the conversation from that point exactly as in direct chat. If the discarded turns already triggered task-scoped side effects — a steering comment added via the planner steering tool, or a refinement task created via the planner refinement tool — those **are not reverted**: the steering comment stays on the task and the refinement task stays open, because undoing either is destructive and out of scope for a chat edit. After a successful edit-and-resend, task detail refreshes automatically (so Activity/steering reflects reality), and if the discarded range held one of those confirmations you get an informational toast noting that the earlier change was not undone.
|
- In task-detail **Planner Chat**, editing an earlier message resumes the conversation from that point exactly as in direct chat. If the discarded turns already triggered task-scoped side effects — a steering comment added via the planner steering tool, or a refinement task created via the planner refinement tool — those **are not reverted**: the steering comment stays on the task and the refinement task stays open, because undoing either is destructive and out of scope for a chat edit. After a successful edit-and-resend, task detail refreshes automatically (so Activity/steering reflects reality), and if the discarded range held one of those confirmations you get an informational toast noting that the earlier change was not undone.
|
||||||
<!-- FNXC:ChatSearch 2026-07-07-12:00: Content search is always on (FN-7651 removed the "Search in title only" toggle per user request); document the always-on behavior instead of a switchable toggle. -->
|
<!-- FNXC:ChatSearch 2026-07-07-12:00: Content search is always on (FN-7651 removed the "Search in title only" toggle per user request); document the always-on behavior instead of a switchable toggle. -->
|
||||||
|
|||||||
@@ -422,6 +422,16 @@ FN-8173 replaces the inline Pin/Rename/Delete cluster with one overflow trigger.
|
|||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:ChatPinned 2026-07-19-00:00:
|
||||||
|
Pinned and Recent conversation groups use the same divider treatment on desktop and mobile.
|
||||||
|
A token-sized gap between populated groups makes their ownership visible without introducing a
|
||||||
|
second sidebar-list component or leaving an empty section shell.
|
||||||
|
*/
|
||||||
|
.chat-session-section + .chat-session-section {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-pinned-divider {
|
.chat-pinned-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3107,8 +3107,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
</button>
|
</button>
|
||||||
{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">
|
||||||
{pinnedFilteredSessions.length > 0 ? <div className="chat-pinned-divider" data-testid="chat-mobile-pinned-divider">{t("chat.pinned", "Pinned")}</div> : null}
|
{[
|
||||||
{[...pinnedFilteredSessions, ...unpinnedFilteredSessions].map((session) => (
|
{ id: "pinned", label: t("chat.pinned", "Pinned"), testId: "chat-mobile-pinned-divider", sessions: pinnedFilteredSessions },
|
||||||
|
{ id: "recent", label: t("chat.recent", "Recent"), testId: "chat-mobile-recent-divider", sessions: unpinnedFilteredSessions },
|
||||||
|
].filter((group) => group.sessions.length > 0).map((group) => (
|
||||||
|
<section className="chat-session-section" data-testid={`chat-mobile-session-section-${group.id}`} key={group.id}>
|
||||||
|
<div className="chat-pinned-divider" data-testid={group.testId}>{group.label}</div>
|
||||||
|
{group.sessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className={`chat-mobile-session-option-row${activeSession?.id === session.id ? " chat-mobile-session-option-row--active" : ""}`}
|
className={`chat-mobile-session-option-row${activeSession?.id === session.id ? " chat-mobile-session-option-row--active" : ""}`}
|
||||||
@@ -3144,6 +3149,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
))}
|
))}
|
||||||
{/*
|
{/*
|
||||||
FNXC:Chat 2026-06-27-00:00:
|
FNXC:Chat 2026-06-27-00:00:
|
||||||
@@ -3319,9 +3326,19 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
<div className="chat-empty-state chat-empty-state--padded">{t("chat.noConversationsYet", "No conversations yet")}</div>
|
<div className="chat-empty-state chat-empty-state--padded">{t("chat.noConversationsYet", "No conversations yet")}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* FNXC:ChatPinned 2026-07-16-12:00: Direct-session pins are grouped on desktop and mobile; the store caps each scope at three. */}
|
{/*
|
||||||
{pinnedFilteredSessions.length > 0 ? <div className="chat-pinned-divider" data-testid="chat-pinned-divider">{t("chat.pinned", "Pinned")}</div> : null}
|
FNXC:ChatPinned 2026-07-19-00:00:
|
||||||
{filteredSessions.map((session) => {
|
Direct conversation pins must be two explicit sections on every session-list surface.
|
||||||
|
Do not flatten Recent rows beneath Pinned: labels and wrappers make the pin boundary
|
||||||
|
clear for desktop, mobile, full Chat, and Quick Chat (all share this component).
|
||||||
|
*/}
|
||||||
|
{[
|
||||||
|
{ id: "pinned", label: t("chat.pinned", "Pinned"), testId: "chat-pinned-divider", sessions: pinnedFilteredSessions },
|
||||||
|
{ id: "recent", label: t("chat.recent", "Recent"), testId: "chat-recent-divider", sessions: unpinnedFilteredSessions },
|
||||||
|
].filter((group) => group.sessions.length > 0).map((group) => (
|
||||||
|
<section className="chat-session-section" data-testid={`chat-session-section-${group.id}`} key={group.id}>
|
||||||
|
<div className="chat-pinned-divider" data-testid={group.testId}>{group.label}</div>
|
||||||
|
{group.sessions.map((session) => {
|
||||||
const isActive = activeSession?.id === session.id;
|
const isActive = activeSession?.id === session.id;
|
||||||
const showUnreadDot = !isActive && isUnread("direct", session.id, session.lastMessageAt ?? session.updatedAt);
|
const showUnreadDot = !isActive && isUnread("direct", session.id, session.lastMessageAt ?? session.updatedAt);
|
||||||
const sessionResolvedModel = resolveSessionProvider(
|
const sessionResolvedModel = resolveSessionProvider(
|
||||||
@@ -3397,7 +3414,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -536,6 +536,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
|||||||
*/
|
*/
|
||||||
const showEditAction = isUserMessage && canEdit && Boolean(onEditMessage);
|
const showEditAction = isUserMessage && canEdit && Boolean(onEditMessage);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
const [editedText, setEditedText] = useState(message.content);
|
const [editedText, setEditedText] = useState(message.content);
|
||||||
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -545,16 +546,31 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
|||||||
}, [message.content]);
|
}, [message.content]);
|
||||||
|
|
||||||
const cancelEditing = useCallback(() => {
|
const cancelEditing = useCallback(() => {
|
||||||
|
if (isSavingEdit) return;
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setEditedText(message.content);
|
setEditedText(message.content);
|
||||||
}, [message.content]);
|
}, [isSavingEdit, message.content]);
|
||||||
|
|
||||||
const saveEdit = useCallback(() => {
|
const saveEdit = useCallback(async () => {
|
||||||
const trimmed = editedText.trim();
|
const trimmed = editedText.trim();
|
||||||
if (!trimmed || trimmed === message.content) return;
|
if (!trimmed || trimmed === message.content.trim() || !onEditMessage || isSavingEdit) return;
|
||||||
setIsEditing(false);
|
|
||||||
void onEditMessage?.(message.id, trimmed);
|
/*
|
||||||
}, [editedText, message.content, message.id, onEditMessage]);
|
FNXC:ChatMessageEdit 2026-07-19-00:00:
|
||||||
|
Save is an async truncate-and-resend operation, not a fire-and-forget click. Keep this
|
||||||
|
editor mounted and lock its actions until the surface handler finishes so repeated clicks
|
||||||
|
cannot race the PATCH rewind with multiple stream starts; only then remove the editor.
|
||||||
|
*/
|
||||||
|
setIsSavingEdit(true);
|
||||||
|
try {
|
||||||
|
await onEditMessage(message.id, trimmed);
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch {
|
||||||
|
// Surface handlers own recovery/toasts; keep the correction visible for unexpected failures.
|
||||||
|
} finally {
|
||||||
|
setIsSavingEdit(false);
|
||||||
|
}
|
||||||
|
}, [editedText, isSavingEdit, message.content, message.id, onEditMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -650,6 +666,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
|||||||
ref={editTextareaRef}
|
ref={editTextareaRef}
|
||||||
className="input chat-message-edit-textarea"
|
className="input chat-message-edit-textarea"
|
||||||
value={editedText}
|
value={editedText}
|
||||||
|
disabled={isSavingEdit}
|
||||||
onChange={(event) => setEditedText(event.target.value)}
|
onChange={(event) => setEditedText(event.target.value)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@@ -657,14 +674,14 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
|||||||
cancelEditing();
|
cancelEditing();
|
||||||
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
saveEdit();
|
void saveEdit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<div className="chat-message-edit-actions">
|
<div className="chat-message-edit-actions">
|
||||||
<button type="button" className="btn btn-sm" onClick={cancelEditing}>{t("chat.editMessageCancel", "Cancel")}</button>
|
<button type="button" className="btn btn-sm" data-testid={`chat-message-edit-cancel-${message.id}`} disabled={isSavingEdit} onClick={cancelEditing}>{t("chat.editMessageCancel", "Cancel")}</button>
|
||||||
<button type="button" className="btn btn-sm btn-primary" disabled={!editedText.trim() || editedText.trim() === message.content} onClick={saveEdit}>{t("chat.editMessageSave", "Save")}</button>
|
<button type="button" className="btn btn-sm btn-primary" data-testid={`chat-message-edit-save-${message.id}`} disabled={isSavingEdit || !editedText.trim() || editedText.trim() === message.content.trim()} onClick={() => void saveEdit()}>{t("chat.editMessageSave", "Save")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -740,19 +740,23 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optimistic truncation: drop the edited message and everything after it immediately,
|
|
||||||
// matching the server's index-based truncation semantics (not just a timestamp filter).
|
|
||||||
setMessages((current) => current.slice(0, targetIndex));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await editChatMessage(resolvedSessionId, messageId, trimmed, projectId);
|
await editChatMessage(resolvedSessionId, messageId, trimmed, projectId);
|
||||||
|
// Keep the edited row mounted until PATCH success so failure leaves its correction editable.
|
||||||
|
setMessages((current) => current.slice(0, targetIndex));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err) || t("taskDetail.plannerChat.editFailed", "Failed to edit planner chat message");
|
const message = getErrorMessage(err) || t("taskDetail.plannerChat.editFailed", "Failed to edit planner chat message");
|
||||||
setError(message);
|
setError(message);
|
||||||
addToastRef.current(message, "error");
|
addToastRef.current(message, "error");
|
||||||
// Restore truthful state from the server rather than trusting the optimistic truncation.
|
// Restore truthful state from the server rather than trusting the optimistic truncation.
|
||||||
void refreshMessagesForSession(resolvedSessionId, () => true);
|
void refreshMessagesForSession(resolvedSessionId, () => true);
|
||||||
return;
|
/*
|
||||||
|
* FNXC:TaskDetailPlannerChat 2026-07-19-00:00:
|
||||||
|
* Preserve an edited correction after a Planner Chat PATCH failure: StandardChatMessageItem
|
||||||
|
* interprets rejection as failed save and retains its editor, while this surface still owns
|
||||||
|
* the error toast and truthful transcript refresh.
|
||||||
|
*/
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendMessageContent(trimmed);
|
await sendMessageContent(trimmed);
|
||||||
|
|||||||
@@ -229,6 +229,52 @@ describe("formatModelTag helper function", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Chat pinned session sections", () => {
|
||||||
|
const pinnedAt = "2026-07-19T00:00:00.000Z";
|
||||||
|
const mixedSessions: ChatSessionInfo[] = [
|
||||||
|
{ id: "session-pinned", agentId: "agent-001", status: "active", title: "Pinned", pinnedAt, createdAt: pinnedAt, updatedAt: pinnedAt },
|
||||||
|
{ id: "session-recent", agentId: "agent-002", status: "active", title: "Recent", createdAt: pinnedAt, updatedAt: pinnedAt },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("separates pinned and recent direct sessions on desktop and omits empty shells", async () => {
|
||||||
|
setupMockChat({ activeSession: mixedSessions[0], sessions: mixedSessions, filteredSessions: mixedSessions });
|
||||||
|
|
||||||
|
const view = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const pinned = screen.getByTestId("chat-session-section-pinned");
|
||||||
|
const recent = screen.getByTestId("chat-session-section-recent");
|
||||||
|
expect(pinned).toHaveTextContent("Pinned");
|
||||||
|
expect(within(pinned).getByTestId("chat-session-session-pinned")).toBeInTheDocument();
|
||||||
|
expect(within(recent).getByTestId("chat-session-session-recent")).toBeInTheDocument();
|
||||||
|
expect(within(recent).getByTestId("chat-recent-divider")).toHaveTextContent("Recent");
|
||||||
|
|
||||||
|
setupMockChat({ activeSession: mixedSessions[1], sessions: [mixedSessions[1]], filteredSessions: [mixedSessions[1]] });
|
||||||
|
await act(async () => {
|
||||||
|
view.rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("chat-session-section-pinned")).toBeNull();
|
||||||
|
expect(screen.getByTestId("chat-session-section-recent")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates pinned and recent direct sessions in the mobile switcher", async () => {
|
||||||
|
const restoreMatchMedia = mockViewportMode("mobile");
|
||||||
|
try {
|
||||||
|
setupMockChat({ activeSession: mixedSessions[0], sessions: mixedSessions, filteredSessions: mixedSessions });
|
||||||
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
await userEvent.click(screen.getByTestId("chat-session-session-pinned"));
|
||||||
|
await userEvent.click(screen.getByTestId("chat-mobile-session-trigger"));
|
||||||
|
|
||||||
|
const pinned = screen.getByTestId("chat-mobile-session-section-pinned");
|
||||||
|
const recent = screen.getByTestId("chat-mobile-session-section-recent");
|
||||||
|
expect(within(pinned).getByTestId("chat-mobile-session-option-session-pinned")).toBeInTheDocument();
|
||||||
|
expect(within(recent).getByTestId("chat-mobile-session-option-session-recent")).toBeInTheDocument();
|
||||||
|
expect(within(recent).getByTestId("chat-mobile-recent-divider")).toHaveTextContent("Recent");
|
||||||
|
} finally {
|
||||||
|
restoreMatchMedia();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Chat Session Action Menu", () => {
|
describe("Chat Session Action Menu", () => {
|
||||||
it("renders one overflow button on each session item without an inline action cluster", async () => {
|
it("renders one overflow button on each session item without an inline action cluster", async () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ streaming. Also covers the inline editor save/cancel interaction and the
|
|||||||
editMessageAndResend wiring.
|
editMessageAndResend wiring.
|
||||||
*/
|
*/
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, fireEvent, render as rtlRender, screen } from "@testing-library/react";
|
import { act, fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||||
import { ChatView } from "../ChatView";
|
import { ChatView } from "../ChatView";
|
||||||
import { StandardChatMessageItem } from "../StandardChatSurface";
|
import { StandardChatMessageItem } from "../StandardChatSurface";
|
||||||
import * as useChatModule from "../../hooks/useChat";
|
import * as useChatModule from "../../hooks/useChat";
|
||||||
@@ -227,11 +227,35 @@ describe("ChatView message edit affordance", () => {
|
|||||||
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
|
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
|
||||||
expect(textarea.value).toBe("hello");
|
expect(textarea.value).toBe("hello");
|
||||||
|
|
||||||
|
expect(screen.getByTestId("chat-message-edit-save-user-1")).toBeDisabled();
|
||||||
fireEvent.change(textarea, { target: { value: "hello, edited" } });
|
fireEvent.change(textarea, { target: { value: "hello, edited" } });
|
||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByTestId("chat-message-edit-save-user-1"));
|
||||||
|
|
||||||
expect(editMessageAndResend).toHaveBeenCalledWith("user-1", "hello, edited");
|
expect(editMessageAndResend).toHaveBeenCalledWith("user-1", "hello, edited");
|
||||||
expect(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat unchanged trailing whitespace as an edit", async () => {
|
||||||
|
const editMessageAndResend = vi.fn();
|
||||||
|
await renderWithAct(
|
||||||
|
<StandardChatMessageItem
|
||||||
|
message={{ id: "whitespace-user-1", sessionId: "session-001", role: "user", content: "hello ", createdAt: "2026-04-08T00:00:00.000Z" }}
|
||||||
|
forcePlain={false}
|
||||||
|
agentName="Fusion"
|
||||||
|
hideAssistantIdentity={false}
|
||||||
|
showAssistantModelTag={false}
|
||||||
|
activeModelTag={null}
|
||||||
|
activeModelProvider={null}
|
||||||
|
activeSessionId="session-001"
|
||||||
|
onEditMessage={editMessageAndResend}
|
||||||
|
canEdit
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("chat-message-edit-whitespace-user-1"));
|
||||||
|
expect(screen.getByTestId("chat-message-edit-save-whitespace-user-1")).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancel restores the original content without calling editMessageAndResend", async () => {
|
it("cancel restores the original content without calling editMessageAndResend", async () => {
|
||||||
@@ -250,13 +274,41 @@ describe("ChatView message edit affordance", () => {
|
|||||||
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
|
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
|
||||||
fireEvent.change(textarea, { target: { value: "changed but cancelled" } });
|
fireEvent.change(textarea, { target: { value: "changed but cancelled" } });
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Cancel"));
|
fireEvent.click(screen.getByTestId("chat-message-edit-cancel-user-1"));
|
||||||
|
|
||||||
expect(editMessageAndResend).not.toHaveBeenCalled();
|
expect(editMessageAndResend).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
|
expect(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
|
||||||
expect(screen.getByTestId("chat-message-user-1")).toHaveTextContent("hello");
|
expect(screen.getByTestId("chat-message-user-1")).toHaveTextContent("hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the correction visible when an edit handler rejects", async () => {
|
||||||
|
const editMessageAndResend = vi.fn().mockRejectedValueOnce(new Error("PATCH failed"));
|
||||||
|
await renderWithAct(
|
||||||
|
<StandardChatMessageItem
|
||||||
|
message={{ id: "failed-user-1", sessionId: "session-001", role: "user", content: "original", createdAt: "2026-04-08T00:00:00.000Z" }}
|
||||||
|
forcePlain={false}
|
||||||
|
agentName="Fusion"
|
||||||
|
hideAssistantIdentity={false}
|
||||||
|
showAssistantModelTag={false}
|
||||||
|
activeModelTag={null}
|
||||||
|
activeModelProvider={null}
|
||||||
|
activeSessionId="session-001"
|
||||||
|
onEditMessage={editMessageAndResend}
|
||||||
|
canEdit
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("chat-message-edit-failed-user-1"));
|
||||||
|
const textarea = screen.getByTestId("chat-message-edit-editor-failed-user-1").querySelector("textarea") as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(textarea, { target: { value: "corrected" } });
|
||||||
|
fireEvent.click(screen.getByTestId("chat-message-edit-save-failed-user-1"));
|
||||||
|
|
||||||
|
await act(async () => undefined);
|
||||||
|
expect(editMessageAndResend).toHaveBeenCalledWith("failed-user-1", "corrected");
|
||||||
|
expect(screen.getByTestId("chat-message-edit-editor-failed-user-1")).toBeInTheDocument();
|
||||||
|
expect(textarea).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders inline edit without a go-to-top control for non-scroll-to-top consumers", () => {
|
it("renders inline edit without a go-to-top control for non-scroll-to-top consumers", () => {
|
||||||
rtlRender(
|
rtlRender(
|
||||||
<StandardChatMessageItem
|
<StandardChatMessageItem
|
||||||
|
|||||||
@@ -1644,9 +1644,9 @@ describe("TaskPlannerChatTab", () => {
|
|||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
await waitFor(() => expect(mockEditChatMessage).toHaveBeenCalledWith("chat-planner", "m1", "Hello, edited", undefined));
|
await waitFor(() => expect(mockEditChatMessage).toHaveBeenCalledWith("chat-planner", "m1", "Hello, edited", undefined));
|
||||||
// Optimistic truncation happens before the PATCH resolves: the edited row and its tail drop immediately.
|
// The edited row stays mounted until PATCH success so a rejected save can retain its correction.
|
||||||
await waitFor(() => expect(screen.queryByText("Hello")).not.toBeInTheDocument());
|
expect(screen.getByTestId("chat-message-edit-editor-m1")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Hi there")).not.toBeInTheDocument();
|
expect(screen.getByText("Hi there")).toBeInTheDocument();
|
||||||
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
||||||
|
|
||||||
deferredEdit.resolve({ retained: [] });
|
deferredEdit.resolve({ retained: [] });
|
||||||
@@ -1689,7 +1689,9 @@ describe("TaskPlannerChatTab", () => {
|
|||||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("edit failed", "error"));
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith("edit failed", "error"));
|
||||||
await waitFor(() => expect(mockFetchChatMessages).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(mockFetchChatMessages).toHaveBeenCalledTimes(2));
|
||||||
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
||||||
expect(await screen.findByText("Hello")).toBeInTheDocument();
|
// A rejected PATCH must leave the inline correction available for retry instead of closing it.
|
||||||
|
expect(screen.getByTestId("chat-message-edit-editor-m1")).toBeInTheDocument();
|
||||||
|
expect(textarea).toHaveValue("Hello, edited");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the edit affordance on an already-persisted message while a new generation is streaming", async () => {
|
it("hides the edit affordance on an already-persisted message while a new generation is streaming", async () => {
|
||||||
|
|||||||
@@ -505,9 +505,9 @@ describe("useChat", () => {
|
|||||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [m2, m1] });
|
mockFetchChatMessages.mockResolvedValueOnce({ messages: [m2, m1] });
|
||||||
mockStreamChatResponse.mockClear();
|
mockStreamChatResponse.mockClear();
|
||||||
|
|
||||||
await act(async () => {
|
await expect(act(async () => {
|
||||||
await result.current.editMessageAndResend("msg-1", "edited");
|
await result.current.editMessageAndResend("msg-1", "edited");
|
||||||
});
|
})).rejects.toThrow("boom");
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.messages.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
expect(result.current.messages.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
||||||
|
|||||||
@@ -1510,17 +1510,22 @@ export function useChat(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic truncation: drop the edited message and everything after it immediately.
|
|
||||||
setMessages(previousMessages.slice(0, targetIndex));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await editChatMessage(sessionId, messageId, trimmed, projectId);
|
await editChatMessage(sessionId, messageId, trimmed, projectId);
|
||||||
|
// Keep the editor's message mounted until the PATCH succeeds so a rejected save retains its correction.
|
||||||
|
setMessages(previousMessages.slice(0, targetIndex));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[useChat] Failed to edit message:", error);
|
console.error("[useChat] Failed to edit message:", error);
|
||||||
addToast?.("Failed to edit message", "error");
|
addToast?.("Failed to edit message", "error");
|
||||||
// Restore truthful state from the server rather than trusting the optimistic truncation.
|
// Restore truthful state from the server rather than trusting the optimistic truncation.
|
||||||
await loadMessages(sessionId);
|
await loadMessages(sessionId);
|
||||||
return;
|
/*
|
||||||
|
* FNXC:ChatMessageEdit 2026-07-19-00:00:
|
||||||
|
* The inline editor closes only when its async handler fulfills. Rethrow a failed PATCH
|
||||||
|
* after recovery so Direct Chat keeps the user's correction available instead of treating
|
||||||
|
* a toast-only failure as a successful save.
|
||||||
|
*/
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
|
const cacheKey = getChatMessagesCacheKey(projectId, sessionId);
|
||||||
|
|||||||
@@ -1318,6 +1318,7 @@
|
|||||||
"relativeTimeMinutes_one": "{{count}}m ago",
|
"relativeTimeMinutes_one": "{{count}}m ago",
|
||||||
"relativeTimeMinutes_other": "{{count}}m ago",
|
"relativeTimeMinutes_other": "{{count}}m ago",
|
||||||
"removeAttachment": "Remove {{name}}",
|
"removeAttachment": "Remove {{name}}",
|
||||||
|
"recent": "Recent",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"renameConversationAria": "Rename conversation {{title}}",
|
"renameConversationAria": "Rename conversation {{title}}",
|
||||||
"renameConversationBody": "Choose a new name for this conversation. Leave it blank to show Untitled.",
|
"renameConversationBody": "Choose a new name for this conversation. Leave it blank to show Untitled.",
|
||||||
|
|||||||
@@ -1306,6 +1306,7 @@
|
|||||||
"relativeTimeMinutes_one": "",
|
"relativeTimeMinutes_one": "",
|
||||||
"relativeTimeMinutes_other": "",
|
"relativeTimeMinutes_other": "",
|
||||||
"removeAttachment": "Eliminar {{name}}",
|
"removeAttachment": "Eliminar {{name}}",
|
||||||
|
"recent": "Reciente",
|
||||||
"rename": "",
|
"rename": "",
|
||||||
"renameConversationAria": "",
|
"renameConversationAria": "",
|
||||||
"renameConversationBody": "",
|
"renameConversationBody": "",
|
||||||
|
|||||||
@@ -1306,6 +1306,7 @@
|
|||||||
"relativeTimeMinutes_one": "",
|
"relativeTimeMinutes_one": "",
|
||||||
"relativeTimeMinutes_other": "",
|
"relativeTimeMinutes_other": "",
|
||||||
"removeAttachment": "Supprimer {{name}}",
|
"removeAttachment": "Supprimer {{name}}",
|
||||||
|
"recent": "Récent",
|
||||||
"rename": "",
|
"rename": "",
|
||||||
"renameConversationAria": "",
|
"renameConversationAria": "",
|
||||||
"renameConversationBody": "",
|
"renameConversationBody": "",
|
||||||
|
|||||||
@@ -1306,6 +1306,7 @@
|
|||||||
"relativeTimeMinutes_one": "",
|
"relativeTimeMinutes_one": "",
|
||||||
"relativeTimeMinutes_other": "",
|
"relativeTimeMinutes_other": "",
|
||||||
"removeAttachment": "{{name}} 첨부 파일 제거",
|
"removeAttachment": "{{name}} 첨부 파일 제거",
|
||||||
|
"recent": "최근",
|
||||||
"rename": "",
|
"rename": "",
|
||||||
"renameConversationAria": "",
|
"renameConversationAria": "",
|
||||||
"renameConversationBody": "",
|
"renameConversationBody": "",
|
||||||
|
|||||||
@@ -1306,6 +1306,7 @@
|
|||||||
"relativeTimeMinutes_one": "",
|
"relativeTimeMinutes_one": "",
|
||||||
"relativeTimeMinutes_other": "",
|
"relativeTimeMinutes_other": "",
|
||||||
"removeAttachment": "移除 {{name}}",
|
"removeAttachment": "移除 {{name}}",
|
||||||
|
"recent": "最近",
|
||||||
"rename": "",
|
"rename": "",
|
||||||
"renameConversationAria": "",
|
"renameConversationAria": "",
|
||||||
"renameConversationBody": "",
|
"renameConversationBody": "",
|
||||||
|
|||||||
@@ -1306,6 +1306,7 @@
|
|||||||
"relativeTimeMinutes_one": "",
|
"relativeTimeMinutes_one": "",
|
||||||
"relativeTimeMinutes_other": "",
|
"relativeTimeMinutes_other": "",
|
||||||
"removeAttachment": "移除 {{name}}",
|
"removeAttachment": "移除 {{name}}",
|
||||||
|
"recent": "最近",
|
||||||
"rename": "",
|
"rename": "",
|
||||||
"renameConversationAria": "",
|
"renameConversationAria": "",
|
||||||
"renameConversationBody": "",
|
"renameConversationBody": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user