diff --git a/.changeset/fn-7628-chat-message-edit.md b/.changeset/fn-7628-chat-message-edit.md new file mode 100644 index 0000000000..7b7dc2fa39 --- /dev/null +++ b/.changeset/fn-7628-chat-message-edit.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Edit a chat message and resume the conversation from that point. +category: feature +dev: Adds ChatStore.deleteMessagesFrom + PATCH /api/chat/sessions/:id/messages/:messageId; rewinds the pi SessionManager (createBranchedSession) so the model forgets discarded turns. Direct model-loop chats only. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 57f5033a68..02de52a151 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -505,6 +505,10 @@ Chat view provides project-scoped conversations with agents. - Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools. - Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model. - Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked. + +- Your own messages in a **direct (model-loop) chat** can be edited: hover/tap a user message and use the **Edit message** (pencil) action to swap it for an inline textarea, then **Save** (or Cmd/Ctrl+Enter) or **Cancel** (or Escape). Saving an edit **resumes the conversation from that point** — the edited turn and every turn after it are discarded from both the visible transcript and the model's memory, so the agent responds fresh from the edited content with no bias from what was removed. This is the only way to correct or steer an earlier turn without leaving a stale, misleading message in the thread. +- Message editing applies to direct/model-loop chat sessions only. 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. +- 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. ![Chat view](./screenshots/chat-view.png) diff --git a/packages/core/src/__tests__/chat-store.test.ts b/packages/core/src/__tests__/chat-store.test.ts index bd6f212409..ac76ba72bb 100644 --- a/packages/core/src/__tests__/chat-store.test.ts +++ b/packages/core/src/__tests__/chat-store.test.ts @@ -905,6 +905,177 @@ describe("ChatStore", () => { ); }); }); + + describe("deleteMessagesFrom", () => { + it("deletes a middle message and everything after it, retaining the earlier tail", () => { + const session = createTestSession(store); + const m1 = store.addMessage(session.id, { role: "user", content: "one" }); + const m2 = store.addMessage(session.id, { role: "assistant", content: "two" }); + const m3 = store.addMessage(session.id, { role: "user", content: "three" }); + const m4 = store.addMessage(session.id, { role: "assistant", content: "four" }); + + const result = store.deleteMessagesFrom(session.id, m3.id); + + expect(result.deletedIds.sort()).toEqual([m3.id, m4.id].sort()); + expect(result.retained.map((m) => m.id)).toEqual([m1.id, m2.id]); + + const remaining = store.getMessages(session.id); + expect(remaining.map((m) => m.id)).toEqual([m1.id, m2.id]); + }); + + it("deletes only itself when the target is the last message", () => { + const session = createTestSession(store); + const m1 = store.addMessage(session.id, { role: "user", content: "one" }); + const m2 = store.addMessage(session.id, { role: "assistant", content: "two" }); + + const result = store.deleteMessagesFrom(session.id, m2.id); + + expect(result.deletedIds).toEqual([m2.id]); + expect(result.retained.map((m) => m.id)).toEqual([m1.id]); + expect(store.getMessages(session.id).map((m) => m.id)).toEqual([m1.id]); + }); + + it("deletes everything when the target is the first message", () => { + const session = createTestSession(store); + const m1 = store.addMessage(session.id, { role: "user", content: "one" }); + const m2 = store.addMessage(session.id, { role: "assistant", content: "two" }); + + const result = store.deleteMessagesFrom(session.id, m1.id); + + expect(result.deletedIds.sort()).toEqual([m1.id, m2.id].sort()); + expect(result.retained).toEqual([]); + expect(store.getMessages(session.id)).toEqual([]); + }); + + it("is a no-op (no events) for a non-existent message id", () => { + const session = createTestSession(store); + store.addMessage(session.id, { role: "user", content: "one" }); + + const deletedListener = vi.fn(); + const updatedListener = vi.fn(); + store.on("chat:message:deleted", deletedListener); + store.on("chat:session:updated", updatedListener); + + const result = store.deleteMessagesFrom(session.id, "msg-nonexistent"); + + expect(result.deletedIds).toEqual([]); + expect(deletedListener).not.toHaveBeenCalled(); + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it("is a no-op (no events) when the message belongs to a different session", () => { + const session1 = createTestSession(store); + const session2 = createTestSession(store); + const otherMsg = store.addMessage(session2.id, { role: "user", content: "elsewhere" }); + store.addMessage(session1.id, { role: "user", content: "here" }); + + const deletedListener = vi.fn(); + store.on("chat:message:deleted", deletedListener); + + const result = store.deleteMessagesFrom(session1.id, otherMsg.id); + + expect(result.deletedIds).toEqual([]); + expect(deletedListener).not.toHaveBeenCalled(); + expect(store.getMessages(session2.id)).toHaveLength(1); + }); + + it("tie-breaks deterministically when messages share an identical createdAt", () => { + startFakeClock(); + const session = createTestSession(store); + // All four messages inserted at the exact same timestamp (fake clock frozen). + const m1 = store.addMessage(session.id, { role: "user", content: "one" }); + const m2 = store.addMessage(session.id, { role: "assistant", content: "two" }); + const m3 = store.addMessage(session.id, { role: "user", content: "three" }); + const m4 = store.addMessage(session.id, { role: "assistant", content: "four" }); + + expect(new Set([m1.createdAt, m2.createdAt, m3.createdAt, m4.createdAt]).size).toBe(1); + + const result = store.deleteMessagesFrom(session.id, m3.id); + + // Insertion-order (rowid) tiebreak must still put m3/m4 after m1/m2, deleting exactly the tail. + expect(result.retained.map((m) => m.id)).toEqual([m1.id, m2.id]); + expect(result.deletedIds.sort()).toEqual([m3.id, m4.id].sort()); + }); + + it("emits chat:message:deleted per removed id and exactly one chat:session:updated", () => { + const session = createTestSession(store); + const m1 = store.addMessage(session.id, { role: "user", content: "one" }); + const m2 = store.addMessage(session.id, { role: "assistant", content: "two" }); + const m3 = store.addMessage(session.id, { role: "user", content: "three" }); + + const deletedListener = vi.fn(); + const updatedListener = vi.fn(); + store.on("chat:message:deleted", deletedListener); + store.on("chat:session:updated", updatedListener); + + store.deleteMessagesFrom(session.id, m2.id); + + expect(deletedListener).toHaveBeenCalledTimes(2); + expect(deletedListener.mock.calls.map((c) => c[0]).sort()).toEqual([m2.id, m3.id].sort()); + expect(updatedListener).toHaveBeenCalledTimes(1); + void m1; + }); + + it("bumps the parent session's updatedAt", () => { + startFakeClock(); + const session = createTestSession(store); + store.addMessage(session.id, { role: "user", content: "one" }); + const beforeUpdatedAt = store.getSession(session.id)!.updatedAt; + + advanceClock(10); + const m2 = store.addMessage(session.id, { role: "assistant", content: "two" }); + + advanceClock(10); + store.deleteMessagesFrom(session.id, m2.id); + + const afterUpdatedAt = store.getSession(session.id)!.updatedAt; + expect(new Date(afterUpdatedAt).getTime()).toBeGreaterThan(new Date(beforeUpdatedAt).getTime()); + }); + }); + + describe("updateMessageMetadata", () => { + it("merges new metadata onto existing metadata by default", () => { + const session = createTestSession(store); + const message = store.addMessage(session.id, { + role: "user", + content: "hi", + metadata: { mentions: ["agent-1"] }, + }); + + const updated = store.updateMessageMetadata(message.id, { piParentLeafId: "leaf-1" }); + + expect(updated.metadata).toEqual({ mentions: ["agent-1"], piParentLeafId: "leaf-1" }); + }); + + it("replaces metadata wholesale when merge=false", () => { + const session = createTestSession(store); + const message = store.addMessage(session.id, { + role: "user", + content: "hi", + metadata: { mentions: ["agent-1"] }, + }); + + const updated = store.updateMessageMetadata(message.id, { piParentLeafId: "leaf-1" }, { merge: false }); + + expect(updated.metadata).toEqual({ piParentLeafId: "leaf-1" }); + }); + + it("emits chat:message:updated", () => { + const session = createTestSession(store); + const message = store.addMessage(session.id, { role: "user", content: "hi" }); + + const listener = vi.fn(); + store.on("chat:message:updated", listener); + + store.updateMessageMetadata(message.id, { piParentLeafId: null }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("throws for a non-existent message id", () => { + expect(() => store.updateMessageMetadata("msg-nonexistent", { a: 1 })).toThrow("not found"); + }); + }); }); // ── Room CRUD Tests ─────────────────────────────────────────── diff --git a/packages/core/src/chat-store.ts b/packages/core/src/chat-store.ts index b7096ad178..1e4d59a1bd 100644 --- a/packages/core/src/chat-store.ts +++ b/packages/core/src/chat-store.ts @@ -772,6 +772,101 @@ export class ChatStore extends EventEmitter { return true; } + /** + * FNXC:ChatMessageEdit 2026-07-07-09:00: + * Truncate a chat session from (and including) a target message onward. Editing an earlier + * user turn must "forget" that turn and every turn after it — both from the persisted + * transcript here AND from the model's resumable pi session context (rewound separately by + * ChatManager.rewindSessionForEdit) — so future responses are not biased by discarded turns. + * + * Ordering is resolved by (createdAt ASC, rowid ASC) rather than createdAt alone, since + * multiple messages can share an identical createdAt timestamp (same-millisecond inserts); + * rowid is SQLite's implicit monotonic insertion-order tiebreaker, guaranteeing the edited + * message and every later message (in true insertion order) are always included, with no + * sibling straggler surviving the truncation. + * + * @param sessionId - Parent session ID + * @param fromMessageId - Id of the earliest message to delete (inclusive) + * @returns deletedIds (in ASC order) and retained messages (pre-edit history, ASC order) + */ + deleteMessagesFrom(sessionId: string, fromMessageId: string): { deletedIds: string[]; retained: ChatMessage[] } { + const target = this.db.prepare( + "SELECT id, sessionId, rowid as rowid_ FROM chat_messages WHERE id = ?", + ).get(fromMessageId) as { id: string; sessionId: string; rowid_: number } | undefined; + + if (!target || target.sessionId !== sessionId) { + return { deletedIds: [], retained: this.getMessages(sessionId) }; + } + + // Ordered id list for the session (createdAt ASC, rowid ASC tiebreak) so we can + // deterministically split retained-vs-deleted around the target message. + const orderedRows = this.db.prepare( + "SELECT id, rowid as rowid_ FROM chat_messages WHERE sessionId = ? ORDER BY createdAt ASC, rowid_ ASC", + ).all(sessionId) as { id: string; rowid_: number }[]; + + const targetIndex = orderedRows.findIndex((row) => row.id === fromMessageId); + if (targetIndex === -1) { + return { deletedIds: [], retained: this.getMessages(sessionId) }; + } + + const retainedIds = orderedRows.slice(0, targetIndex).map((row) => row.id); + const deletedIds = orderedRows.slice(targetIndex).map((row) => row.id); + + const retained = retainedIds + .map((id) => this.getMessage(id)) + .filter((message): message is ChatMessage => Boolean(message)); + + if (deletedIds.length === 0) { + return { deletedIds: [], retained }; + } + + const now = new Date().toISOString(); + const placeholders = deletedIds.map(() => "?").join(", "); + this.db.prepare(`DELETE FROM chat_messages WHERE id IN (${placeholders})`).run(...deletedIds); + this.db.prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(now, sessionId); + this.db.bumpLastModified(); + + for (const id of deletedIds) { + this.emit("chat:message:deleted", id); + } + const updatedSession = this.getSession(sessionId); + if (updatedSession) { + this.emit("chat:session:updated", updatedSession); + } + + return { deletedIds, retained }; + } + + /** + * FNXC:ChatMessageEdit 2026-07-07-09:00: + * Merge (default) or replace a persisted message's metadata. Used by the model-loop generation + * path to record the pi SessionManager parent-leaf id (`metadata.piParentLeafId`) onto the + * just-created user message, without disturbing other metadata (e.g. `mentions`). This linkage + * is what lets a later edit rewind losslessly via SessionManager.branch()/resetLeaf(). + */ + updateMessageMetadata(messageId: string, metadata: Record | null, options?: { merge?: boolean }): ChatMessage { + const existing = this.getMessage(messageId); + if (!existing) { + throw new Error(`Message ${messageId} not found`); + } + + const merge = options?.merge !== false; + const nextMetadata = metadata === null + ? (merge ? existing.metadata : null) + : (merge ? { ...(existing.metadata ?? {}), ...metadata } : metadata); + + this.db.prepare("UPDATE chat_messages SET metadata = ? WHERE id = ?").run(toJsonNullable(nextMetadata), messageId); + + const updated = this.getMessage(messageId); + if (!updated) { + throw new Error(`Failed to update message ${messageId}`); + } + + this.db.bumpLastModified(); + this.emit("chat:message:updated", updated); + return updated; + } + createRoom(input: ChatRoomCreateInput & { memberAgentIds?: string[] }): ChatRoom { const normalizedName = this.normalizeRoomName(input.name); if (!normalizedName) throw new Error("Room name cannot be empty"); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 92cf9f9955..5318845f18 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -10188,6 +10188,28 @@ export function deleteChatMessage( ); } +/** + * FNXC:ChatMessageEdit 2026-07-07-09:00: + * Edit an earlier user message in a direct (model-loop) chat session. Truncates the persisted + * transcript from (and including) the target message onward AND rewinds the pi session context + * server-side, so the returned `retained` list is the surviving pre-edit history. Does NOT + * trigger regeneration — the caller resends the edited content via the existing streaming send. + */ +export function editChatMessage( + sessionId: string, + messageId: string, + content: string, + projectId?: string, +): Promise<{ retained: ChatMessage[] }> { + return api<{ retained: ChatMessage[] }>( + withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, projectId), + { + method: "PATCH", + body: JSON.stringify({ content }), + }, + ); +} + export function fetchChatRooms( options: { status?: string; agentId?: string } = {}, projectId?: string, diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 820d77d008..f19a90e208 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -1326,6 +1326,84 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures color: var(--color-error); } +/* +FNXC:ChatMessageEdit 2026-07-07-09:00: +User messages in direct (model-loop) chat can carry an inline edit affordance mirroring the +assistant copy/scroll-to-top action row pattern above, plus an inline textarea editor. Editing +an earlier message resumes the conversation from that point, forgetting everything after it, so +keep the affordance visually consistent with existing chat-message-actions styling rather than a +one-off treatment. Rendered only for user messages on direct/model-loop sessions (never rooms, +CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEditAction`. +*/ +.chat-message-actions--user { + justify-content: flex-start; +} + +.chat-message-edit-action { + display: flex; + align-items: center; + justify-content: center; + width: calc(var(--space-lg) * 2); + height: calc(var(--space-lg) * 2); + min-width: calc(var(--space-lg) * 2); + min-height: calc(var(--space-lg) * 2); + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent-text) 15%, transparent); + color: var(--accent-text); + cursor: pointer; + opacity: 0.85; + transition: opacity var(--transition-fast), background var(--transition-fast); +} + +.chat-message-edit-action:hover { + opacity: 1; + background: color-mix(in srgb, var(--accent-text) 25%, transparent); +} + +.chat-message-edit-action:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); + opacity: 1; +} + +.chat-message-edit-editor { + display: flex; + flex-direction: column; + gap: var(--space-xs); + width: 100%; +} + +.chat-message-edit-textarea { + width: 100%; + resize: vertical; + min-height: calc(var(--space-lg) * 4); + font: inherit; + color: var(--text); + background: var(--surface); +} + +.chat-message-edit-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-xs); +} + +@media (max-width: 768px) { + .chat-message-edit-action { + width: calc(var(--space-lg) * 2.25); + height: calc(var(--space-lg) * 2.25); + min-width: calc(var(--space-lg) * 2.25); + min-height: calc(var(--space-lg) * 2.25); + } + + .chat-message-edit-textarea { + min-height: calc(var(--space-lg) * 5); + } +} + .chat-message-content--markdown > :first-child { margin-top: 0; } diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index 175803a404..c8edcf528e 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -486,6 +486,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout renameSession, deleteSession, sendMessage, + editMessageAndResend, stopStreaming, pendingMessages, clearPendingMessage, @@ -2311,6 +2312,15 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout // Terminal attach id: the native session linkage when known, else the chat id. const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || ""; + /* + * FNXC:ChatMessageEdit 2026-07-07-09:00: + * Editing is supported only for direct (model-loop) chat sessions: never CLI-agent-backed + * sessions (a live PTY owns the transcript, not a rewindable pi session), and never while a + * generation is streaming (an edit cannot race a live send). Rooms don't route through this + * pane at all, so no additional gate is needed here for that surface. + */ + const canEditChatMessages = !cliChatActive && !isStreaming; + // The session message pane and composer, captured once so both the normal // provider path and the CLI-backed path (CliChatSurface thunks) render the // exact same JSX — no parallel message/composer UI. @@ -2341,6 +2351,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming} submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)} onQuestionSubmit={handleQuestionSubmit} + canEdit={canEditChatMessages} + onEditMessage={editMessageAndResend} /> ))} ))} diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx index 7d3a6faddc..7480a372ac 100644 --- a/packages/dashboard/app/components/StandardChatSurface.tsx +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -1,9 +1,9 @@ import type { Agent } from "@fusion/core"; -import React, { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import { ArrowUpToLine, Bot, File, Send, TriangleAlert } from "lucide-react"; +import { ArrowUpToLine, Bot, File, Pencil, Send, TriangleAlert } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ChatMessageInfo, FailureInfo, ToolCallInfo } from "../hooks/chatTypes"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; @@ -33,6 +33,21 @@ export interface StandardChatMessageItemProps { submittedQuestionAnswer?: string; onQuestionSubmit?: (answerText: string, structured: Record) => void; toolCallRenderer?: (toolCall: ToolCallInfo, index: number) => ReactNode | undefined; + /** + * FNXC:ChatMessageEdit 2026-07-07-09:00: + * When set together with `canEdit`, a user message renders an edit affordance that swaps its + * content for an inline textarea. Saving calls this with the edited text; the caller is + * responsible for truncating server + local history from this message onward and resending + * (see useChat.editMessageAndResend) so the model forgets everything after the edited turn. + * Only rendered for `role === "user"` messages — never for assistant/system messages. + */ + onEditMessage?: (messageId: string, newContent: string) => void | Promise; + /** + * Gate for whether editing is currently supported/allowed for this message's surface (direct + * model-loop chat, not Rooms or CLI-agent sessions) and state (not while streaming). When + * false or `onEditMessage` is absent, no affordance renders at all — never a disabled/dead one. + */ + canEdit?: boolean; } export interface StandardStreamingMessageProps { @@ -325,9 +340,47 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ submittedQuestionAnswer, onQuestionSubmit, toolCallRenderer, + onEditMessage, + canEdit = false, }: StandardChatMessageItemProps) { const { t } = useTranslation("app"); const isAssistantMessage = message.role === "assistant"; + const isUserMessage = message.role === "user"; + /* + * FNXC:ChatMessageEdit 2026-07-07-09:00: + * Edit affordance is scoped strictly to user messages on surfaces that opt in via both + * `canEdit` and `onEditMessage`; absent either, `showEditAction` is false and nothing renders + * (no dead button, no empty shell) — e.g. assistant/system messages, Rooms, CLI-agent chat, or + * while a generation is streaming. + */ + const showEditAction = isUserMessage && canEdit && Boolean(onEditMessage); + const [isEditing, setIsEditing] = useState(false); + const [editedText, setEditedText] = useState(message.content); + const editTextareaRef = useRef(null); + + const startEditing = useCallback(() => { + setEditedText(message.content); + setIsEditing(true); + }, [message.content]); + + const cancelEditing = useCallback(() => { + setIsEditing(false); + setEditedText(message.content); + }, [message.content]); + + const saveEdit = useCallback(() => { + const trimmed = editedText.trim(); + if (!trimmed || trimmed === message.content) return; + setIsEditing(false); + void onEditMessage?.(message.id, trimmed); + }, [editedText, message.content, message.id, onEditMessage]); + + useEffect(() => { + if (isEditing) { + editTextareaRef.current?.focus(); + editTextareaRef.current?.select(); + } + }, [isEditing]); const failureInfo = isAssistantMessage ? message.failureInfo : undefined; const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo)); const renderedUserContent = useMemo(() => { @@ -377,10 +430,36 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ return renderStandardAssistantContent(message.content, forcePlain); }, [failureInfo, forcePlain, isAssistantMessage, message.content, t]); return ( -
+
{showAssistantIdentity &&
{activeModelProvider ? : }{agentName}{showAssistantModelTag && activeModelTag && {activeModelTag}}
} - {isAssistantMessage ? assistantBody :
{renderedUserContent}
} + {isEditing ? ( +
+