Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -357,7 +358,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
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
Expand Down
5 changes: 4 additions & 1 deletion src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const { text, mode = "append", imageParts } = customEvent.detail;

if (mode === "replace") {
if (editingMessage) {
return;
}
restoreText(text);
} else {
appendText(text);
Expand All @@ -666,7 +669,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (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(() => {
Expand Down
4 changes: 3 additions & 1 deletion src/browser/components/Messages/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,7 +49,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({

const handleEdit = () => {
if (onEdit && !isLocalCommandOutput) {
onEdit(message.historyId, content);
const editText = getEditableUserMessageText(message);
onEdit(message.historyId, editText);
}
};

Expand Down
19 changes: 19 additions & 0 deletions src/browser/utils/chatCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/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({
Expand Down
12 changes: 6 additions & 6 deletions src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -719,19 +719,19 @@ 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 = "/compact";
if (options.maxOutputTokens) {
cmd += ` -t ${options.maxOutputTokens}`;
}
if (options.model) {
cmd += ` -m ${options.model}`;
}
if (options.continueMessage) {
cmd += `\n${options.continueMessage.text}`;
}
return cmd;
}

Expand Down
18 changes: 18 additions & 0 deletions src/browser/utils/compaction/format.ts
Original file line number Diff line number Diff line change
@@ -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;
}
51 changes: 51 additions & 0 deletions src/browser/utils/compaction/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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: "/compact -t 100",
parsed: { continueMessage: { text: "Do the thing" } },
},
},
},
],
} as unknown as Parameters<typeof cancelCompaction>[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", "/compact -t 100\nDo the thing");
expect(interruptStream).toHaveBeenCalledWith({
workspaceId: "ws-1",
options: { abandonPartial: true },
});
expect(calls).toEqual(["edit", "interrupt"]);
});
});
16 changes: 10 additions & 6 deletions src/browser/utils/compaction/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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;
}
16 changes: 16 additions & 0 deletions src/browser/utils/messages/messageUtils.ts
Original file line number Diff line number Diff line change
@@ -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<DisplayedMessage, { type: "user" }>
): 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
Expand Down