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:
gsxdsm
2026-07-20 00:40:36 -07:00
parent cb1acc1590
commit e22c65ad11
17 changed files with 205 additions and 36 deletions

View 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.

View File

@@ -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. -->

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
) : (

View File

@@ -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);

View File

@@ -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({

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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"]);

View File

@@ -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);

View File

@@ -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.",

View File

@@ -1306,6 +1306,7 @@
"relativeTimeMinutes_one": "",
"relativeTimeMinutes_other": "",
"removeAttachment": "Eliminar {{name}}",
"recent": "Reciente",
"rename": "",
"renameConversationAria": "",
"renameConversationBody": "",

View File

@@ -1306,6 +1306,7 @@
"relativeTimeMinutes_one": "",
"relativeTimeMinutes_other": "",
"removeAttachment": "Supprimer {{name}}",
"recent": "Récent",
"rename": "",
"renameConversationAria": "",
"renameConversationBody": "",

View File

@@ -1306,6 +1306,7 @@
"relativeTimeMinutes_one": "",
"relativeTimeMinutes_other": "",
"removeAttachment": "{{name}} 첨부 파일 제거",
"recent": "최근",
"rename": "",
"renameConversationAria": "",
"renameConversationBody": "",

View File

@@ -1306,6 +1306,7 @@
"relativeTimeMinutes_one": "",
"relativeTimeMinutes_other": "",
"removeAttachment": "移除 {{name}}",
"recent": "最近",
"rename": "",
"renameConversationAria": "",
"renameConversationBody": "",

View File

@@ -1306,6 +1306,7 @@
"relativeTimeMinutes_one": "",
"relativeTimeMinutes_other": "",
"removeAttachment": "移除 {{name}}",
"recent": "最近",
"rename": "",
"renameConversationAria": "",
"renameConversationBody": "",