diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 1f91d1ce02..b950e02fa1 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -29,6 +29,7 @@ import { shouldShowInterruptedBarrier, mergeConsecutiveStreamErrors, computeBashOutputGroupInfo, + getEditableUserMessageText, } from "@/browser/utils/messages/messageUtils"; import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator"; import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility"; @@ -357,7 +358,10 @@ const AIViewInner: React.FC = ({ return; } - setEditingMessage({ id: lastUserMessage.historyId, content: lastUserMessage.content }); + setEditingMessage({ + id: lastUserMessage.historyId, + content: getEditableUserMessageText(lastUserMessage), + }); setAutoScroll(false); // Show jump-to-bottom indicator // Scroll to the message being edited diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 8c506c6965..5bf988b0e1 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -654,6 +654,9 @@ const ChatInputInner: React.FC = (props) => { const { text, mode = "append", imageParts } = customEvent.detail; if (mode === "replace") { + if (editingMessage) { + return; + } restoreText(text); } else { appendText(text); @@ -666,7 +669,7 @@ const ChatInputInner: React.FC = (props) => { window.addEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener); return () => window.removeEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener); - }, [appendText, restoreText, restoreImages]); + }, [appendText, restoreText, restoreImages, editingMessage]); // Allow external components to open the Model Selector useEffect(() => { diff --git a/src/browser/components/Messages/UserMessage.tsx b/src/browser/components/Messages/UserMessage.tsx index a7fbbf2c9c..7f467a32d6 100644 --- a/src/browser/components/Messages/UserMessage.tsx +++ b/src/browser/components/Messages/UserMessage.tsx @@ -7,6 +7,7 @@ import { TerminalOutput } from "./TerminalOutput"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { copyToClipboard } from "@/browser/utils/clipboard"; +import { getEditableUserMessageText } from "@/browser/utils/messages/messageUtils"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { Clipboard, ClipboardCheck, Pencil } from "lucide-react"; @@ -48,7 +49,8 @@ export const UserMessage: React.FC = ({ const handleEdit = () => { if (onEdit && !isLocalCommandOutput) { - onEdit(message.historyId, content); + const editText = getEditableUserMessageText(message); + onEdit(message.historyId, editText); } }; diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index 8c10a954f9..7c158f4328 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -166,6 +166,25 @@ describe("prepareCompactionMessage", () => { expect(metadata.parsed.continueMessage?.text).toBe("Continue with this"); }); + test("rawCommand excludes multiline continue payload", () => { + const sendMessageOptions = createBaseOptions(); + const { metadata } = prepareCompactionMessage({ + workspaceId: "ws-1", + maxOutputTokens: 2048, + model: "anthropic:claude-3-5-haiku", + continueMessage: { text: "Line 1\nLine 2" }, + sendMessageOptions, + }); + + if (metadata.type !== "compaction-request") { + throw new Error("Expected compaction metadata"); + } + + expect(metadata.rawCommand).toBe("https://github.com/compact -t 2048 -m anthropic:claude-3-5-haiku"); + expect(metadata.rawCommand).not.toContain("Line 1"); + expect(metadata.rawCommand).not.toContain("\n"); + }); + test("omits default resume text from compaction prompt", () => { const sendMessageOptions = createBaseOptions(); const { messageText, metadata } = prepareCompactionMessage({ diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index f88beefd02..24d13cd733 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -671,7 +671,7 @@ export function prepareCompactionMessage(options: CompactionOptions): { const metadata: MuxFrontendMetadata = { type: "compaction-request", - rawCommand: formatCompactionCommand(options), + rawCommand: formatCompactionCommandLine(options), parsed: compactData, ...(options.source === "idle-compaction" && { source: options.source, @@ -719,9 +719,12 @@ export async function executeCompaction( } /** - * Format compaction command string for display + * Format compaction command *line* for display. + * + * Intentionally excludes the multiline continue payload; that content is stored in + * `muxMetadata.parsed.continueMessage` and is shown/edited separately. */ -function formatCompactionCommand(options: CompactionOptions): string { +function formatCompactionCommandLine(options: CompactionOptions): string { let cmd = "https://github.com/compact"; if (options.maxOutputTokens) { cmd += ` -t ${options.maxOutputTokens}`; @@ -729,9 +732,6 @@ function formatCompactionCommand(options: CompactionOptions): string { if (options.model) { cmd += ` -m ${options.model}`; } - if (options.continueMessage) { - cmd += `\n${options.continueMessage.text}`; - } return cmd; } diff --git a/src/browser/utils/compaction/format.ts b/src/browser/utils/compaction/format.ts new file mode 100644 index 0000000000..402c6db1df --- /dev/null +++ b/src/browser/utils/compaction/format.ts @@ -0,0 +1,18 @@ +import type { CompactionRequestData } from "@/common/types/message"; + +/** + * Build the text shown in the editor when editing a /compact request. + * + * `rawCommand` is intentionally a single-line command (no multiline payload). + * If a continue message exists, we append its text on subsequent lines. + */ +export function buildCompactionEditText(request: { + rawCommand: string; + parsed: CompactionRequestData; +}): string { + const continueText = request.parsed.continueMessage?.text; + if (typeof continueText === "string" && continueText.trim().length > 0) { + return `${request.rawCommand}\n${continueText}`; + } + return request.rawCommand; +} diff --git a/src/browser/utils/compaction/handler.test.ts b/src/browser/utils/compaction/handler.test.ts new file mode 100644 index 0000000000..63de65bf31 --- /dev/null +++ b/src/browser/utils/compaction/handler.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test, mock } from "bun:test"; +import type { APIClient } from "@/browser/contexts/API"; +import { cancelCompaction } from "./handler"; + +describe("cancelCompaction", () => { + test("enters edit mode with full text before interrupting", async () => { + const calls: string[] = []; + + const interruptStream = mock(() => { + calls.push("interrupt"); + return Promise.resolve({ success: true }); + }); + + const client = { + workspace: { + interruptStream, + }, + } as unknown as APIClient; + + const aggregator = { + getAllMessages: () => [ + { + id: "user-1", + role: "user", + metadata: { + muxMetadata: { + type: "compaction-request", + rawCommand: "https://github.com/compact -t 100", + parsed: { continueMessage: { text: "Do the thing" } }, + }, + }, + }, + ], + } as unknown as Parameters[2]; + + const startEditingMessage = mock(() => { + calls.push("edit"); + return undefined; + }); + + const result = await cancelCompaction(client, "ws-1", aggregator, startEditingMessage); + + expect(result).toBe(true); + expect(startEditingMessage).toHaveBeenCalledWith("user-1", "https://github.com/compact -t 100\nDo the thing"); + expect(interruptStream).toHaveBeenCalledWith({ + workspaceId: "ws-1", + options: { abandonPartial: true }, + }); + expect(calls).toEqual(["edit", "interrupt"]); + }); +}); diff --git a/src/browser/utils/compaction/handler.ts b/src/browser/utils/compaction/handler.ts index 80895d0f6e..3034cc89d5 100644 --- a/src/browser/utils/compaction/handler.ts +++ b/src/browser/utils/compaction/handler.ts @@ -7,6 +7,7 @@ import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator"; import type { APIClient } from "@/browser/contexts/API"; +import { buildCompactionEditText } from "./format"; /** * Check if the workspace is currently in a compaction stream @@ -42,7 +43,7 @@ export function getCompactionCommand(aggregator: StreamingMessageAggregator): st const muxMeta = compactionMsg.metadata?.muxMetadata; if (muxMeta?.type !== "compaction-request") return null; - return muxMeta.rawCommand ?? null; + return buildCompactionEditText(muxMeta); } /** @@ -76,13 +77,16 @@ export async function cancelCompaction( return false; } + // Enter edit mode first so any subsequent restore-to-input event from the interrupt can't + // clobber the edit buffer. + startEditingMessage(compactionRequestMsg.id, command); + // Interrupt stream with abandonPartial flag // Backend detects this and skips compaction (Ctrl+C flow) - await client.workspace.interruptStream({ workspaceId, options: { abandonPartial: true } }); - - // Enter edit mode on the compaction-request message with original command - // This lets user immediately edit the message or delete it - startEditingMessage(compactionRequestMsg.id, command); + await client.workspace.interruptStream({ + workspaceId, + options: { abandonPartial: true }, + }); return true; } diff --git a/src/browser/utils/messages/messageUtils.ts b/src/browser/utils/messages/messageUtils.ts index 877bbfa0e5..abfcee7aee 100644 --- a/src/browser/utils/messages/messageUtils.ts +++ b/src/browser/utils/messages/messageUtils.ts @@ -1,5 +1,21 @@ import type { DisplayedMessage } from "@/common/types/message"; import type { BashOutputToolArgs } from "@/common/types/tools"; +import { buildCompactionEditText } from "@/browser/utils/compaction/format"; + +/** + * Returns the text that should be placed into the ChatInput when editing a user message. + * + * For /compact requests, this reconstructs the full multiline command by appending the + * continue message to the stored single-line rawCommand. + */ +export function getEditableUserMessageText( + message: Extract +): string { + if (message.compactionRequest) { + return buildCompactionEditText(message.compactionRequest); + } + return message.content; +} /** * Type guard to check if a message is a bash_output tool call with valid args