Skip to content

Commit 7cf2edd

Browse files
committed
🤖 feat: queue messages during streaming
1 parent 816297d commit 7cf2edd

File tree

22 files changed

+1127
-32
lines changed

22 files changed

+1127
-32
lines changed

src/browser/App.stories.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ function setupMockAPI(options: {
6767
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
6868
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
6969
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
70+
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
7071
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
7172
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
7273
getInfo: () => Promise.resolve(null),
@@ -1118,6 +1119,7 @@ export const ActiveWorkspaceWithChat: Story = {
11181119
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
11191120
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
11201121
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
1122+
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
11211123
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
11221124
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
11231125
getInfo: () => Promise.resolve(null),
@@ -1408,6 +1410,7 @@ These tables should render cleanly without any disruptive copy or download actio
14081410
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
14091411
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
14101412
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
1413+
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
14111414
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
14121415
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
14131416
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/browser/components/AIView.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type { DisplayedMessage } from "@/common/types/message";
2727
import type { RuntimeConfig } from "@/common/types/runtime";
2828
import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds";
2929
import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
30+
import { QueuedMessage } from "./Messages/QueuedMessage";
3031

3132
interface AIViewProps {
3233
workspaceId: string;
@@ -141,8 +142,28 @@ const AIViewInner: React.FC<AIViewProps> = ({
141142
setEditingMessage({ id: messageId, content });
142143
}, []);
143144

144-
const handleEditLastUserMessage = useCallback(() => {
145+
const handleEditQueuedMessage = useCallback(async () => {
146+
const queuedMessage = workspaceState?.queuedMessage;
147+
if (!queuedMessage) return;
148+
149+
await window.api.workspace.clearQueue(workspaceId);
150+
chatInputAPI.current?.restoreText(queuedMessage.content);
151+
152+
// Restore images if present
153+
if (queuedMessage.imageParts && queuedMessage.imageParts.length > 0) {
154+
chatInputAPI.current?.restoreImages(queuedMessage.imageParts);
155+
}
156+
}, [workspaceId, workspaceState?.queuedMessage, chatInputAPI]);
157+
158+
const handleEditLastUserMessage = useCallback(async () => {
145159
if (!workspaceState) return;
160+
161+
if (workspaceState.queuedMessage) {
162+
await handleEditQueuedMessage();
163+
return;
164+
}
165+
166+
// Otherwise, edit last user message
146167
const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
147168
const lastUserMessage = [...mergedMessages]
148169
.reverse()
@@ -159,7 +180,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
159180
element?.scrollIntoView({ behavior: "smooth", block: "center" });
160181
});
161182
}
162-
}, [workspaceState, contentRef, setAutoScroll]);
183+
}, [workspaceState, contentRef, setAutoScroll, handleEditQueuedMessage]);
163184

164185
const handleCancelEdit = useCallback(() => {
165186
setEditingMessage(undefined);
@@ -458,6 +479,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
458479
/>
459480
)}
460481
</div>
482+
{workspaceState?.queuedMessage && (
483+
<QueuedMessage
484+
message={workspaceState.queuedMessage}
485+
onEdit={() => void handleEditQueuedMessage()}
486+
/>
487+
)}
461488
</div>
462489
{!autoScroll && (
463490
<button
@@ -479,7 +506,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
479506
isCompacting={isCompacting}
480507
editingMessage={editingMessage}
481508
onCancelEdit={handleCancelEdit}
482-
onEditLastUserMessage={handleEditLastUserMessage}
509+
onEditLastUserMessage={() => void handleEditLastUserMessage()}
483510
canInterrupt={canInterrupt}
484511
onReady={handleChatInputReady}
485512
/>

src/browser/components/ChatInput/index.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ function createTokenCountResource(promise: Promise<number>): TokenCountReader {
9696

9797
// Import types from local types file
9898
import type { ChatInputProps, ChatInputAPI } from "./types";
99+
import type { ImagePart } from "@/common/types/ipc";
99100
export type { ChatInputProps, ChatInputAPI };
100101

101102
export const ChatInput: React.FC<ChatInputProps> = (props) => {
@@ -225,16 +226,27 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
225226
[setInput]
226227
);
227228

229+
// Method to restore images to input (used by queued message edit)
230+
const restoreImages = useCallback((images: ImagePart[]) => {
231+
const attachments: ImageAttachment[] = images.map((img, index) => ({
232+
id: `restored-${Date.now()}-${index}`,
233+
url: img.url,
234+
mediaType: img.mediaType,
235+
}));
236+
setImageAttachments(attachments);
237+
}, []);
238+
228239
// Provide API to parent via callback
229240
useEffect(() => {
230241
if (props.onReady) {
231242
props.onReady({
232243
focus: focusMessageInput,
233244
restoreText,
234245
appendText,
246+
restoreImages,
235247
});
236248
}
237-
}, [props.onReady, focusMessageInput, restoreText, appendText, props]);
249+
}, [props.onReady, focusMessageInput, restoreText, appendText, restoreImages, props]);
238250

239251
useEffect(() => {
240252
const handleGlobalKeyDown = (event: KeyboardEvent) => {
@@ -305,18 +317,31 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
305317
};
306318
}, []);
307319

308-
// Allow external components (e.g., CommandPalette) to insert text
320+
// Allow external components (e.g., CommandPalette, Queued message edits) to insert text
309321
useEffect(() => {
310322
const handler = (e: Event) => {
311-
const detail = (e as CustomEvent).detail as { text?: string } | undefined;
312-
if (!detail?.text) return;
313-
setInput(detail.text);
314-
setTimeout(() => inputRef.current?.focus(), 0);
323+
const customEvent = e as CustomEvent<{
324+
text: string;
325+
mode?: "append" | "replace";
326+
imageParts?: ImagePart[];
327+
}>;
328+
329+
const { text, mode = "append", imageParts } = customEvent.detail;
330+
331+
if (mode === "replace") {
332+
restoreText(text);
333+
} else {
334+
appendText(text);
335+
}
336+
337+
if (imageParts && imageParts.length > 0) {
338+
restoreImages(imageParts);
339+
}
315340
};
316341
window.addEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
317342
return () =>
318343
window.removeEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
319-
}, [setInput]);
344+
}, [appendText, restoreText, restoreImages]);
320345

321346
// Allow external components to open the Model Selector
322347
useEffect(() => {
@@ -835,6 +860,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
835860
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
836861
hints.push(`${formatKeybind(interruptKeybind)} to interrupt`);
837862
}
863+
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to ${canInterrupt ? "queue" : "send"}`);
838864
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send`);
839865
hints.push(`${formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} to change model`);
840866
hints.push(`/vim to toggle Vim mode (${vimEnabled ? "on" : "off"})`);

src/browser/components/ChatInput/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import type { ImagePart } from "@/common/types/ipc";
12
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
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/browser/components/CommandPalette.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,12 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
197197
shortcutHint: `${formatKeybind(KEYBINDS.SEND_MESSAGE)} to insert`,
198198
run: () => {
199199
const text = s.replacement;
200-
window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { text }));
200+
window.dispatchEvent(
201+
createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, {
202+
text,
203+
mode: "append",
204+
})
205+
);
201206
},
202207
})),
203208
},

src/browser/components/Messages/MessageRenderer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface MessageRendererProps {
1212
message: DisplayedMessage;
1313
className?: string;
1414
onEditUserMessage?: (messageId: string, content: string) => void;
15+
onEditQueuedMessage?: () => void;
1516
workspaceId?: string;
1617
isCompacting?: boolean;
1718
}

src/browser/components/Messages/MessageWindow.tsx

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

src/browser/stores/WorkspaceStore.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "@/common/utils/assert";
2-
import type { MuxMessage, DisplayedMessage } from "@/common/types/message";
2+
import type { MuxMessage, DisplayedMessage, QueuedMessage } from "@/common/types/message";
33
import { createMuxMessage } from "@/common/types/message";
44
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
55
import type { WorkspaceChatMessage } from "@/common/types/ipc";
@@ -14,6 +14,8 @@ import {
1414
isStreamError,
1515
isDeleteMessage,
1616
isMuxMessage,
17+
isQueuedMessageChanged,
18+
isRestoreToInput,
1719
} from "@/common/types/ipc";
1820
import { MapStore } from "./MapStore";
1921
import { createDisplayUsage } from "@/common/utils/tokens/displayUsage";
@@ -32,6 +34,7 @@ import { createFreshRetryState } from "@/browser/utils/messages/retryState";
3234
export interface WorkspaceState {
3335
name: string; // User-facing workspace name (e.g., "feature-branch")
3436
messages: DisplayedMessage[];
37+
queuedMessage: QueuedMessage | null;
3538
canInterrupt: boolean;
3639
isCompacting: boolean;
3740
loading: boolean;
@@ -111,6 +114,7 @@ export class WorkspaceStore {
111114
private historicalMessages = new Map<string, MuxMessage[]>();
112115
private pendingStreamEvents = new Map<string, WorkspaceChatMessage[]>();
113116
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>(); // Store metadata for name lookup
117+
private queuedMessages = new Map<string, QueuedMessage | null>(); // Cached queued messages
114118

115119
/**
116120
* Map of event types to their handlers. This is the single source of truth for:
@@ -201,6 +205,36 @@ export class WorkspaceStore {
201205
aggregator.handleMessage(data);
202206
this.states.bump(workspaceId);
203207
},
208+
"queued-message-changed": (workspaceId, _aggregator, data) => {
209+
if (!isQueuedMessageChanged(data)) return;
210+
211+
// Create QueuedMessage once here instead of on every render
212+
// Use displayText which handles slash commands (shows /compact instead of expanded prompt)
213+
// Show queued message if there's text OR images (support image-only queued messages)
214+
const hasContent = data.queuedMessages.length > 0 || (data.imageParts?.length ?? 0) > 0;
215+
const queuedMessage: QueuedMessage | null = hasContent
216+
? {
217+
id: `queued-${workspaceId}`,
218+
content: data.displayText,
219+
imageParts: data.imageParts,
220+
}
221+
: null;
222+
223+
this.queuedMessages.set(workspaceId, queuedMessage);
224+
this.states.bump(workspaceId);
225+
},
226+
"restore-to-input": (workspaceId, _aggregator, data) => {
227+
if (!isRestoreToInput(data)) return;
228+
229+
// Use INSERT_TO_CHAT_INPUT event with mode="replace"
230+
window.dispatchEvent(
231+
createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, {
232+
text: data.text,
233+
mode: "replace",
234+
imageParts: data.imageParts,
235+
})
236+
);
237+
},
204238
};
205239

206240
// Cache of last known recency per workspace (for change detection)
@@ -305,6 +339,7 @@ export class WorkspaceStore {
305339
return {
306340
name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing
307341
messages: aggregator.getDisplayedMessages(),
342+
queuedMessage: this.queuedMessages.get(workspaceId) ?? null,
308343
canInterrupt: activeStreams.length > 0,
309344
isCompacting: aggregator.isCompacting(),
310345
loading: !hasMessages && !isCaughtUp,

0 commit comments

Comments
 (0)