From 9596cd11e895d44e908fca2d9655f224c10ca93d Mon Sep 17 00:00:00 2001 From: zhangshuo Date: Fri, 4 Sep 2026 14:38:37 +0800 Subject: [PATCH] feat(chat): edit user messages inline --- app/api/sessions/[id]/context/route.ts | 7 +- app/api/sessions/context-route.test.mjs | 8 +- components/ChatWindow.tsx | 49 +++-- components/MessageView.test.mjs | 42 ++++ components/MessageView.tsx | 242 ++++++++++++++++-------- hooks/useAgentSession.test.mjs | 16 +- hooks/useAgentSession.ts | 48 ++++- lib/i18n/messages/en.ts | 2 + lib/i18n/messages/zh-CN.ts | 2 + lib/i18n/messages/zh-TW.ts | 2 + lib/rpc-manager.test.mjs | 11 ++ lib/rpc-manager.ts | 5 +- 12 files changed, 329 insertions(+), 105 deletions(-) diff --git a/app/api/sessions/[id]/context/route.ts b/app/api/sessions/[id]/context/route.ts index 1f048e2695..ffd2fa010f 100644 --- a/app/api/sessions/[id]/context/route.ts +++ b/app/api/sessions/[id]/context/route.ts @@ -9,7 +9,9 @@ export async function GET( ) { const { id } = await params; const url = new URL(req.url); - const leafId = url.searchParams.get("leafId") ?? undefined; + const leafId = url.searchParams.has("leafId") + ? url.searchParams.get("leafId") || null + : undefined; const deferThinking = url.searchParams.has("deferThinking"); const deferToolResultImages = url.searchParams.has("deferMedia"); // `tail` caps the ancestor chain returned (default 50); `before` rewinds the @@ -30,7 +32,8 @@ export async function GET( const sm = liveRpc?.inner.sessionManager ?? SessionManager.open(filePath!); // `before` is the oldest entry already on the client; fetch its ancestors // only (excludeLeaf) so prepending the page does not duplicate `before`. - const context = buildSessionContext(sm.getEntries() as never, before ?? leafId, { + const contextLeafId = before !== undefined ? before : leafId; + const context = buildSessionContext(sm.getEntries() as never, contextLeafId, { deferThinking, deferToolResultImages, tail, diff --git a/app/api/sessions/context-route.test.mjs b/app/api/sessions/context-route.test.mjs index c637b422d3..53d0cf66f2 100644 --- a/app/api/sessions/context-route.test.mjs +++ b/app/api/sessions/context-route.test.mjs @@ -19,7 +19,13 @@ const { buildSessionContext } = await jiti.import("@/lib/session-reader"); test("context route parses ?tail and ?before, excluding the boundary on paging", () => { assert.match(routeSrc, /const tail = Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/); assert.match(routeSrc, /const before = url\.searchParams\.get\("before"\)/); - assert.match(routeSrc, /buildSessionContext\(sm\.getEntries\(\) as never, before \?\? leafId, \{[^}]*excludeLeaf: Boolean\(before\)/); + assert.match(routeSrc, /const contextLeafId = before !== undefined \? before : leafId/); + assert.match(routeSrc, /buildSessionContext\(sm\.getEntries\(\) as never, contextLeafId, \{[^}]*excludeLeaf: Boolean\(before\)/); +}); + +test("context route distinguishes an explicit root from an omitted leaf", () => { + assert.match(routeSrc, /url\.searchParams\.has\("leafId"\)/); + assert.match(routeSrc, /url\.searchParams\.get\("leafId"\) \|\| null/); }); test("context route: ?before pages upward without duplicating the boundary", () => { diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 5ab8e4e248..08e42e1bb6 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -7,7 +7,12 @@ import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input"; import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, isMessageGroupAnchor, splitFinalAssistantBlocks, splitThinkingBlocks } from "@/lib/message-display"; import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files"; import { MessageView, ThinkingBlock } from "./MessageView"; -import { ChatInput, type ChatInputHandle } from "./ChatInput"; +import { + ChatInput, + getUserMessageDraftImages, + getUserMessageText, + type ChatInputHandle, +} from "./ChatInput"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; import { ExtensionStatusBar } from "./ExtensionStatusBar"; import { AnsiText } from "./AnsiText"; @@ -249,11 +254,6 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi onAgentEnd?.(); }, [completionNotificationsEnabled, onAgentEnd]); - // 稳定化 onEditContent 引用,配合 React.memo 防止历史消息重渲染 - const handleEditContent = useCallback((message: UserMessage) => { - chatInputRef?.current?.replaceMessage(message); - }, [chatInputRef]); - const { loading, error, messages, entryIds, historyCursor, hasEarlierMessages, streamState, agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel, @@ -266,7 +266,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi isNew, sessionIdRef, messagesEndRef, scrollContainerRef, lastUserMsgRef, promptAnchorActive, - handleSend, handleAbort, handleFork, handleNavigate, handleModelChange, + handleSend, handleAbort, handleFork, handleEditAndSend, handleModelChange, handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, @@ -277,6 +277,30 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsPanelOpen, }); const sessionBusy = agentRunning || bashRunning; + const [editingEntryId, setEditingEntryId] = useState(null); + + useEffect(() => { + setEditingEntryId(null); + }, [session?.id, newSessionDraftKey]); + + useEffect(() => { + if (sessionBusy) setEditingEntryId(null); + }, [sessionBusy]); + + const handleStartEdit = useCallback((entryId: string) => { + setEditingEntryId(entryId); + }, []); + + const handleCancelEdit = useCallback(() => { + setEditingEntryId(null); + }, []); + + const handleSubmitEdit = useCallback(async (entryId: string, message: UserMessage): Promise => { + const images = getUserMessageDraftImages(message); + const submitted = await handleEditAndSend(entryId, getUserMessageText(message), images); + if (submitted) setEditingEntryId(null); + return submitted; + }, [handleEditAndSend]); useEffect(() => { if ( @@ -743,10 +767,6 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean; writtenFiles?: WrittenFile[] } = {}): ReactNode => { const msg = options.messageOverride ?? messages[idx]; - const prevAssistantEntryId = - msg.role === "user" && idx > 0 && messages[idx - 1].role === "assistant" - ? entryIds[idx - 1] - : undefined; const isVisible = isMessageGroupAnchor(msg) || msg.role === "assistant"; const currentRefIdx = visibleRefIndexByMessage.get(idx); const keyPrefix = options.keyPrefix ?? "message"; @@ -778,9 +798,10 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, sessi searchBlock={entryIds[idx] === pendingSearchScroll?.entryId ? searchBlock : undefined} onFork={sessionBusy || isNew || (idx === 0 && msg.role === "user") ? undefined : handleFork} forking={forkingEntryId === entryIds[idx]} - onNavigate={sessionBusy ? undefined : handleNavigate} - prevAssistantEntryId={sessionBusy ? undefined : prevAssistantEntryId} - onEditContent={handleEditContent} + editing={msg.role === "user" && editingEntryId === entryIds[idx]} + onStartEdit={!sessionBusy && !isNew ? handleStartEdit : undefined} + onCancelEdit={handleCancelEdit} + onSubmitEdit={handleSubmitEdit} showTimestamp={showTimestamp} prevTimestamp={idx > 0 ? (messages[idx - 1] as AgentMessage & { timestamp?: number }).timestamp : undefined} sessionId={session?.id ?? sessionIdRef.current ?? undefined} diff --git a/components/MessageView.test.mjs b/components/MessageView.test.mjs index 3d2f9f4497..384e7ab1d1 100644 --- a/components/MessageView.test.mjs +++ b/components/MessageView.test.mjs @@ -228,6 +228,48 @@ test("keeps attached images when restoring a compact command for editing", () => ]); }); +test("renders an inline edit action for the first user message", () => { + const html = renderMessage({ + role: "user", + content: "original request", + }, { + entryId: "user-root", + onStartEdit() {}, + async onSubmitEdit() { return true; }, + }); + + assert.match(html, /aria-label="Edit message"/); + assert.ok(html.indexOf("Copy message") < html.indexOf("Edit message")); +}); + +test("renders the message text with Cancel and Send inside the inline editor", () => { + const html = renderMessage({ + role: "user", + content: "original request", + }, { + entryId: "user-2", + editing: true, + onStartEdit() {}, + onCancelEdit() {}, + async onSubmitEdit() { return true; }, + }); + + assert.match(html, /]+aria-label="Edit message"/); + assert.match(html, />original request<\/textarea>/); + assert.match(html, />Cancel<\/button>/); + assert.match(html, />Send<\/button>/); + assert.doesNotMatch(html, /title="Copy message"/); +}); + +test("does not offer message editing without an idle edit handler", () => { + const html = renderMessage({ + role: "user", + content: "running request", + }, { entryId: "user-running" }); + + assert.doesNotMatch(html, /aria-label="Edit message"/); +}); + test("renders user-message images as buttons that open a larger preview", () => { const html = renderMessage({ role: "user", diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 34e89f7155..919174e92b 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -189,9 +189,10 @@ interface Props { searchBlock?: AssistantContentBlock; onFork?: (entryId: string) => void; forking?: boolean; - onNavigate?: (entryId: string) => void; - prevAssistantEntryId?: string; - onEditContent?: (message: UserMessage) => void; + editing?: boolean; + onStartEdit?: (entryId: string) => void; + onCancelEdit?: () => void; + onSubmitEdit?: (entryId: string, message: UserMessage) => Promise; showTimestamp?: boolean; prevTimestamp?: number; sessionId?: string; @@ -250,9 +251,9 @@ function haveSameRelevantToolResults( return true; } -export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onOpenSession, entryId, searchBlock, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId, writtenFiles }: Props) { +export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onOpenSession, entryId, searchBlock, onFork, forking, editing, onStartEdit, onCancelEdit, onSubmitEdit, showTimestamp, prevTimestamp, sessionId, writtenFiles }: Props) { if (message.role === "user") { - return ; + return ; } if (message.role === "assistant") { return ; @@ -283,24 +284,26 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too && prev.searchBlock === next.searchBlock && prev.onFork === next.onFork && prev.forking === next.forking - && prev.onNavigate === next.onNavigate - && prev.prevAssistantEntryId === next.prevAssistantEntryId - && prev.onEditContent === next.onEditContent + && prev.editing === next.editing + && prev.onStartEdit === next.onStartEdit + && prev.onCancelEdit === next.onCancelEdit + && prev.onSubmitEdit === next.onSubmitEdit && prev.showTimestamp === next.showTimestamp && prev.prevTimestamp === next.prevTimestamp && prev.sessionId === next.sessionId; }); -function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: { +function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, editing, onStartEdit, onCancelEdit, onSubmitEdit }: { message: UserMessage; cwd?: string; onOpenFile?: (filePath: string) => void; entryId?: string; onFork?: (entryId: string) => void; forking?: boolean; - onNavigate?: (entryId: string) => void; - prevAssistantEntryId?: string; - onEditContent?: (message: UserMessage) => void; + editing?: boolean; + onStartEdit?: (entryId: string) => void; + onCancelEdit?: () => void; + onSubmitEdit?: (entryId: string, message: UserMessage) => Promise; }) { const { t } = useI18n(); const [hovered, setHovered] = useState(false); @@ -332,7 +335,6 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o const time = formatTime(message.timestamp); const canFork = !!entryId && !!onFork; const copyTarget = commandText ?? content; - const editTarget = commandText ? replaceUserMessageText(message, commandText) : message; const imageBlocksNode = imageBlocks.length > 0 && (
@@ -360,7 +362,25 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o })}
); - const canNavigate = !!prevAssistantEntryId && !!onNavigate; + const editableText = commandText ?? content; + const [editValue, setEditValue] = useState(editableText); + const [editSubmitting, setEditSubmitting] = useState(false); + const canEdit = !!entryId && !!onStartEdit && !!onSubmitEdit; + const canSubmitEdit = editValue.trim().length > 0 || imageBlocks.length > 0; + + useEffect(() => { + if (editing) setEditValue(editableText); + }, [editableText, editing]); + + const submitEdit = async () => { + if (!entryId || !onSubmitEdit || !canSubmitEdit || editSubmitting) return; + setEditSubmitting(true); + try { + await onSubmitEdit(entryId, replaceUserMessageText(message, editValue)); + } finally { + setEditSubmitting(false); + } + }; const copyContent = () => { copyText(copyTarget).then(() => { @@ -375,7 +395,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} > -
+
- {commandText ? ( + {editing ? ( +
+ {imageBlocksNode} +