Skip to content

Commit a83abdc

Browse files
committed
🤖 feat: add queued messages feature
Allows users to queue multiple messages while AI is streaming: - Messages sent during streaming are queued instead of interrupting - Queued messages auto-send when stream completes - On stream abort (Ctrl+C), queued messages restore to chat input Implementation includes: - MessageQueue service for accumulating text + images + options - AgentSession integration with stream-end/abort handlers - QueuedMessage component with edit functionality - Extended ChatInput to support message restoration - Full test coverage (27 unit tests + 10 integration tests) Generated with cmux
1 parent 9572334 commit a83abdc

22 files changed

+1149
-40
lines changed

src/App.stories.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ function setupMockAPI(options: {
6565
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
6666
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
6767
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
68+
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
6869
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
6970
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
7071
getInfo: () => Promise.resolve(null),
@@ -655,6 +656,7 @@ export const ActiveWorkspaceWithChat: Story = {
655656
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
656657
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
657658
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
659+
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
658660
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
659661
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
660662
getInfo: () => Promise.resolve(null),
@@ -851,6 +853,7 @@ These tables should render cleanly without any disruptive copy or download actio
851853
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
852854
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
853855
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
856+
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
854857
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
855858
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
856859
getInfo: () => Promise.resolve(null),

src/browser/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ const webApi: IPCApi = {
225225
invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
226226
interruptStream: (workspaceId, options) =>
227227
invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
228+
clearQueue: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_QUEUE_CLEAR, workspaceId),
228229
truncateHistory: (workspaceId, percentage) =>
229230
invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage),
230231
replaceChatHistory: (workspaceId, summaryMessage) =>

src/components/AIView.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MessageRenderer } from "./Messages/MessageRenderer";
44
import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier";
55
import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier";
66
import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier";
7+
import { QueuedMessage } from "./Messages/QueuedMessage";
78
import { PinnedTodoList } from "./PinnedTodoList";
89
import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/constants/storage";
910
import { ChatInput, type ChatInputAPI } from "./ChatInput/index";
@@ -113,8 +114,28 @@ const AIViewInner: React.FC<AIViewProps> = ({
113114
setEditingMessage({ id: messageId, content });
114115
}, []);
115116

116-
const handleEditLastUserMessage = useCallback(() => {
117+
const handleEditQueuedMessage = useCallback(async () => {
118+
const queuedMessage = workspaceState?.queuedMessage;
119+
if (!queuedMessage) return;
120+
121+
await window.api.workspace.clearQueue(workspaceId);
122+
chatInputAPI.current?.restoreText(queuedMessage.content);
123+
124+
// Restore images if present
125+
if (queuedMessage.imageParts && queuedMessage.imageParts.length > 0) {
126+
chatInputAPI.current?.restoreImages(queuedMessage.imageParts);
127+
}
128+
}, [workspaceId, workspaceState?.queuedMessage, chatInputAPI]);
129+
130+
const handleEditLastUserMessage = useCallback(async () => {
117131
if (!workspaceState) return;
132+
133+
if (workspaceState.queuedMessage) {
134+
await handleEditQueuedMessage();
135+
return;
136+
}
137+
138+
// Otherwise, edit last user message
118139
const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
119140
const lastUserMessage = [...mergedMessages]
120141
.reverse()
@@ -131,7 +152,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
131152
element?.scrollIntoView({ behavior: "smooth", block: "center" });
132153
});
133154
}
134-
}, [workspaceState, contentRef, setAutoScroll]);
155+
}, [workspaceState, contentRef, setAutoScroll, handleEditQueuedMessage]);
135156

136157
const handleCancelEdit = useCallback(() => {
137158
setEditingMessage(undefined);
@@ -435,6 +456,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
435456
}
436457
/>
437458
)}
459+
{workspaceState?.queuedMessage && (
460+
<QueuedMessage
461+
message={workspaceState.queuedMessage}
462+
onEdit={() => void handleEditQueuedMessage()}
463+
/>
464+
)}
438465
</div>
439466
{!autoScroll && (
440467
<button
@@ -471,7 +498,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
471498
isCompacting={isCompacting}
472499
editingMessage={editingMessage}
473500
onCancelEdit={handleCancelEdit}
474-
onEditLastUserMessage={handleEditLastUserMessage}
501+
onEditLastUserMessage={() => void handleEditLastUserMessage()}
475502
canInterrupt={canInterrupt}
476503
onReady={handleChatInputReady}
477504
/>

src/components/ChatInput/index.tsx

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
extractImagesFromDrop,
5050
processImageFiles,
5151
} from "@/utils/imageHandling";
52+
import type { ImagePart } from "@/types/ipc";
5253

5354
import type { ThinkingLevel } from "@/types/thinking";
5455
import type { MuxFrontendMetadata } from "@/types/message";
@@ -220,16 +221,27 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
220221
[setInput]
221222
);
222223

224+
// Method to restore images to input (used by queued message edit)
225+
const restoreImages = useCallback((images: ImagePart[]) => {
226+
const attachments: ImageAttachment[] = images.map((img, index) => ({
227+
id: `restored-${Date.now()}-${index}`,
228+
url: img.url,
229+
mediaType: img.mediaType,
230+
}));
231+
setImageAttachments(attachments);
232+
}, []);
233+
223234
// Provide API to parent via callback
224235
useEffect(() => {
225236
if (props.onReady) {
226237
props.onReady({
227238
focus: focusMessageInput,
228239
restoreText,
229240
appendText,
241+
restoreImages,
230242
});
231243
}
232-
}, [props.onReady, focusMessageInput, restoreText, appendText, props]);
244+
}, [props.onReady, focusMessageInput, restoreText, appendText, restoreImages, props]);
233245

234246
useEffect(() => {
235247
const handleGlobalKeyDown = (event: KeyboardEvent) => {
@@ -300,18 +312,27 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
300312
};
301313
}, []);
302314

303-
// Allow external components (e.g., CommandPalette) to insert text
315+
// Allow external components (e.g., CommandPalette, Queued message edits) to insert text
304316
useEffect(() => {
305317
const handler = (e: Event) => {
306-
const detail = (e as CustomEvent).detail as { text?: string } | undefined;
307-
if (!detail?.text) return;
308-
setInput(detail.text);
309-
setTimeout(() => inputRef.current?.focus(), 0);
318+
const customEvent = e as CustomEventType<typeof CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT>;
319+
320+
const { text, mode = "append", imageParts } = customEvent.detail;
321+
322+
if (mode === "replace") {
323+
restoreText(text);
324+
} else {
325+
appendText(text);
326+
}
327+
328+
if (imageParts && imageParts.length > 0) {
329+
restoreImages(imageParts);
330+
}
310331
};
311332
window.addEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
312333
return () =>
313334
window.removeEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
314-
}, [setInput]);
335+
}, [appendText, restoreText, restoreImages]);
315336

316337
// Allow external components to open the Model Selector
317338
useEffect(() => {
@@ -830,7 +851,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
830851
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
831852
hints.push(`${formatKeybind(interruptKeybind)} to interrupt`);
832853
}
833-
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send`);
854+
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to ${canInterrupt ? "queue" : "send"}`);
834855
hints.push(`${formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} to change model`);
835856
hints.push(`/vim to toggle Vim mode (${vimEnabled ? "on" : "off"})`);
836857

src/components/ChatInput/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
2+
import type { ImagePart } from "@/types/ipc";
23

34
export interface ChatInputAPI {
45
focus: () => void;
56
restoreText: (text: string) => void;
67
appendText: (text: string) => void;
8+
restoreImages: (images: ImagePart[]) => void;
79
}
810

911
// Workspace variant: full functionality for existing workspaces

src/components/CommandPalette.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,12 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
192192
shortcutHint: `${formatKeybind(KEYBINDS.SEND_MESSAGE)} to insert`,
193193
run: () => {
194194
const text = s.replacement;
195-
window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { text }));
195+
window.dispatchEvent(
196+
createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, {
197+
text,
198+
mode: "append",
199+
})
200+
);
196201
},
197202
})),
198203
},

src/components/Messages/MessageRenderer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import { ReasoningMessage } from "./ReasoningMessage";
77
import { StreamErrorMessage } from "./StreamErrorMessage";
88
import { HistoryHiddenMessage } from "./HistoryHiddenMessage";
99
import { InitMessage } from "./InitMessage";
10+
// Note: QueuedMessage is NOT imported here - it's rendered directly in AIView after StreamingBarrier
1011

1112
interface MessageRendererProps {
1213
message: DisplayedMessage;
1314
className?: string;
1415
onEditUserMessage?: (messageId: string, content: string) => void;
16+
onEditQueuedMessage?: () => void;
1517
workspaceId?: string;
1618
isCompacting?: boolean;
1719
}

src/components/Messages/MessageWindow.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactNode } from "react";
22
import React, { useState, useMemo } from "react";
3-
import type { MuxMessage, DisplayedMessage } from "@/types/message";
3+
import type { MuxMessage, DisplayedMessage, QueuedMessage } from "@/types/message";
44
import { HeaderButton } from "../tools/shared/ToolPrimitives";
55
import { formatTimestamp } from "@/utils/ui/dateTime";
66
import { TooltipWrapper, Tooltip } from "../Tooltip";
@@ -19,7 +19,7 @@ interface MessageWindowProps {
1919
label: ReactNode;
2020
borderColor: string;
2121
backgroundColor?: string;
22-
message: MuxMessage | DisplayedMessage;
22+
message: MuxMessage | DisplayedMessage | QueuedMessage;
2323
buttons?: ButtonConfig[];
2424
kebabMenuItems?: KebabMenuItem[]; // Optional kebab menu items (provide empty array to use kebab with only Show JSON)
2525
children: ReactNode;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from "react";
2+
import type { QueuedMessage as QueuedMessageType } from "@/types/message";
3+
import type { ButtonConfig } from "./MessageWindow";
4+
import { MessageWindow } from "./MessageWindow";
5+
6+
interface QueuedMessageProps {
7+
message: QueuedMessageType;
8+
className?: string;
9+
onEdit?: () => void;
10+
}
11+
12+
export const QueuedMessage: React.FC<QueuedMessageProps> = ({ message, className, onEdit }) => {
13+
const { content } = message;
14+
15+
const buttons: ButtonConfig[] = onEdit
16+
? [
17+
{
18+
label: "Edit",
19+
onClick: onEdit,
20+
},
21+
]
22+
: [];
23+
24+
return (
25+
<>
26+
<MessageWindow
27+
label="queued"
28+
borderColor="var(--color-user-border)"
29+
message={message}
30+
className={className}
31+
buttons={buttons}
32+
>
33+
{content && (
34+
<pre className="text-subtle m-0 font-mono text-xs leading-4 break-words whitespace-pre-wrap opacity-90">
35+
{content}
36+
</pre>
37+
)}
38+
{message.imageParts && message.imageParts.length > 0 && (
39+
<div className="mt-2 flex flex-wrap gap-2">
40+
{message.imageParts.map((img, idx) => (
41+
<img
42+
key={idx}
43+
src={img.url}
44+
alt={`Attachment ${idx + 1}`}
45+
className="border-border-light max-h-[300px] max-w-80 rounded border"
46+
/>
47+
))}
48+
</div>
49+
)}
50+
</MessageWindow>
51+
</>
52+
);
53+
};

src/constants/events.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type { ThinkingLevel } from "@/types/thinking";
9+
import type { ImagePart } from "@/types/ipc";
910

1011
export const CUSTOM_EVENTS = {
1112
/**
@@ -16,7 +17,7 @@ export const CUSTOM_EVENTS = {
1617

1718
/**
1819
* Event to insert text into the chat input
19-
* Detail: { text: string }
20+
* Detail: { text: string, mode?: "replace" | "append", imageParts?: ImagePart[] }
2021
*/
2122
INSERT_TO_CHAT_INPUT: "cmux:insertToChatInput",
2223

@@ -63,6 +64,8 @@ export interface CustomEventPayloads {
6364
};
6465
[CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT]: {
6566
text: string;
67+
mode?: "replace" | "append";
68+
imageParts?: ImagePart[];
6669
};
6770
[CUSTOM_EVENTS.OPEN_MODEL_SELECTOR]: never; // No payload
6871
[CUSTOM_EVENTS.RESUME_CHECK_REQUESTED]: {

0 commit comments

Comments
 (0)