From 5196c75017b4a83be756c68c51448302c999f8b1 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Tue, 2 Sep 2025 19:12:18 -0700 Subject: [PATCH] Move message queue to the extension host (#7604) Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- packages/cloud/src/bridge/ExtensionChannel.ts | 3 + .../bridge/__tests__/ExtensionChannel.test.ts | 3 +- packages/types/npm/package.metadata.json | 2 +- packages/types/src/cloud.ts | 38 ++-- packages/types/src/events.ts | 2 + packages/types/src/message.ts | 19 +- packages/types/src/task.ts | 15 +- src/core/message-queue/MessageQueueService.ts | 98 +++++++++ src/core/task-persistence/index.ts | 2 +- src/core/task/Task.ts | 85 ++++++-- src/core/webview/ClineProvider.ts | 53 +++-- src/core/webview/webviewMessageHandler.ts | 30 ++- src/shared/ExtensionMessage.ts | 15 +- src/shared/WebviewMessage.ts | 7 + webview-ui/src/components/chat/ChatView.tsx | 200 ++++++------------ .../src/components/chat/QueuedMessages.tsx | 14 +- .../chat/__tests__/ChatView.spec.tsx | 17 +- .../src/context/ExtensionStateContext.tsx | 3 +- 18 files changed, 385 insertions(+), 221 deletions(-) create mode 100644 src/core/message-queue/MessageQueueService.ts diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index b38e3b9a8b..9e142d1df2 100644 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ b/packages/cloud/src/bridge/ExtensionChannel.ts @@ -175,6 +175,7 @@ export class ExtensionChannel extends BaseChannel< { from: RooCodeEventName.TaskInteractive, to: ExtensionBridgeEventName.TaskInteractive }, { from: RooCodeEventName.TaskResumable, to: ExtensionBridgeEventName.TaskResumable }, { from: RooCodeEventName.TaskIdle, to: ExtensionBridgeEventName.TaskIdle }, + { from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage }, ] as const eventMapping.forEach(({ from, to }) => { @@ -220,6 +221,8 @@ export class ExtensionChannel extends BaseChannel< ? { taskId: task.taskId, taskStatus: task.taskStatus, + taskAsk: task?.taskAsk, + queuedMessages: task.queuedMessages, ...task.metadata, } : { taskId: "", taskStatus: TaskStatus.None }, diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts index 7d25891840..511137bf1e 100644 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts @@ -101,6 +101,7 @@ describe("ExtensionChannel", () => { RooCodeEventName.TaskInteractive, RooCodeEventName.TaskResumable, RooCodeEventName.TaskIdle, + RooCodeEventName.TaskUserMessage, ] // Check that on() was called for each event @@ -230,7 +231,7 @@ describe("ExtensionChannel", () => { } // Listeners should still be the same count (not accumulated) - const expectedEventCount = 10 // Number of events we listen to + const expectedEventCount = 11 // Number of events we listen to (including TaskUserMessage) expect(eventListeners.size).toBe(expectedEventCount) // Each event should have exactly 1 listener diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index f5ccde8888..9fe0e70493 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.66.0", + "version": "1.67.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 44dec96271..418863e804 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -7,7 +7,7 @@ import { TaskStatus, taskMetadataSchema } from "./task.js" import { globalSettingsSchema } from "./global-settings.js" import { providerSettingsWithIdSchema } from "./provider-settings.js" import { mcpMarketplaceItemSchema } from "./marketplace.js" -import { clineMessageSchema } from "./message.js" +import { clineMessageSchema, queuedMessageSchema } from "./message.js" import { staticAppPropertiesSchema, gitPropertiesSchema } from "./telemetry.js" /** @@ -359,6 +359,8 @@ export const INSTANCE_TTL_SECONDS = 60 const extensionTaskSchema = z.object({ taskId: z.string(), taskStatus: z.nativeEnum(TaskStatus), + taskAsk: clineMessageSchema.optional(), + queuedMessages: z.array(queuedMessageSchema).optional(), ...taskMetadataSchema.shape, }) @@ -402,6 +404,8 @@ export enum ExtensionBridgeEventName { TaskResumable = RooCodeEventName.TaskResumable, TaskIdle = RooCodeEventName.TaskIdle, + TaskUserMessage = RooCodeEventName.TaskUserMessage, + ModeChanged = RooCodeEventName.ModeChanged, ProviderProfileChanged = RooCodeEventName.ProviderProfileChanged, @@ -461,6 +465,26 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ instance: extensionInstanceSchema, timestamp: z.number(), }), + + z.object({ + type: z.literal(ExtensionBridgeEventName.TaskUserMessage), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + + z.object({ + type: z.literal(ExtensionBridgeEventName.ModeChanged), + instance: extensionInstanceSchema, + mode: z.string(), + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.ProviderProfileChanged), + instance: extensionInstanceSchema, + providerProfile: z.object({ name: z.string(), provider: z.string().optional() }), + timestamp: z.number(), + }), + z.object({ type: z.literal(ExtensionBridgeEventName.InstanceRegistered), instance: extensionInstanceSchema, @@ -476,18 +500,6 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ instance: extensionInstanceSchema, timestamp: z.number(), }), - z.object({ - type: z.literal(ExtensionBridgeEventName.ModeChanged), - instance: extensionInstanceSchema, - mode: z.string(), - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.ProviderProfileChanged), - instance: extensionInstanceSchema, - providerProfile: z.object({ name: z.string(), provider: z.string().optional() }), - timestamp: z.number(), - }), ]) export type ExtensionBridgeEvent = z.infer diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 590c2bdc88..b33320c7be 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -31,6 +31,7 @@ export enum RooCodeEventName { Message = "message", TaskModeSwitched = "taskModeSwitched", TaskAskResponded = "taskAskResponded", + TaskUserMessage = "taskUserMessage", // Task Analytics TaskTokenUsageUpdated = "taskTokenUsageUpdated", @@ -82,6 +83,7 @@ export const rooCodeEventsSchema = z.object({ ]), [RooCodeEventName.TaskModeSwitched]: z.tuple([z.string(), z.string()]), [RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]), + [RooCodeEventName.TaskUserMessage]: z.tuple([z.string()]), [RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]), [RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema]), diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 378506973c..2303b0f6fd 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -248,14 +248,11 @@ export type TokenUsage = z.infer * QueuedMessage */ -/** - * Represents a message that is queued to be sent when sending is enabled - */ -export interface QueuedMessage { - /** Unique identifier for the queued message */ - id: string - /** The text content of the message */ - text: string - /** Array of image data URLs attached to the message */ - images: string[] -} +export const queuedMessageSchema = z.object({ + timestamp: z.number(), + id: z.string(), + text: z.string(), + images: z.array(z.string()).optional(), +}) + +export type QueuedMessage = z.infer diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index b005852119..9eb201bd83 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -2,7 +2,7 @@ import { z } from "zod" import { RooCodeEventName } from "./events.js" import type { RooCodeSettings } from "./global-settings.js" -import type { ClineMessage, TokenUsage } from "./message.js" +import type { ClineMessage, QueuedMessage, TokenUsage } from "./message.js" import type { ToolUsage, ToolName } from "./tool.js" import type { StaticAppProperties, GitProperties, TelemetryProperties } from "./telemetry.js" import type { TodoItem } from "./todo.js" @@ -59,8 +59,6 @@ export interface TaskProviderLike { export type TaskProviderEvents = { [RooCodeEventName.TaskCreated]: [task: TaskLike] - - // Proxied from the Task EventEmitter. [RooCodeEventName.TaskStarted]: [taskId: string] [RooCodeEventName.TaskCompleted]: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage] [RooCodeEventName.TaskAborted]: [taskId: string] @@ -71,6 +69,9 @@ export type TaskProviderEvents = { [RooCodeEventName.TaskResumable]: [taskId: string] [RooCodeEventName.TaskIdle]: [taskId: string] [RooCodeEventName.TaskSpawned]: [taskId: string] + + [RooCodeEventName.TaskUserMessage]: [taskId: string] + [RooCodeEventName.ModeChanged]: [mode: string] [RooCodeEventName.ProviderProfileChanged]: [config: { name: string; provider?: string }] } @@ -105,11 +106,12 @@ export type TaskMetadata = z.infer export interface TaskLike { readonly taskId: string - readonly taskStatus: TaskStatus - readonly taskAsk: ClineMessage | undefined + readonly rootTask?: TaskLike readonly metadata: TaskMetadata - readonly rootTask?: TaskLike + readonly taskStatus: TaskStatus + readonly taskAsk: ClineMessage | undefined + readonly queuedMessages: QueuedMessage[] on(event: K, listener: (...args: TaskEvents[K]) => void | Promise): this off(event: K, listener: (...args: TaskEvents[K]) => void | Promise): this @@ -141,6 +143,7 @@ export type TaskEvents = { [RooCodeEventName.Message]: [{ action: "created" | "updated"; message: ClineMessage }] [RooCodeEventName.TaskModeSwitched]: [taskId: string, mode: string] [RooCodeEventName.TaskAskResponded]: [] + [RooCodeEventName.TaskUserMessage]: [taskId: string] // Task Analytics [RooCodeEventName.TaskToolFailed]: [taskId: string, tool: ToolName, error: string] diff --git a/src/core/message-queue/MessageQueueService.ts b/src/core/message-queue/MessageQueueService.ts new file mode 100644 index 0000000000..fe38bf0194 --- /dev/null +++ b/src/core/message-queue/MessageQueueService.ts @@ -0,0 +1,98 @@ +import { EventEmitter } from "events" + +import { v4 as uuidv4 } from "uuid" + +import { QueuedMessage } from "@roo-code/types" + +export interface MessageQueueState { + messages: QueuedMessage[] + isProcessing: boolean + isPaused: boolean +} + +export interface QueueEvents { + stateChanged: [messages: QueuedMessage[]] +} + +export class MessageQueueService extends EventEmitter { + private _messages: QueuedMessage[] + + constructor() { + super() + + this._messages = [] + } + + private findMessage(id: string) { + const index = this._messages.findIndex((msg) => msg.id === id) + + if (index === -1) { + return { index, message: undefined } + } + + return { index, message: this._messages[index] } + } + + public addMessage(text: string, images?: string[]): QueuedMessage | undefined { + if (!text && !images?.length) { + return undefined + } + + const message: QueuedMessage = { + timestamp: Date.now(), + id: uuidv4(), + text, + images, + } + + this._messages.push(message) + this.emit("stateChanged", this._messages) + + return message + } + + public removeMessage(id: string): boolean { + const { index, message } = this.findMessage(id) + + if (!message) { + return false + } + + this._messages.splice(index, 1) + this.emit("stateChanged", this._messages) + return true + } + + public updateMessage(id: string, text: string, images?: string[]): boolean { + const { message } = this.findMessage(id) + + if (!message) { + return false + } + + message.timestamp = Date.now() + message.text = text + message.images = images + this.emit("stateChanged", this._messages) + return true + } + + public dequeueMessage(): QueuedMessage | undefined { + const message = this._messages.shift() + this.emit("stateChanged", this._messages) + return message + } + + public get messages(): QueuedMessage[] { + return this._messages + } + + public isEmpty(): boolean { + return this._messages.length === 0 + } + + public dispose(): void { + this._messages = [] + this.removeAllListeners() + } +} diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index dccdf08470..c8656002bd 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,3 +1,3 @@ -export { readApiMessages, saveApiMessages } from "./apiMessages" +export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 655983db20..1c4d9ec6c7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -10,7 +10,6 @@ import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" import { - type RooCodeSettings, type TaskLike, type TaskMetadata, type TaskEvents, @@ -35,6 +34,7 @@ import { isIdleAsk, isInteractiveAsk, isResumableAsk, + QueuedMessage, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" @@ -42,6 +42,7 @@ import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" import { ApiStream } from "../../api/transform/stream" +import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" // shared import { findLastIndex } from "../../shared/array" @@ -79,6 +80,7 @@ import { SYSTEM_PROMPT } from "../prompts/system" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" +import { restoreTodoListForTask } from "../tools/updateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" @@ -88,7 +90,14 @@ import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" -import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "../task-persistence" +import { + type ApiMessage, + readApiMessages, + saveApiMessages, + readTaskMessages, + saveTaskMessages, + taskMetadata, +} from "../task-persistence" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { @@ -100,12 +109,11 @@ import { checkpointDiff, } from "../checkpoints" import { processUserContentMentions } from "../mentions/processUserContentMentions" -import { ApiMessage } from "../task-persistence/apiMessages" import { getMessagesSinceLastSummary, summarizeConversation } from "../condense" -import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" -import { restoreTodoListForTask } from "../tools/updateTodoListTool" -import { AutoApprovalHandler } from "./AutoApprovalHandler" import { Gpt5Metadata, ClineMessageWithMetadata } from "./types" +import { MessageQueueService } from "../message-queue/MessageQueueService" + +import { AutoApprovalHandler } from "./AutoApprovalHandler" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds @@ -259,6 +267,10 @@ export class Task extends EventEmitter implements TaskLike { // Task Bridge enableBridge: boolean + // Message Queue Service + public readonly messageQueueService: MessageQueueService + private messageQueueStateChangedHandler: (() => void) | undefined + // Streaming isWaitingForFirstChunk = false isStreaming = false @@ -356,9 +368,18 @@ export class Task extends EventEmitter implements TaskLike { TelemetryService.instance.captureTaskCreated(this.taskId) } - // Initialize the assistant message parser + // Initialize the assistant message parser. this.assistantMessageParser = new AssistantMessageParser() + this.messageQueueService = new MessageQueueService() + + this.messageQueueStateChangedHandler = () => { + this.emit(RooCodeEventName.TaskUserMessage, this.taskId) + this.providerRef.deref()?.postStateToWebview() + } + + this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler) + // Only set up diff strategy if diff is enabled. if (this.diffEnabled) { // Default to old strategy, will be updated if experiment is enabled. @@ -759,10 +780,13 @@ export class Task extends EventEmitter implements TaskLike { // The state is mutable if the message is complete and the task will // block (via the `pWaitFor`). const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) - const isStatusMutable = !partial && isBlocking + const isMessageQueued = !this.messageQueueService.isEmpty() + const isStatusMutable = !partial && isBlocking && !isMessageQueued let statusMutationTimeouts: NodeJS.Timeout[] = [] if (isStatusMutable) { + console.log(`Task#ask will block -> type: ${type}`) + if (isInteractiveAsk(type)) { statusMutationTimeouts.push( setTimeout(() => { @@ -797,9 +821,19 @@ export class Task extends EventEmitter implements TaskLike { }, 1_000), ) } + } else if (isMessageQueued) { + console.log("Task#ask will process message queue") + + const message = this.messageQueueService.dequeueMessage() + + if (message) { + setTimeout(async () => { + await this.submitUserMessage(message.text, message.images) + }, 0) + } } - // Wait for askResponse to be set + // Wait for askResponse to be set. await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) if (this.lastMessageTs !== askTs) { @@ -872,6 +906,8 @@ export class Task extends EventEmitter implements TaskLike { await provider.setProviderProfile(providerProfile) } + this.emit(RooCodeEventName.TaskUserMessage, this.taskId) + provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) } else { console.error("[Task#submitUserMessage] Provider reference lost") @@ -1406,8 +1442,8 @@ export class Task extends EventEmitter implements TaskLike { newUserContent.push(...formatResponse.imageBlocks(responseImages)) } - // Ensure we have at least some content to send to the API - // If newUserContent is empty, add a minimal resumption message + // Ensure we have at least some content to send to the API. + // If newUserContent is empty, add a minimal resumption message. if (newUserContent.length === 0) { newUserContent.push({ type: "text", @@ -1417,14 +1453,25 @@ export class Task extends EventEmitter implements TaskLike { await this.overwriteApiConversationHistory(modifiedApiConversationHistory) - // Task resuming from history item - + // Task resuming from history item. await this.initiateTaskLoop(newUserContent) } public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + // Dispose message queue and remove event listeners. + try { + if (this.messageQueueStateChangedHandler) { + this.messageQueueService.removeListener("stateChanged", this.messageQueueStateChangedHandler) + this.messageQueueStateChangedHandler = undefined + } + + this.messageQueueService.dispose() + } catch (error) { + console.error("Error disposing message queue:", error) + } + // Remove all event listeners to prevent memory leaks. try { this.removeAllListeners() @@ -2719,10 +2766,6 @@ export class Task extends EventEmitter implements TaskLike { // Getters - public get cwd() { - return this.workspacePath - } - public get taskStatus(): TaskStatus { if (this.interactiveAsk) { return TaskStatus.Interactive @@ -2742,4 +2785,12 @@ export class Task extends EventEmitter implements TaskLike { public get taskAsk(): ClineMessage | undefined { return this.idleAsk || this.resumableAsk || this.interactiveAsk } + + public get queuedMessages(): QueuedMessage[] { + return this.messageQueueService.messages + } + + public get cwd() { + return this.workspacePath + } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b6a1ae3cd4..504e9f3e55 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -47,7 +47,7 @@ import { Package } from "../../shared/package" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" -import { ExtensionMessage, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage" +import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage" import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" import { experimentDefault } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" @@ -184,6 +184,7 @@ export class ClineProvider const onTaskInteractive = (taskId: string) => this.emit(RooCodeEventName.TaskInteractive, taskId) const onTaskResumable = (taskId: string) => this.emit(RooCodeEventName.TaskResumable, taskId) const onTaskIdle = (taskId: string) => this.emit(RooCodeEventName.TaskIdle, taskId) + const onTaskUserMessage = (taskId: string) => this.emit(RooCodeEventName.TaskUserMessage, taskId) // Attach the listeners. instance.on(RooCodeEventName.TaskStarted, onTaskStarted) @@ -195,6 +196,7 @@ export class ClineProvider instance.on(RooCodeEventName.TaskInteractive, onTaskInteractive) instance.on(RooCodeEventName.TaskResumable, onTaskResumable) instance.on(RooCodeEventName.TaskIdle, onTaskIdle) + instance.on(RooCodeEventName.TaskUserMessage, onTaskUserMessage) // Store the cleanup functions for later removal. this.taskEventListeners.set(instance, [ @@ -207,6 +209,7 @@ export class ClineProvider () => instance.off(RooCodeEventName.TaskInteractive, onTaskInteractive), () => instance.off(RooCodeEventName.TaskResumable, onTaskResumable), () => instance.off(RooCodeEventName.TaskIdle, onTaskIdle), + () => instance.off(RooCodeEventName.TaskUserMessage, onTaskUserMessage), ]) } @@ -1210,14 +1213,16 @@ export class ClineProvider // OpenRouter async handleOpenRouterCallback(code: string) { - let { apiConfiguration, currentApiConfigName } = await this.getState() + let { apiConfiguration, currentApiConfigName = "default" } = await this.getState() let apiKey: string + try { const baseUrl = apiConfiguration.openRouterBaseUrl || "https://openrouter.ai/api/v1" - // Extract the base domain for the auth endpoint + // Extract the base domain for the auth endpoint. const baseUrlDomain = baseUrl.match(/^(https?:\/\/[^\/]+)/)?.[1] || "https://openrouter.ai" const response = await axios.post(`${baseUrlDomain}/api/v1/auth/keys`, { code }) + if (response.data && response.data.key) { apiKey = response.data.key } else { @@ -1227,6 +1232,7 @@ export class ClineProvider this.log( `Error exchanging code for API key: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) + throw error } @@ -1244,8 +1250,10 @@ export class ClineProvider async handleGlamaCallback(code: string) { let apiKey: string + try { const response = await axios.post("https://glama.ai/api/gateway/v1/auth/exchange-code", { code }) + if (response.data && response.data.apiKey) { apiKey = response.data.apiKey } else { @@ -1255,10 +1263,11 @@ export class ClineProvider this.log( `Error exchanging code for API key: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) + throw error } - const { apiConfiguration, currentApiConfigName } = await this.getState() + const { apiConfiguration, currentApiConfigName = "default" } = await this.getState() const newConfiguration: ProviderSettings = { ...apiConfiguration, @@ -1273,7 +1282,7 @@ export class ClineProvider // Requesty async handleRequestyCallback(code: string) { - let { apiConfiguration, currentApiConfigName } = await this.getState() + let { apiConfiguration, currentApiConfigName = "default" } = await this.getState() const newConfiguration: ProviderSettings = { ...apiConfiguration, @@ -1531,7 +1540,7 @@ export class ClineProvider } } - async getStateToPostToWebview() { + async getStateToPostToWebview(): Promise { const { apiConfiguration, lastShownAnnouncementId, @@ -1621,6 +1630,7 @@ export class ClineProvider remoteControlEnabled, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + openRouterUseMiddleOutTransform, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1658,6 +1668,7 @@ export class ClineProvider : undefined, clineMessages: this.getCurrentTask()?.clineMessages || [], currentTaskTodos: this.getCurrentTask()?.todoList || [], + messageQueue: this.getCurrentTask()?.messageQueueService?.messages, taskHistory: (taskHistory || []) .filter((item: HistoryItem) => item.ts && item.task) .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), @@ -1754,6 +1765,7 @@ export class ClineProvider remoteControlEnabled, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + openRouterUseMiddleOutTransform, } } @@ -1763,7 +1775,17 @@ export class ClineProvider * https://www.eliostruyf.com/devhack-code-extension-storage-options/ */ - async getState() { + async getState(): Promise< + Omit< + ExtensionState, + | "clineMessages" + | "renderContext" + | "hasOpenedModeSelector" + | "version" + | "shouldShowAnnouncement" + | "hasSystemPromptOverride" + > + > { const stateValues = this.contextProxy.getValues() const customModes = await this.customModesManager.getCustomModes() @@ -1831,7 +1853,7 @@ export class ClineProvider ) } - // Return the same structure as before + // Return the same structure as before. return { apiConfiguration: providerSettings, lastShownAnnouncementId: stateValues.lastShownAnnouncementId, @@ -1855,7 +1877,7 @@ export class ClineProvider allowedMaxCost: stateValues.allowedMaxCost, autoCondenseContext: stateValues.autoCondenseContext ?? true, autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, - taskHistory: stateValues.taskHistory, + taskHistory: stateValues.taskHistory ?? [], allowedCommands: stateValues.allowedCommands, deniedCommands: stateValues.deniedCommands, soundEnabled: stateValues.soundEnabled ?? false, @@ -1902,7 +1924,7 @@ export class ClineProvider customModes, maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform ?? true, + openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform, browserToolEnabled: stateValues.browserToolEnabled ?? true, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, @@ -1916,7 +1938,6 @@ export class ClineProvider sharingEnabled, organizationAllowList, organizationSettingsVersion, - // Explicitly add condensing settings condensingApiConfigId: stateValues.condensingApiConfigId, customCondensingPrompt: stateValues.customCondensingPrompt, codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, @@ -1936,12 +1957,9 @@ export class ClineProvider codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, }, profileThresholds: stateValues.profileThresholds ?? {}, - // Add diagnostic message settings includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, - // Add includeTaskHistoryInEnhance setting includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, - // Add remoteControlEnabled setting - get from cloud settings remoteControlEnabled: (() => { try { const cloudSettings = CloudService.instance.getUserSettings() @@ -1953,7 +1971,6 @@ export class ClineProvider return false } })(), - // Add image generation settings openRouterImageApiKey: stateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, } @@ -2390,12 +2407,12 @@ export class ClineProvider // Provider Profiles public async getProviderProfiles(): Promise<{ name: string; provider?: string }[]> { - const { listApiConfigMeta } = await this.getState() + const { listApiConfigMeta = [] } = await this.getState() return listApiConfigMeta.map((profile) => ({ name: profile.name, provider: profile.apiProvider })) } public async getProviderProfile(): Promise { - const { currentApiConfigName } = await this.getState() + const { currentApiConfigName = "default" } = await this.getState() return currentApiConfigName } @@ -2446,7 +2463,7 @@ export class ClineProvider } private async getTaskProperties(): Promise { - const { language, mode, apiConfiguration } = await this.getState() + const { language = "en", mode, apiConfiguration } = await this.getState() const task = this.getCurrentTask() const todoList = task?.todoList diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ff16e64094..b9a30708dd 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,7 +23,12 @@ import { Package } from "../../shared/package" import { RouterName, toRouterName, ModelRecord } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" -import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" +import { + type WebviewMessage, + type EditQueuedMessagePayload, + checkoutDiffPayloadSchema, + checkoutRestorePayloadSchema, +} from "../../shared/WebviewMessage" import { checkExistKey } from "../../shared/checkExistApiConfig" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" @@ -1412,7 +1417,7 @@ export const webviewMessageHandler = async ( const { apiConfiguration, customSupportPrompts, - listApiConfigMeta, + listApiConfigMeta = [], enhancementApiConfigId, includeTaskHistoryInEnhance, } = state @@ -2671,5 +2676,26 @@ export const webviewMessageHandler = async ( vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth")) break } + + /** + * Chat Message Queue + */ + + case "queueMessage": { + provider.getCurrentTask()?.messageQueueService.addMessage(message.text ?? "", message.images) + break + } + case "removeQueuedMessage": { + provider.getCurrentTask()?.messageQueueService.removeMessage(message.text ?? "") + break + } + case "editQueuedMessage": { + if (message.payload) { + const { id, text, images } = message.payload as EditQueuedMessagePayload + provider.getCurrentTask()?.messageQueueService.updateMessage(id, text, images) + } + + break + } } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index d0173d106d..beaf2f17e9 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -12,6 +12,7 @@ import type { CloudUserInfo, OrganizationAllowList, ShareVisibility, + QueuedMessage, } from "@roo-code/types" import { GitCommit } from "../utils/git" @@ -196,6 +197,7 @@ export interface ExtensionMessage { messageTs?: number context?: string commands?: Command[] + queuedMessages?: QueuedMessage[] } export type ExtensionState = Pick< @@ -219,8 +221,10 @@ export type ExtensionState = Pick< | "alwaysAllowMcp" | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" + | "alwaysAllowFollowupQuestions" | "alwaysAllowExecute" | "alwaysAllowUpdateTodoList" + | "followupAutoApproveTimeoutMs" | "allowedCommands" | "deniedCommands" | "allowedMaxRequests" @@ -229,6 +233,7 @@ export type ExtensionState = Pick< | "browserViewportSize" | "screenshotQuality" | "remoteBrowserEnabled" + | "cachedChromeHostUrl" | "remoteBrowserHost" // | "enableCheckpoints" // Optional in GlobalSettings, required here. | "ttsEnabled" @@ -274,12 +279,13 @@ export type ExtensionState = Pick< | "maxDiagnosticMessages" | "remoteControlEnabled" | "openRouterImageGenerationSelectedModel" + | "includeTaskHistoryInEnhance" > & { version: string clineMessages: ClineMessage[] currentTaskItem?: HistoryItem currentTaskTodos?: TodoItem[] // Initial todos for the current task - apiConfiguration?: ProviderSettings + apiConfiguration: ProviderSettings uriScheme?: string shouldShowAnnouncement: boolean @@ -328,6 +334,13 @@ export type ExtensionState = Pick< profileThresholds: Record hasOpenedModeSelector: boolean openRouterImageApiKey?: string + openRouterUseMiddleOutTransform?: boolean + messageQueue?: QueuedMessage[] + lastShownAnnouncementId?: string + apiModelId?: string + mcpServers?: McpServer[] + hasSystemPromptOverride?: boolean + mdmCompliant?: boolean } export interface ClineSayTool { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index c08f4771d1..1202f48a21 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -7,6 +7,7 @@ import { type InstallMarketplaceItemOptions, type MarketplaceItem, type ShareVisibility, + type QueuedMessage, marketplaceItemSchema, } from "@roo-code/types" @@ -22,6 +23,8 @@ export interface UpdateTodoListPayload { todos: any[] } +export type EditQueuedMessagePayload = Pick + export interface WebviewMessage { type: | "updateTodoList" @@ -215,6 +218,9 @@ export interface WebviewMessage { | "imageGenerationSettings" | "openRouterImageApiKey" | "openRouterImageGenerationSelectedModel" + | "queueMessage" + | "removeQueuedMessage" + | "editQueuedMessage" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" @@ -330,3 +336,4 @@ export type WebViewMessagePayload = | IndexClearedPayload | InstallMarketplaceItemWithParametersPayload | UpdateTodoListPayload + | EditQueuedMessagePayload diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a95e9d95a0..e66bbb55be 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -23,6 +23,7 @@ import { getApiMetrics } from "@roo/getApiMetrics" import { AudioType } from "@roo/WebviewMessage" import { getAllModes } from "@roo/modes" import { ProfileValidator } from "@roo/ProfileValidator" +import { getLatestTodo } from "@roo/todo" import { vscode } from "@src/utils/vscode" import { @@ -54,9 +55,7 @@ import AutoApproveMenu from "./AutoApproveMenu" import SystemPromptWarning from "./SystemPromptWarning" import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" -import QueuedMessages from "./QueuedMessages" -import { getLatestTodo } from "@roo/todo" -import { QueuedMessage } from "@roo-code/types" +import { QueuedMessages } from "./QueuedMessages" export interface ChatViewProps { isHidden: boolean @@ -121,6 +120,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) - const [messageQueue, setMessageQueue] = useState([]) - const isProcessingQueueRef = useRef(false) - const retryCountRef = useRef>(new Map()) - const MAX_RETRY_ATTEMPTS = 3 // we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed) const [clineAsk, setClineAsk] = useState(undefined) @@ -470,11 +466,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -586,129 +577,71 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - try { - text = text.trim() + (text: string, images: string[]) => { + text = text.trim() - if (text || images.length > 0) { - if (sendingDisabled && !fromQueue) { - // Generate a more unique ID using timestamp + random component - const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` - setMessageQueue((prev: QueuedMessage[]) => [...prev, { id: messageId, text, images }]) + if (text || images.length > 0) { + if (sendingDisabled) { + try { + console.log("queueMessage", text, images) + vscode.postMessage({ type: "queueMessage", text, images }) setInputValue("") setSelectedImages([]) - return - } - // Mark that user has responded - this prevents any pending auto-approvals - userRespondedRef.current = true - - if (messagesRef.current.length === 0) { - vscode.postMessage({ type: "newTask", text, images }) - } else if (clineAskRef.current) { - if (clineAskRef.current === "followup") { - markFollowUpAsAnswered() - } - - // Use clineAskRef.current - switch ( - clineAskRef.current // Use clineAskRef.current - ) { - case "followup": - case "tool": - case "browser_action_launch": - case "command": // User can provide feedback to a tool or command use. - case "command_output": // User can send input to command stdin. - case "use_mcp_server": - case "completion_result": // If this happens then the user has feedback for the completion result. - case "resume_task": - case "resume_completed_task": - case "mistake_limit_reached": - vscode.postMessage({ - type: "askResponse", - askResponse: "messageResponse", - text, - images, - }) - break - // There is no other case that a textfield should be enabled. - } - } else { - // This is a new message in an ongoing task. - vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) + } catch (error) { + console.error( + `Failed to queue message: ${error instanceof Error ? error.message : String(error)}`, + ) } - handleChatReset() + return } - } catch (error) { - console.error("Error in handleSendMessage:", error) - // If this was a queued message, we should handle it differently - if (fromQueue) { - throw error // Re-throw to be caught by the queue processor + + // Mark that user has responded - this prevents any pending auto-approvals. + userRespondedRef.current = true + + if (messagesRef.current.length === 0) { + vscode.postMessage({ type: "newTask", text, images }) + } else if (clineAskRef.current) { + if (clineAskRef.current === "followup") { + markFollowUpAsAnswered() + } + + // Use clineAskRef.current + switch ( + clineAskRef.current // Use clineAskRef.current + ) { + case "followup": + case "tool": + case "browser_action_launch": + case "command": // User can provide feedback to a tool or command use. + case "command_output": // User can send input to command stdin. + case "use_mcp_server": + case "completion_result": // If this happens then the user has feedback for the completion result. + case "resume_task": + case "resume_completed_task": + case "mistake_limit_reached": + vscode.postMessage({ + type: "askResponse", + askResponse: "messageResponse", + text, + images, + }) + break + // There is no other case that a textfield should be enabled. + } + } else { + // This is a new message in an ongoing task. + vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) } - // For direct sends, we could show an error to the user - // but for now we'll just log it + + handleChatReset() } }, [handleChatReset, markFollowUpAsAnswered, sendingDisabled], // messagesRef and clineAskRef are stable ) - useEffect(() => { - // Early return if conditions aren't met - // Also don't process queue if there's an API error (clineAsk === "api_req_failed") - if ( - sendingDisabled || - messageQueue.length === 0 || - isProcessingQueueRef.current || - clineAsk === "api_req_failed" - ) { - return - } - - // Mark as processing immediately to prevent race conditions - isProcessingQueueRef.current = true - - // Process the first message in the queue - const [nextMessage, ...remaining] = messageQueue - - // Update queue immediately to prevent duplicate processing - setMessageQueue(remaining) - - // Process the message - Promise.resolve() - .then(() => { - handleSendMessage(nextMessage.text, nextMessage.images, true) - // Clear retry count on success - retryCountRef.current.delete(nextMessage.id) - }) - .catch((error) => { - console.error("Failed to send queued message:", error) - - // Get current retry count - const retryCount = retryCountRef.current.get(nextMessage.id) || 0 - - // Only re-add if under retry limit - if (retryCount < MAX_RETRY_ATTEMPTS) { - retryCountRef.current.set(nextMessage.id, retryCount + 1) - // Re-add the message to the end of the queue - setMessageQueue((current: QueuedMessage[]) => [...current, nextMessage]) - } else { - console.error(`Message ${nextMessage.id} failed after ${MAX_RETRY_ATTEMPTS} attempts, discarding`) - retryCountRef.current.delete(nextMessage.id) - } - }) - .finally(() => { - isProcessingQueueRef.current = false - }) - - // Cleanup function to handle component unmount - return () => { - isProcessingQueueRef.current = false - } - }, [sendingDisabled, messageQueue, handleSendMessage, clineAsk]) - const handleSetChatBoxMessage = useCallback( (text: string, images: string[]) => { // Avoid nested template literals by breaking down the logic @@ -724,18 +657,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Store refs in variables to avoid stale closure issues - const retryCountMap = retryCountRef.current - const isProcessingRef = isProcessingQueueRef - - return () => { - retryCountMap.clear() - isProcessingRef.current = false - } - }, []) - const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), []) // This logic depends on the useEffect[messages] above to set clineAsk, @@ -2018,9 +1939,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction setMessageQueue((prev) => prev.filter((_, i) => i !== index))} + onRemove={(index) => { + if (messageQueue[index]) { + vscode.postMessage({ type: "removeQueuedMessage", text: messageQueue[index].id }) + } + }} onUpdate={(index, newText) => { - setMessageQueue((prev) => prev.map((msg, i) => (i === index ? { ...msg, text: newText } : msg))) + if (messageQueue[index]) { + vscode.postMessage({ + type: "editQueuedMessage", + payload: { id: messageQueue[index].id, text: newText, images: messageQueue[index].images }, + }) + } }} /> void onUpdate: (index: number, newText: string) => void } -const QueuedMessages: React.FC = ({ queue, onRemove, onUpdate }) => { +export const QueuedMessages = ({ queue, onRemove, onUpdate }: QueuedMessagesProps) => { const { t } = useTranslation("chat") const [editingStates, setEditingStates] = useState>({}) @@ -108,5 +112,3 @@ const QueuedMessages: React.FC = ({ queue, onRemove, onUpda ) } - -export default QueuedMessages diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 38624f674a..09d46083d4 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -97,22 +97,23 @@ vi.mock("@src/components/welcome/RooCloudCTA", () => ({ // Mock QueuedMessages component vi.mock("../QueuedMessages", () => ({ - default: function MockQueuedMessages({ - messages = [], - onRemoveMessage, + QueuedMessages: function MockQueuedMessages({ + queue = [], + onRemove, }: { - messages?: Array<{ id: string; text: string; images?: string[] }> - onRemoveMessage?: (id: string) => void + queue?: Array<{ id: string; text: string; images?: string[] }> + onRemove?: (index: number) => void + onUpdate?: (index: number, newText: string) => void }) { - if (!messages || messages.length === 0) { + if (!queue || queue.length === 0) { return null } return (
- {messages.map((msg) => ( + {queue.map((msg, index) => (
{msg.text} -
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index cbaf6fa045..2f4af84f58 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -176,7 +176,8 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Extensi } export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [state, setState] = useState({ + const [state, setState] = useState({ + apiConfiguration: {}, version: "", clineMessages: [], taskHistory: [],