From 00ddde4ade6cde93f94a8e494dc4adf67a4d79c0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 00:51:07 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20make=20queued=20indi?= =?UTF-8?q?cator=20clickable=20to=20send=20immediately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sendQueuedImmediately option to interruptStream IPC - Make QueuedMessage 'Queued' label clickable when stream is active - Add hover underline effect and tooltip explaining the action - Clicking interrupts the current stream and sends the queued message _Generated with mux_ --- src/browser/components/AIView.tsx | 9 ++++ .../components/Messages/QueuedMessage.tsx | 41 +++++++++++++++++-- src/common/types/ipc.ts | 2 +- src/node/services/agentSession.ts | 4 +- src/node/services/ipcMain.ts | 13 +++++- 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 2dd1d36515..394abd8dce 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -245,6 +245,12 @@ const AIViewInner: React.FC = ({ } }, [workspaceId, workspaceState?.queuedMessage, chatInputAPI]); + // Handler for sending queued message immediately (interrupt + send) + const handleSendQueuedImmediately = useCallback(async () => { + if (!workspaceState?.queuedMessage || !workspaceState.canInterrupt) return; + await window.api.workspace.interruptStream(workspaceId, { sendQueuedImmediately: true }); + }, [workspaceId, workspaceState?.queuedMessage, workspaceState?.canInterrupt]); + const handleEditLastUserMessage = useCallback(async () => { if (!workspaceState) return; @@ -562,6 +568,9 @@ const AIViewInner: React.FC = ({ void handleEditQueuedMessage()} + onSendImmediately={ + workspaceState.canInterrupt ? handleSendQueuedImmediately : undefined + } /> )} void; + onSendImmediately?: () => Promise; } -export const QueuedMessage: React.FC = ({ message, className, onEdit }) => { +export const QueuedMessage: React.FC = ({ + message, + className, + onEdit, + onSendImmediately, +}) => { const { content } = message; + const [isSending, setIsSending] = useState(false); + + const handleSendImmediately = useCallback(async () => { + if (isSending || !onSendImmediately) return; + setIsSending(true); + try { + await onSendImmediately(); + } finally { + setIsSending(false); + } + }, [isSending, onSendImmediately]); const buttons: ButtonConfig[] = onEdit ? [ @@ -23,10 +41,27 @@ export const QueuedMessage: React.FC = ({ message, className ] : []; + // Clickable "Queued" label with tooltip + const queuedLabel = onSendImmediately ? ( + + + Click to send immediately + + ) : ( + "Queued" + ); + return ( <> >; interruptStream( workspaceId: string, - options?: { abandonPartial?: boolean } + options?: { abandonPartial?: boolean; sendQueuedImmediately?: boolean } ): Promise>; clearQueue(workspaceId: string): Promise>; truncateHistory(workspaceId: string, percentage?: number): Promise>; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index a2cf0a78ec..8a50897751 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -591,9 +591,9 @@ export class AgentSession { /** * Send queued messages if any exist. - * Called when tool execution completes or stream ends. + * Called when tool execution completes, stream ends, or user clicks send immediately. */ - private sendQueuedMessages(): void { + sendQueuedMessages(): void { if (!this.messageQueue.isEmpty()) { const { message, options } = this.messageQueue.produceMessage(); this.messageQueue.clear(); diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index ebd89108bc..955c0eb278 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1281,7 +1281,11 @@ export class IpcMain { ipcMain.handle( IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, - async (_event, workspaceId: string, options?: { abandonPartial?: boolean }) => { + async ( + _event, + workspaceId: string, + options?: { abandonPartial?: boolean; sendQueuedImmediately?: boolean } + ) => { log.debug("interruptStream handler: Received", { workspaceId, options }); try { const session = this.getOrCreateSession(workspaceId); @@ -1291,7 +1295,12 @@ export class IpcMain { return { success: false, error: stopResult.error }; } - session.restoreQueueToInput(); + if (options?.sendQueuedImmediately) { + // Send queued messages immediately instead of restoring to input + session.sendQueuedMessages(); + } else { + session.restoreQueueToInput(); + } return { success: true, data: undefined }; } catch (error) { From 5168b462265900aecb4c51b9f2cd460fad6b3dcb Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 00:57:47 -0600 Subject: [PATCH 2/3] fix: align preload.ts interruptStream signature with IPC type --- src/desktop/preload.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index 8ac8a5f8d7..5ccc603a92 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -82,8 +82,10 @@ const api: IPCApi = { ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options), resumeStream: (workspaceId, options) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), - interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) => - ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), + interruptStream: ( + workspaceId: string, + options?: { abandonPartial?: boolean; sendQueuedImmediately?: boolean } + ) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), clearQueue: (workspaceId: string) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_CLEAR_QUEUE, workspaceId), truncateHistory: (workspaceId, percentage) => From d2abbad4f9690ea0cdf1e1c22c0772b4eb76d11f Mon Sep 17 00:00:00 2001 From: Ammar Date: Tue, 2 Dec 2025 10:44:14 -0600 Subject: [PATCH 3/3] test: add integration test for sendQueuedImmediately option Tests that interrupting with sendQueuedImmediately:true sends the queued message instead of restoring it to the input box. --- tests/ipcMain/queuedMessages.test.ts | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/ipcMain/queuedMessages.test.ts b/tests/ipcMain/queuedMessages.test.ts index 7e1a72b454..ff3bc15ae0 100644 --- a/tests/ipcMain/queuedMessages.test.ts +++ b/tests/ipcMain/queuedMessages.test.ts @@ -182,6 +182,74 @@ describeIntegration("IpcMain queuedMessages integration tests", () => { 20000 ); + test.concurrent( + "should send queued message immediately when sendQueuedImmediately is true", + async () => { + const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); + try { + // Start a stream + void sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "Count to 10 slowly", + modelString("anthropic", "claude-haiku-4-5") + ); + + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-start", 5000); + + // Queue a message + await sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "This message should be sent immediately", + modelString("anthropic", "claude-haiku-4-5") + ); + + // Verify message was queued + const queued = await getQueuedMessages(collector); + expect(queued).toEqual(["This message should be sent immediately"]); + + // Interrupt the stream with sendQueuedImmediately flag + const interruptResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + workspaceId, + { sendQueuedImmediately: true } + ); + expect(interruptResult.success).toBe(true); + + // Wait for stream abort + await collector.waitForEvent("stream-abort", 5000); + + // Should NOT get restore-to-input event (message is sent, not restored) + // Instead, we should see the queued message being sent as a new user message + const autoSendHappened = await waitFor(() => { + collector.collect(); + const userMessages = collector + .getEvents() + .filter((e) => "role" in e && e.role === "user"); + return userMessages.length === 2; // First + immediately sent + }, 5000); + expect(autoSendHappened).toBe(true); + + // Verify queue was cleared + const queuedAfter = await getQueuedMessages(collector); + expect(queuedAfter).toEqual([]); + + // Clear events to track second stream separately + env.sentEvents.length = 0; + + // Wait for the immediately-sent message's stream + const collector2 = createEventCollector(env.sentEvents, workspaceId); + await collector2.waitForEvent("stream-start", 5000); + await collector2.waitForEvent("stream-end", 15000); + } finally { + await cleanup(); + } + }, + 30000 + ); + test.concurrent( "should combine multiple queued messages with newline separator", async () => {