Skip to content

Commit 526e770

Browse files
authored
🤖 feat: make queued indicator clickable to send immediately (#839)
## Summary Makes the "Queued" indicator that appears below queued messages clickable. When clicked, it interrupts the current stream and immediately sends the queued message. ## Changes - **Backend**: Added `sendQueuedImmediately` option to `interruptStream` IPC. When set, the queued message is sent instead of being restored to the input box. - **Frontend**: Made the "Queued" label a clickable button with: - Hover underline effect - Tooltip: "Click to send immediately" - Disabled state while processing ## Behavior 1. User sends a message while assistant is streaming → message is queued 2. "Queued" indicator appears below the queued message 3. On hover: underline appears, tooltip shows 4. On click: current stream is interrupted, queued message is sent immediately 5. Button is disabled during the operation to prevent race conditions _Generated with `mux`_
1 parent 0d42beb commit 526e770

File tree

7 files changed

+133
-10
lines changed

7 files changed

+133
-10
lines changed

src/browser/components/AIView.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
245245
}
246246
}, [workspaceId, workspaceState?.queuedMessage, chatInputAPI]);
247247

248+
// Handler for sending queued message immediately (interrupt + send)
249+
const handleSendQueuedImmediately = useCallback(async () => {
250+
if (!workspaceState?.queuedMessage || !workspaceState.canInterrupt) return;
251+
await window.api.workspace.interruptStream(workspaceId, { sendQueuedImmediately: true });
252+
}, [workspaceId, workspaceState?.queuedMessage, workspaceState?.canInterrupt]);
253+
248254
const handleEditLastUserMessage = useCallback(async () => {
249255
if (!workspaceState) return;
250256

@@ -562,6 +568,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
562568
<QueuedMessage
563569
message={workspaceState.queuedMessage}
564570
onEdit={() => void handleEditQueuedMessage()}
571+
onSendImmediately={
572+
workspaceState.canInterrupt ? handleSendQueuedImmediately : undefined
573+
}
565574
/>
566575
)}
567576
<ConcurrentLocalWarning

src/browser/components/Messages/QueuedMessage.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,35 @@
1-
import React from "react";
1+
import React, { useCallback, useState } from "react";
22
import type { ButtonConfig } from "./MessageWindow";
33
import { MessageWindow } from "./MessageWindow";
44
import type { QueuedMessage as QueuedMessageType } from "@/common/types/message";
55
import { Pencil } from "lucide-react";
6+
import { Tooltip, TooltipWrapper } from "../Tooltip";
67

78
interface QueuedMessageProps {
89
message: QueuedMessageType;
910
className?: string;
1011
onEdit?: () => void;
12+
onSendImmediately?: () => Promise<void>;
1113
}
1214

13-
export const QueuedMessage: React.FC<QueuedMessageProps> = ({ message, className, onEdit }) => {
15+
export const QueuedMessage: React.FC<QueuedMessageProps> = ({
16+
message,
17+
className,
18+
onEdit,
19+
onSendImmediately,
20+
}) => {
1421
const { content } = message;
22+
const [isSending, setIsSending] = useState(false);
23+
24+
const handleSendImmediately = useCallback(async () => {
25+
if (isSending || !onSendImmediately) return;
26+
setIsSending(true);
27+
try {
28+
await onSendImmediately();
29+
} finally {
30+
setIsSending(false);
31+
}
32+
}, [isSending, onSendImmediately]);
1533

1634
const buttons: ButtonConfig[] = onEdit
1735
? [
@@ -23,10 +41,27 @@ export const QueuedMessage: React.FC<QueuedMessageProps> = ({ message, className
2341
]
2442
: [];
2543

44+
// Clickable "Queued" label with tooltip
45+
const queuedLabel = onSendImmediately ? (
46+
<TooltipWrapper inline>
47+
<button
48+
type="button"
49+
onClick={() => void handleSendImmediately()}
50+
disabled={isSending}
51+
className="cursor-pointer hover:underline disabled:cursor-not-allowed disabled:opacity-50"
52+
>
53+
Queued
54+
</button>
55+
<Tooltip align="center">Click to send immediately</Tooltip>
56+
</TooltipWrapper>
57+
) : (
58+
"Queued"
59+
);
60+
2661
return (
2762
<>
2863
<MessageWindow
29-
label="Queued"
64+
label={queuedLabel}
3065
variant="user"
3166
message={message}
3267
className={className}

src/common/types/ipc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ export interface IPCApi {
320320
): Promise<Result<void, SendMessageError>>;
321321
interruptStream(
322322
workspaceId: string,
323-
options?: { abandonPartial?: boolean }
323+
options?: { abandonPartial?: boolean; sendQueuedImmediately?: boolean }
324324
): Promise<Result<void, string>>;
325325
clearQueue(workspaceId: string): Promise<Result<void, string>>;
326326
truncateHistory(workspaceId: string, percentage?: number): Promise<Result<void, string>>;

src/desktop/preload.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ const api: IPCApi = {
8282
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options),
8383
resumeStream: (workspaceId, options) =>
8484
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
85-
interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) =>
86-
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
85+
interruptStream: (
86+
workspaceId: string,
87+
options?: { abandonPartial?: boolean; sendQueuedImmediately?: boolean }
88+
) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
8789
clearQueue: (workspaceId: string) =>
8890
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CLEAR_QUEUE, workspaceId),
8991
truncateHistory: (workspaceId, percentage) =>

src/node/services/agentSession.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,9 +591,9 @@ export class AgentSession {
591591

592592
/**
593593
* Send queued messages if any exist.
594-
* Called when tool execution completes or stream ends.
594+
* Called when tool execution completes, stream ends, or user clicks send immediately.
595595
*/
596-
private sendQueuedMessages(): void {
596+
sendQueuedMessages(): void {
597597
if (!this.messageQueue.isEmpty()) {
598598
const { message, options } = this.messageQueue.produceMessage();
599599
this.messageQueue.clear();

src/node/services/ipcMain.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,7 +1281,11 @@ export class IpcMain {
12811281

12821282
ipcMain.handle(
12831283
IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM,
1284-
async (_event, workspaceId: string, options?: { abandonPartial?: boolean }) => {
1284+
async (
1285+
_event,
1286+
workspaceId: string,
1287+
options?: { abandonPartial?: boolean; sendQueuedImmediately?: boolean }
1288+
) => {
12851289
log.debug("interruptStream handler: Received", { workspaceId, options });
12861290
try {
12871291
const session = this.getOrCreateSession(workspaceId);
@@ -1291,7 +1295,12 @@ export class IpcMain {
12911295
return { success: false, error: stopResult.error };
12921296
}
12931297

1294-
session.restoreQueueToInput();
1298+
if (options?.sendQueuedImmediately) {
1299+
// Send queued messages immediately instead of restoring to input
1300+
session.sendQueuedMessages();
1301+
} else {
1302+
session.restoreQueueToInput();
1303+
}
12951304

12961305
return { success: true, data: undefined };
12971306
} catch (error) {

tests/ipcMain/queuedMessages.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,74 @@ describeIntegration("IpcMain queuedMessages integration tests", () => {
182182
20000
183183
);
184184

185+
test.concurrent(
186+
"should send queued message immediately when sendQueuedImmediately is true",
187+
async () => {
188+
const { env, workspaceId, cleanup } = await setupWorkspace("anthropic");
189+
try {
190+
// Start a stream
191+
void sendMessageWithModel(
192+
env.mockIpcRenderer,
193+
workspaceId,
194+
"Count to 10 slowly",
195+
modelString("anthropic", "claude-haiku-4-5")
196+
);
197+
198+
const collector = createEventCollector(env.sentEvents, workspaceId);
199+
await collector.waitForEvent("stream-start", 5000);
200+
201+
// Queue a message
202+
await sendMessageWithModel(
203+
env.mockIpcRenderer,
204+
workspaceId,
205+
"This message should be sent immediately",
206+
modelString("anthropic", "claude-haiku-4-5")
207+
);
208+
209+
// Verify message was queued
210+
const queued = await getQueuedMessages(collector);
211+
expect(queued).toEqual(["This message should be sent immediately"]);
212+
213+
// Interrupt the stream with sendQueuedImmediately flag
214+
const interruptResult = await env.mockIpcRenderer.invoke(
215+
IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM,
216+
workspaceId,
217+
{ sendQueuedImmediately: true }
218+
);
219+
expect(interruptResult.success).toBe(true);
220+
221+
// Wait for stream abort
222+
await collector.waitForEvent("stream-abort", 5000);
223+
224+
// Should NOT get restore-to-input event (message is sent, not restored)
225+
// Instead, we should see the queued message being sent as a new user message
226+
const autoSendHappened = await waitFor(() => {
227+
collector.collect();
228+
const userMessages = collector
229+
.getEvents()
230+
.filter((e) => "role" in e && e.role === "user");
231+
return userMessages.length === 2; // First + immediately sent
232+
}, 5000);
233+
expect(autoSendHappened).toBe(true);
234+
235+
// Verify queue was cleared
236+
const queuedAfter = await getQueuedMessages(collector);
237+
expect(queuedAfter).toEqual([]);
238+
239+
// Clear events to track second stream separately
240+
env.sentEvents.length = 0;
241+
242+
// Wait for the immediately-sent message's stream
243+
const collector2 = createEventCollector(env.sentEvents, workspaceId);
244+
await collector2.waitForEvent("stream-start", 5000);
245+
await collector2.waitForEvent("stream-end", 15000);
246+
} finally {
247+
await cleanup();
248+
}
249+
},
250+
30000
251+
);
252+
185253
test.concurrent(
186254
"should combine multiple queued messages with newline separator",
187255
async () => {

0 commit comments

Comments
 (0)