Skip to content

Commit 877278b

Browse files
committed
🤖 fix: avoid restore-to-input clobbering edit mode
1 parent b0b91e5 commit 877278b

File tree

8 files changed

+35
-28
lines changed

8 files changed

+35
-28
lines changed

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/utils/compaction/handler.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@ import type { APIClient } from "@/browser/contexts/API";
33
import { cancelCompaction } from "./handler";
44

55
describe("cancelCompaction", () => {
6-
test("interrupts without restore-to-input and enters edit mode with full text", async () => {
7-
const interruptStream = mock(() => Promise.resolve({ success: true }));
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+
});
813

914
const client = {
1015
workspace: {
@@ -28,15 +33,19 @@ describe("cancelCompaction", () => {
2833
],
2934
} as unknown as Parameters<typeof cancelCompaction>[2];
3035

31-
const startEditingMessage = mock(() => undefined);
36+
const startEditingMessage = mock(() => {
37+
calls.push("edit");
38+
return undefined;
39+
});
3240

3341
const result = await cancelCompaction(client, "ws-1", aggregator, startEditingMessage);
3442

3543
expect(result).toBe(true);
44+
expect(startEditingMessage).toHaveBeenCalledWith("user-1", "/compact -t 100\nDo the thing");
3645
expect(interruptStream).toHaveBeenCalledWith({
3746
workspaceId: "ws-1",
38-
options: { abandonPartial: true, restoreQueuedToInput: false },
47+
options: { abandonPartial: true },
3948
});
40-
expect(startEditingMessage).toHaveBeenCalledWith("user-1", "/compact -t 100\nDo the thing");
49+
expect(calls).toEqual(["edit", "interrupt"]);
4150
});
4251
});

src/browser/utils/compaction/handler.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,16 @@ export async function cancelCompaction(
7777
return false;
7878
}
7979

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+
8084
// Interrupt stream with abandonPartial flag
8185
// Backend detects this and skips compaction (Ctrl+C flow)
8286
await client.workspace.interruptStream({
8387
workspaceId,
84-
options: { abandonPartial: true, restoreQueuedToInput: false },
88+
options: { abandonPartial: true },
8589
});
8690

87-
// Enter edit mode on the compaction-request message with original command
88-
// This lets user immediately edit the message or delete it
89-
startEditingMessage(compactionRequestMsg.id, command);
90-
9191
return true;
9292
}

src/browser/utils/messages/compactionOptions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe("applyCompactionOverrides", () => {
7474
const result = applyCompactionOverrides(baseWithTools, compactData);
7575

7676
expect(result.mode).toBe("compact");
77-
expect(result.toolPolicy).toEqual([{ regex_match: ".*", action: "disable" }]);
77+
expect(result.toolPolicy).toEqual([{ regex_match: ".*", action: "disable" }]); // Tools always disabled for compaction
7878
});
7979

8080
it("applies all overrides together", () => {

src/common/orpc/schemas/api.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,6 @@ export const workspace = {
306306
soft: z.boolean().optional(),
307307
abandonPartial: z.boolean().optional(),
308308
sendQueuedImmediately: z.boolean().optional(),
309-
restoreQueuedToInput: z.boolean().optional(),
310309
})
311310
.optional(),
312311
}),

src/common/types/message.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export type MuxFrontendMetadata = MuxFrontendMetadataBase &
8585
(
8686
| {
8787
type: "compaction-request";
88-
rawCommand: string; // The /compact command line for display (excludes multiline continue payload)
88+
rawCommand: string; // The original /compact command as typed by user (for display)
8989
parsed: CompactionRequestData;
9090
/** Source of compaction request: user-initiated (undefined) or idle-compaction (auto) */
9191
source?: "idle-compaction";

src/node/services/serverService.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,22 @@ describe("ServerService.startServer", () => {
7777
}
7878

7979
test("cleans up server when lockfile acquisition fails", async () => {
80+
// Skip on Windows where chmod doesn't work the same way
81+
if (process.platform === "win32") {
82+
return;
83+
}
84+
8085
const service = new ServerService();
8186

82-
// Make muxHome a file (not a directory) so lockfile.acquire fails deterministically.
83-
const muxHomeFile = path.join(tempDir, "not-a-dir");
84-
await fs.writeFile(muxHomeFile, "not a directory");
87+
// Make muxHome read-only so lockfile.acquire() will fail
88+
await fs.chmod(tempDir, 0o444);
8589

8690
let thrownError: Error | null = null;
8791

8892
try {
8993
// Start server - this should fail when trying to write lockfile
9094
await service.startServer({
91-
muxHome: muxHomeFile,
95+
muxHome: tempDir,
9296
context: stubContext as ORPCContext,
9397
authToken: "test-token",
9498
port: 0, // random port
@@ -99,7 +103,7 @@ describe("ServerService.startServer", () => {
99103

100104
// Verify that an error was thrown
101105
expect(thrownError).not.toBeNull();
102-
expect(thrownError!.message).toMatch(/ENOTDIR|not a directory/i);
106+
expect(thrownError!.message).toMatch(/EACCES|permission denied/i);
103107

104108
// Verify the server is NOT left running
105109
expect(service.isServerRunning()).toBe(false);

src/node/services/workspaceService.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1449,12 +1449,7 @@ export class WorkspaceService extends EventEmitter {
14491449

14501450
async interruptStream(
14511451
workspaceId: string,
1452-
options?: {
1453-
soft?: boolean;
1454-
abandonPartial?: boolean;
1455-
sendQueuedImmediately?: boolean;
1456-
restoreQueuedToInput?: boolean;
1457-
}
1452+
options?: { soft?: boolean; abandonPartial?: boolean; sendQueuedImmediately?: boolean }
14581453
): Promise<Result<void>> {
14591454
try {
14601455
const session = this.getOrCreateSession(workspaceId);
@@ -1475,9 +1470,6 @@ export class WorkspaceService extends EventEmitter {
14751470
if (options?.sendQueuedImmediately) {
14761471
// Send queued messages immediately instead of restoring to input
14771472
session.sendQueuedMessages();
1478-
} else if (options?.restoreQueuedToInput === false) {
1479-
// Explicitly drop queued messages (used by cancel-compaction so we don't clobber edit mode)
1480-
session.clearQueue();
14811473
} else {
14821474
// Restore queued messages to input box for user-initiated interrupts
14831475
session.restoreQueueToInput();

0 commit comments

Comments
 (0)