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.
|
||||
- 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. -->
|
||||
- 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: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.
|
||||
@@ -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. -->
|
||||
- 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.
|
||||
- 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. -->
|
||||
- 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. -->
|
||||
|
||||
@@ -422,6 +422,16 @@ FN-8173 replaces the inline Pin/Rename/Delete cluster with one overflow trigger.
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3107,8 +3107,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
</button>
|
||||
{mobileSessionMenuOpen && (
|
||||
<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
|
||||
key={session.id}
|
||||
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} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
{/*
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
{/* 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}
|
||||
{filteredSessions.map((session) => {
|
||||
{/*
|
||||
FNXC:ChatPinned 2026-07-19-00:00:
|
||||
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 showUnreadDot = !isActive && isUnread("direct", session.id, session.lastMessageAt ?? session.updatedAt);
|
||||
const sessionResolvedModel = resolveSessionProvider(
|
||||
@@ -3397,7 +3414,9 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -536,6 +536,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
||||
*/
|
||||
const showEditAction = isUserMessage && canEdit && Boolean(onEditMessage);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [editedText, setEditedText] = useState(message.content);
|
||||
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -545,16 +546,31 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
||||
}, [message.content]);
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
if (isSavingEdit) return;
|
||||
setIsEditing(false);
|
||||
setEditedText(message.content);
|
||||
}, [message.content]);
|
||||
}, [isSavingEdit, message.content]);
|
||||
|
||||
const saveEdit = useCallback(() => {
|
||||
const saveEdit = useCallback(async () => {
|
||||
const trimmed = editedText.trim();
|
||||
if (!trimmed || trimmed === message.content) return;
|
||||
setIsEditing(false);
|
||||
void onEditMessage?.(message.id, trimmed);
|
||||
}, [editedText, message.content, message.id, onEditMessage]);
|
||||
if (!trimmed || trimmed === message.content.trim() || !onEditMessage || isSavingEdit) return;
|
||||
|
||||
/*
|
||||
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(() => {
|
||||
if (isEditing) {
|
||||
@@ -650,6 +666,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
||||
ref={editTextareaRef}
|
||||
className="input chat-message-edit-textarea"
|
||||
value={editedText}
|
||||
disabled={isSavingEdit}
|
||||
onChange={(event) => setEditedText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
@@ -657,14 +674,14 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
||||
cancelEditing();
|
||||
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
saveEdit();
|
||||
void saveEdit();
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
<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 btn-primary" disabled={!editedText.trim() || editedText.trim() === message.content} onClick={saveEdit}>{t("chat.editMessageSave", "Save")}</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" 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>
|
||||
) : (
|
||||
|
||||
@@ -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 {
|
||||
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) {
|
||||
const message = getErrorMessage(err) || t("taskDetail.plannerChat.editFailed", "Failed to edit planner chat message");
|
||||
setError(message);
|
||||
addToastRef.current(message, "error");
|
||||
// Restore truthful state from the server rather than trusting the optimistic truncation.
|
||||
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);
|
||||
|
||||
@@ -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", () => {
|
||||
it("renders one overflow button on each session item without an inline action cluster", async () => {
|
||||
setupMockChat({
|
||||
|
||||
@@ -7,7 +7,7 @@ streaming. Also covers the inline editor save/cancel interaction and the
|
||||
editMessageAndResend wiring.
|
||||
*/
|
||||
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 { StandardChatMessageItem } from "../StandardChatSurface";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
@@ -227,11 +227,35 @@ describe("ChatView message edit affordance", () => {
|
||||
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe("hello");
|
||||
|
||||
expect(screen.getByTestId("chat-message-edit-save-user-1")).toBeDisabled();
|
||||
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(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 () => {
|
||||
@@ -250,13 +274,41 @@ describe("ChatView message edit affordance", () => {
|
||||
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
|
||||
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(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
|
||||
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", () => {
|
||||
rtlRender(
|
||||
<StandardChatMessageItem
|
||||
|
||||
@@ -1644,9 +1644,9 @@ describe("TaskPlannerChatTab", () => {
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
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.
|
||||
await waitFor(() => expect(screen.queryByText("Hello")).not.toBeInTheDocument());
|
||||
expect(screen.queryByText("Hi there")).not.toBeInTheDocument();
|
||||
// The edited row stays mounted until PATCH success so a rejected save can retain its correction.
|
||||
expect(screen.getByTestId("chat-message-edit-editor-m1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hi there")).toBeInTheDocument();
|
||||
expect(mockStreamChatResponse).not.toHaveBeenCalled();
|
||||
|
||||
deferredEdit.resolve({ retained: [] });
|
||||
@@ -1689,7 +1689,9 @@ describe("TaskPlannerChatTab", () => {
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("edit failed", "error"));
|
||||
await waitFor(() => expect(mockFetchChatMessages).toHaveBeenCalledTimes(2));
|
||||
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 () => {
|
||||
|
||||
@@ -505,9 +505,9 @@ describe("useChat", () => {
|
||||
mockFetchChatMessages.mockResolvedValueOnce({ messages: [m2, m1] });
|
||||
mockStreamChatResponse.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await expect(act(async () => {
|
||||
await result.current.editMessageAndResend("msg-1", "edited");
|
||||
});
|
||||
})).rejects.toThrow("boom");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
||||
|
||||
@@ -1510,17 +1510,22 @@ export function useChat(
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic truncation: drop the edited message and everything after it immediately.
|
||||
setMessages(previousMessages.slice(0, targetIndex));
|
||||
|
||||
try {
|
||||
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) {
|
||||
console.error("[useChat] Failed to edit message:", error);
|
||||
addToast?.("Failed to edit message", "error");
|
||||
// Restore truthful state from the server rather than trusting the optimistic truncation.
|
||||
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);
|
||||
|
||||
@@ -1318,6 +1318,7 @@
|
||||
"relativeTimeMinutes_one": "{{count}}m ago",
|
||||
"relativeTimeMinutes_other": "{{count}}m ago",
|
||||
"removeAttachment": "Remove {{name}}",
|
||||
"recent": "Recent",
|
||||
"rename": "Rename",
|
||||
"renameConversationAria": "Rename conversation {{title}}",
|
||||
"renameConversationBody": "Choose a new name for this conversation. Leave it blank to show Untitled.",
|
||||
|
||||
@@ -1306,6 +1306,7 @@
|
||||
"relativeTimeMinutes_one": "",
|
||||
"relativeTimeMinutes_other": "",
|
||||
"removeAttachment": "Eliminar {{name}}",
|
||||
"recent": "Reciente",
|
||||
"rename": "",
|
||||
"renameConversationAria": "",
|
||||
"renameConversationBody": "",
|
||||
|
||||
@@ -1306,6 +1306,7 @@
|
||||
"relativeTimeMinutes_one": "",
|
||||
"relativeTimeMinutes_other": "",
|
||||
"removeAttachment": "Supprimer {{name}}",
|
||||
"recent": "Récent",
|
||||
"rename": "",
|
||||
"renameConversationAria": "",
|
||||
"renameConversationBody": "",
|
||||
|
||||
@@ -1306,6 +1306,7 @@
|
||||
"relativeTimeMinutes_one": "",
|
||||
"relativeTimeMinutes_other": "",
|
||||
"removeAttachment": "{{name}} 첨부 파일 제거",
|
||||
"recent": "최근",
|
||||
"rename": "",
|
||||
"renameConversationAria": "",
|
||||
"renameConversationBody": "",
|
||||
|
||||
@@ -1306,6 +1306,7 @@
|
||||
"relativeTimeMinutes_one": "",
|
||||
"relativeTimeMinutes_other": "",
|
||||
"removeAttachment": "移除 {{name}}",
|
||||
"recent": "最近",
|
||||
"rename": "",
|
||||
"renameConversationAria": "",
|
||||
"renameConversationBody": "",
|
||||
|
||||
@@ -1306,6 +1306,7 @@
|
||||
"relativeTimeMinutes_one": "",
|
||||
"relativeTimeMinutes_other": "",
|
||||
"removeAttachment": "移除 {{name}}",
|
||||
"recent": "最近",
|
||||
"rename": "",
|
||||
"renameConversationAria": "",
|
||||
"renameConversationBody": "",
|
||||
|
||||
Reference in New Issue
Block a user