Skip to content

Commit 534ee43

Browse files
authored
🤖 fix: improve /compact edit-message UX (#1246)
## Summary - Treat `/compact` `muxMetadata.rawCommand` as command-line only (no multiline continue payload), eliminating duplicate-looking content in the UI. - Centralize edit-text reconstruction so editing a compaction request reliably yields: - `/compact …` + newline + continue text (when present) - Make cancel-compaction/edit deterministic without backend changes: - Enter edit mode before interrupting - Ignore `restore-to-input` "replace" events while editing (prevents clobbering the edit buffer) ## Validation - `make typecheck` - `make lint` - Unit tests: `bun test src/browser/utils/chatCommands.test.ts src/browser/utils/compaction/handler.test.ts` --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_
1 parent bb0c0b3 commit 534ee43

File tree

9 files changed

+132
-15
lines changed

9 files changed

+132
-15
lines changed

src/browser/components/AIView.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
shouldShowInterruptedBarrier,
3030
mergeConsecutiveStreamErrors,
3131
computeBashOutputGroupInfo,
32+
getEditableUserMessageText,
3233
} from "@/browser/utils/messages/messageUtils";
3334
import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator";
3435
import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility";
@@ -357,7 +358,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
357358
return;
358359
}
359360

360-
setEditingMessage({ id: lastUserMessage.historyId, content: lastUserMessage.content });
361+
setEditingMessage({
362+
id: lastUserMessage.historyId,
363+
content: getEditableUserMessageText(lastUserMessage),
364+
});
361365
setAutoScroll(false); // Show jump-to-bottom indicator
362366

363367
// Scroll to the message being edited

src/browser/components/ChatInput/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
654654
const { text, mode = "append", imageParts } = customEvent.detail;
655655

656656
if (mode === "replace") {
657+
if (editingMessage) {
658+
return;
659+
}
657660
restoreText(text);
658661
} else {
659662
appendText(text);
@@ -666,7 +669,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
666669
window.addEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
667670
return () =>
668671
window.removeEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
669-
}, [appendText, restoreText, restoreImages]);
672+
}, [appendText, restoreText, restoreImages, editingMessage]);
670673

671674
// Allow external components to open the Model Selector
672675
useEffect(() => {

src/browser/components/Messages/UserMessage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { TerminalOutput } from "./TerminalOutput";
77
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
88
import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard";
99
import { copyToClipboard } from "@/browser/utils/clipboard";
10+
import { getEditableUserMessageText } from "@/browser/utils/messages/messageUtils";
1011
import { usePersistedState } from "@/browser/hooks/usePersistedState";
1112
import { VIM_ENABLED_KEY } from "@/common/constants/storage";
1213
import { Clipboard, ClipboardCheck, Pencil } from "lucide-react";
@@ -48,7 +49,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({
4849

4950
const handleEdit = () => {
5051
if (onEdit && !isLocalCommandOutput) {
51-
onEdit(message.historyId, content);
52+
const editText = getEditableUserMessageText(message);
53+
onEdit(message.historyId, editText);
5254
}
5355
};
5456

src/browser/utils/chatCommands.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,25 @@ describe("prepareCompactionMessage", () => {
166166
expect(metadata.parsed.continueMessage?.text).toBe("Continue with this");
167167
});
168168

169+
test("rawCommand excludes multiline continue payload", () => {
170+
const sendMessageOptions = createBaseOptions();
171+
const { metadata } = prepareCompactionMessage({
172+
workspaceId: "ws-1",
173+
maxOutputTokens: 2048,
174+
model: "anthropic:claude-3-5-haiku",
175+
continueMessage: { text: "Line 1\nLine 2" },
176+
sendMessageOptions,
177+
});
178+
179+
if (metadata.type !== "compaction-request") {
180+
throw new Error("Expected compaction metadata");
181+
}
182+
183+
expect(metadata.rawCommand).toBe("/compact -t 2048 -m anthropic:claude-3-5-haiku");
184+
expect(metadata.rawCommand).not.toContain("Line 1");
185+
expect(metadata.rawCommand).not.toContain("\n");
186+
});
187+
169188
test("omits default resume text from compaction prompt", () => {
170189
const sendMessageOptions = createBaseOptions();
171190
const { messageText, metadata } = prepareCompactionMessage({

src/browser/utils/chatCommands.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@ export function prepareCompactionMessage(options: CompactionOptions): {
671671

672672
const metadata: MuxFrontendMetadata = {
673673
type: "compaction-request",
674-
rawCommand: formatCompactionCommand(options),
674+
rawCommand: formatCompactionCommandLine(options),
675675
parsed: compactData,
676676
...(options.source === "idle-compaction" && {
677677
source: options.source,
@@ -719,19 +719,19 @@ export async function executeCompaction(
719719
}
720720

721721
/**
722-
* Format compaction command string for display
722+
* Format compaction command *line* for display.
723+
*
724+
* Intentionally excludes the multiline continue payload; that content is stored in
725+
* `muxMetadata.parsed.continueMessage` and is shown/edited separately.
723726
*/
724-
function formatCompactionCommand(options: CompactionOptions): string {
727+
function formatCompactionCommandLine(options: CompactionOptions): string {
725728
let cmd = "/compact";
726729
if (options.maxOutputTokens) {
727730
cmd += ` -t ${options.maxOutputTokens}`;
728731
}
729732
if (options.model) {
730733
cmd += ` -m ${options.model}`;
731734
}
732-
if (options.continueMessage) {
733-
cmd += `\n${options.continueMessage.text}`;
734-
}
735735
return cmd;
736736
}
737737

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { CompactionRequestData } from "@/common/types/message";
2+
3+
/**
4+
* Build the text shown in the editor when editing a /compact request.
5+
*
6+
* `rawCommand` is intentionally a single-line command (no multiline payload).
7+
* If a continue message exists, we append its text on subsequent lines.
8+
*/
9+
export function buildCompactionEditText(request: {
10+
rawCommand: string;
11+
parsed: CompactionRequestData;
12+
}): string {
13+
const continueText = request.parsed.continueMessage?.text;
14+
if (typeof continueText === "string" && continueText.trim().length > 0) {
15+
return `${request.rawCommand}\n${continueText}`;
16+
}
17+
return request.rawCommand;
18+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, test, mock } from "bun:test";
2+
import type { APIClient } from "@/browser/contexts/API";
3+
import { cancelCompaction } from "./handler";
4+
5+
describe("cancelCompaction", () => {
6+
test("enters edit mode with full text before interrupting", async () => {
7+
const calls: string[] = [];
8+
9+
const interruptStream = mock(() => {
10+
calls.push("interrupt");
11+
return Promise.resolve({ success: true });
12+
});
13+
14+
const client = {
15+
workspace: {
16+
interruptStream,
17+
},
18+
} as unknown as APIClient;
19+
20+
const aggregator = {
21+
getAllMessages: () => [
22+
{
23+
id: "user-1",
24+
role: "user",
25+
metadata: {
26+
muxMetadata: {
27+
type: "compaction-request",
28+
rawCommand: "/compact -t 100",
29+
parsed: { continueMessage: { text: "Do the thing" } },
30+
},
31+
},
32+
},
33+
],
34+
} as unknown as Parameters<typeof cancelCompaction>[2];
35+
36+
const startEditingMessage = mock(() => {
37+
calls.push("edit");
38+
return undefined;
39+
});
40+
41+
const result = await cancelCompaction(client, "ws-1", aggregator, startEditingMessage);
42+
43+
expect(result).toBe(true);
44+
expect(startEditingMessage).toHaveBeenCalledWith("user-1", "/compact -t 100\nDo the thing");
45+
expect(interruptStream).toHaveBeenCalledWith({
46+
workspaceId: "ws-1",
47+
options: { abandonPartial: true },
48+
});
49+
expect(calls).toEqual(["edit", "interrupt"]);
50+
});
51+
});

src/browser/utils/compaction/handler.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
99
import type { APIClient } from "@/browser/contexts/API";
10+
import { buildCompactionEditText } from "./format";
1011

1112
/**
1213
* Check if the workspace is currently in a compaction stream
@@ -42,7 +43,7 @@ export function getCompactionCommand(aggregator: StreamingMessageAggregator): st
4243
const muxMeta = compactionMsg.metadata?.muxMetadata;
4344
if (muxMeta?.type !== "compaction-request") return null;
4445

45-
return muxMeta.rawCommand ?? null;
46+
return buildCompactionEditText(muxMeta);
4647
}
4748

4849
/**
@@ -76,13 +77,16 @@ export async function cancelCompaction(
7677
return false;
7778
}
7879

80+
// Enter edit mode first so any subsequent restore-to-input event from the interrupt can't
81+
// clobber the edit buffer.
82+
startEditingMessage(compactionRequestMsg.id, command);
83+
7984
// Interrupt stream with abandonPartial flag
8085
// Backend detects this and skips compaction (Ctrl+C flow)
81-
await client.workspace.interruptStream({ workspaceId, options: { abandonPartial: true } });
82-
83-
// Enter edit mode on the compaction-request message with original command
84-
// This lets user immediately edit the message or delete it
85-
startEditingMessage(compactionRequestMsg.id, command);
86+
await client.workspace.interruptStream({
87+
workspaceId,
88+
options: { abandonPartial: true },
89+
});
8690

8791
return true;
8892
}

src/browser/utils/messages/messageUtils.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
import type { DisplayedMessage } from "@/common/types/message";
22
import type { BashOutputToolArgs } from "@/common/types/tools";
3+
import { buildCompactionEditText } from "@/browser/utils/compaction/format";
4+
5+
/**
6+
* Returns the text that should be placed into the ChatInput when editing a user message.
7+
*
8+
* For /compact requests, this reconstructs the full multiline command by appending the
9+
* continue message to the stored single-line rawCommand.
10+
*/
11+
export function getEditableUserMessageText(
12+
message: Extract<DisplayedMessage, { type: "user" }>
13+
): string {
14+
if (message.compactionRequest) {
15+
return buildCompactionEditText(message.compactionRequest);
16+
}
17+
return message.content;
18+
}
319

420
/**
521
* Type guard to check if a message is a bash_output tool call with valid args

0 commit comments

Comments
 (0)