From e22c65ad11e50e0975c23c536c96e89d2a12d3ad Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 00:40:36 -0700 Subject: [PATCH] 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) --- ...-8397-chat-pin-separation-and-edit-save.md | 7 +++ docs/dashboard-guide.md | 5 +- .../dashboard/app/components/ChatView.css | 10 ++++ .../dashboard/app/components/ChatView.tsx | 31 ++++++++-- .../app/components/StandardChatSurface.tsx | 35 ++++++++--- .../app/components/TaskPlannerChatTab.tsx | 14 +++-- .../ChatView.core-contracts.test.tsx | 46 ++++++++++++++ .../__tests__/ChatView.message-edit.test.tsx | 60 +++++++++++++++++-- .../__tests__/TaskPlannerChatTab.test.tsx | 10 ++-- .../app/hooks/__tests__/useChat.test.ts | 4 +- packages/dashboard/app/hooks/useChat.ts | 13 ++-- packages/i18n/locales/en/app.json | 1 + packages/i18n/locales/es/app.json | 1 + packages/i18n/locales/fr/app.json | 1 + packages/i18n/locales/ko/app.json | 1 + packages/i18n/locales/zh-CN/app.json | 1 + packages/i18n/locales/zh-TW/app.json | 1 + 17 files changed, 205 insertions(+), 36 deletions(-) create mode 100644 .changeset/fn-8397-chat-pin-separation-and-edit-save.md diff --git a/.changeset/fn-8397-chat-pin-separation-and-edit-save.md b/.changeset/fn-8397-chat-pin-separation-and-edit-save.md new file mode 100644 index 0000000000..593b6b660e --- /dev/null +++ b/.changeset/fn-8397-chat-pin-separation-and-edit-save.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 37d82fba58..b369af475b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. -- 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. + +- 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. - 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. - 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:` 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. - 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. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 717ce9e794..1daf67a961 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -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; diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index acadd3505c..599a7765bf 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -3107,8 +3107,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout {mobileSessionMenuOpen && (
- {pinnedFilteredSessions.length > 0 ?
{t("chat.pinned", "Pinned")}
: 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) => ( +
+
{group.label}
+ {group.sessions.map((session) => (
+ ))} +
))} {/* FNXC:Chat 2026-06-27-00:00: @@ -3319,9 +3326,19 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
{t("chat.noConversationsYet", "No conversations yet")}
) : ( <> - {/* 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 ?
{t("chat.pinned", "Pinned")}
: 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) => ( +
+
{group.label}
+ {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
); - })} + })} + + ))} )} diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx index 12e01cf092..795368ab6c 100644 --- a/packages/dashboard/app/components/StandardChatSurface.tsx +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -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(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} />
- - + +
) : ( diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index ff88d9985b..10b8692e2a 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -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); diff --git a/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx index ecd0ad8bc8..dc6fabb072 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.core-contracts.test.tsx @@ -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(); + + 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(); + }); + 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(); + 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({ diff --git a/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx index 533ee9a18e..b5a8178723 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( { 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 () => { diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 305df82ae1..c5ff4f6251 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -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"]); diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 4792a11134..cab4847d99 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -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); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index a03e69d09d..2dc74c374d 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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.", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 8272a96a03..52d34d5159 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -1306,6 +1306,7 @@ "relativeTimeMinutes_one": "", "relativeTimeMinutes_other": "", "removeAttachment": "Eliminar {{name}}", + "recent": "Reciente", "rename": "", "renameConversationAria": "", "renameConversationBody": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index e0aa01f95b..e70f49c4ea 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -1306,6 +1306,7 @@ "relativeTimeMinutes_one": "", "relativeTimeMinutes_other": "", "removeAttachment": "Supprimer {{name}}", + "recent": "Récent", "rename": "", "renameConversationAria": "", "renameConversationBody": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 29d99bdc32..dc45209c9b 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -1306,6 +1306,7 @@ "relativeTimeMinutes_one": "", "relativeTimeMinutes_other": "", "removeAttachment": "{{name}} 첨부 파일 제거", + "recent": "최근", "rename": "", "renameConversationAria": "", "renameConversationBody": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 62766bffe3..71899644ec 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -1306,6 +1306,7 @@ "relativeTimeMinutes_one": "", "relativeTimeMinutes_other": "", "removeAttachment": "移除 {{name}}", + "recent": "最近", "rename": "", "renameConversationAria": "", "renameConversationBody": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 96686188e9..26e5e3ba3e 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -1306,6 +1306,7 @@ "relativeTimeMinutes_one": "", "relativeTimeMinutes_other": "", "removeAttachment": "移除 {{name}}", + "recent": "最近", "rename": "", "renameConversationAria": "", "renameConversationBody": "",