From eb385d7141bee4272f3ee52a5087029ad4ba6d41 Mon Sep 17 00:00:00 2001 From: ethan Date: Thu, 13 Nov 2025 17:36:31 +1100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20queued=20messa?= =?UTF-8?q?ges=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.stories.tsx | 3 + src/browser/api.ts | 1 + src/components/AIView.tsx | 33 +- src/components/ChatInput/index.tsx | 37 +- src/components/ChatInput/types.ts | 2 + src/components/CommandPalette.tsx | 7 +- src/components/Messages/MessageRenderer.tsx | 2 + src/components/Messages/MessageWindow.tsx | 4 +- src/components/Messages/QueuedMessage.tsx | 53 ++ src/constants/events.ts | 5 +- src/constants/ipc-constants.ts | 1 + src/preload.ts | 2 + src/services/agentSession.ts | 62 ++- src/services/ipcMain.ts | 22 +- src/services/messageQueue.test.ts | 214 +++++++++ src/services/messageQueue.ts | 111 +++++ src/stores/WorkspaceStore.ts | 51 +- src/types/ipc.ts | 41 +- src/types/message.ts | 9 +- tests/ipcMain/helpers.ts | 19 +- tests/ipcMain/queuedMessages.test.ts | 504 ++++++++++++++++++++ tests/ipcMain/sendMessage.test.ts | 6 +- 22 files changed, 1149 insertions(+), 40 deletions(-) create mode 100644 src/components/Messages/QueuedMessage.tsx create mode 100644 src/services/messageQueue.test.ts create mode 100644 src/services/messageQueue.ts create mode 100644 tests/ipcMain/queuedMessages.test.ts diff --git a/src/App.stories.tsx b/src/App.stories.tsx index bcae352a3..5651766e6 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -65,6 +65,7 @@ function setupMockAPI(options: { sendMessage: () => Promise.resolve({ success: true, data: undefined }), resumeStream: () => Promise.resolve({ success: true, data: undefined }), interruptStream: () => Promise.resolve({ success: true, data: undefined }), + clearQueue: () => Promise.resolve({ success: true, data: undefined }), truncateHistory: () => Promise.resolve({ success: true, data: undefined }), replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), getInfo: () => Promise.resolve(null), @@ -655,6 +656,7 @@ export const ActiveWorkspaceWithChat: Story = { sendMessage: () => Promise.resolve({ success: true, data: undefined }), resumeStream: () => Promise.resolve({ success: true, data: undefined }), interruptStream: () => Promise.resolve({ success: true, data: undefined }), + clearQueue: () => Promise.resolve({ success: true, data: undefined }), truncateHistory: () => Promise.resolve({ success: true, data: undefined }), replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), getInfo: () => Promise.resolve(null), @@ -851,6 +853,7 @@ These tables should render cleanly without any disruptive copy or download actio sendMessage: () => Promise.resolve({ success: true, data: undefined }), resumeStream: () => Promise.resolve({ success: true, data: undefined }), interruptStream: () => Promise.resolve({ success: true, data: undefined }), + clearQueue: () => Promise.resolve({ success: true, data: undefined }), truncateHistory: () => Promise.resolve({ success: true, data: undefined }), replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }), getInfo: () => Promise.resolve(null), diff --git a/src/browser/api.ts b/src/browser/api.ts index b1f6d637c..c350364d9 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -225,6 +225,7 @@ const webApi: IPCApi = { invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options), interruptStream: (workspaceId, options) => invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options), + clearQueue: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_QUEUE_CLEAR, workspaceId), truncateHistory: (workspaceId, percentage) => invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage), replaceChatHistory: (workspaceId, summaryMessage) => diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index c0740836b..4add113d3 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -4,6 +4,7 @@ import { MessageRenderer } from "./Messages/MessageRenderer"; import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; +import { QueuedMessage } from "./Messages/QueuedMessage"; import { PinnedTodoList } from "./PinnedTodoList"; import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/constants/storage"; import { ChatInput, type ChatInputAPI } from "./ChatInput/index"; @@ -113,8 +114,28 @@ const AIViewInner: React.FC = ({ setEditingMessage({ id: messageId, content }); }, []); - const handleEditLastUserMessage = useCallback(() => { + const handleEditQueuedMessage = useCallback(async () => { + const queuedMessage = workspaceState?.queuedMessage; + if (!queuedMessage) return; + + await window.api.workspace.clearQueue(workspaceId); + chatInputAPI.current?.restoreText(queuedMessage.content); + + // Restore images if present + if (queuedMessage.imageParts && queuedMessage.imageParts.length > 0) { + chatInputAPI.current?.restoreImages(queuedMessage.imageParts); + } + }, [workspaceId, workspaceState?.queuedMessage, chatInputAPI]); + + const handleEditLastUserMessage = useCallback(async () => { if (!workspaceState) return; + + if (workspaceState.queuedMessage) { + await handleEditQueuedMessage(); + return; + } + + // Otherwise, edit last user message const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages); const lastUserMessage = [...mergedMessages] .reverse() @@ -131,7 +152,7 @@ const AIViewInner: React.FC = ({ element?.scrollIntoView({ behavior: "smooth", block: "center" }); }); } - }, [workspaceState, contentRef, setAutoScroll]); + }, [workspaceState, contentRef, setAutoScroll, handleEditQueuedMessage]); const handleCancelEdit = useCallback(() => { setEditingMessage(undefined); @@ -435,6 +456,12 @@ const AIViewInner: React.FC = ({ } /> )} + {workspaceState?.queuedMessage && ( + void handleEditQueuedMessage()} + /> + )} {!autoScroll && (