diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts index b38e3b9a8b9..9e142d1df28 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 7d25891840c..511137bf1ef 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 f5ccde8888a..9fe0e704934 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 44dec96271e..418863e804c 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,31 +465,39 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ instance: extensionInstanceSchema, timestamp: z.number(), }), + z.object({ - type: z.literal(ExtensionBridgeEventName.InstanceRegistered), + type: z.literal(ExtensionBridgeEventName.TaskUserMessage), instance: extensionInstanceSchema, timestamp: z.number(), }), + z.object({ - type: z.literal(ExtensionBridgeEventName.InstanceUnregistered), + type: z.literal(ExtensionBridgeEventName.ModeChanged), instance: extensionInstanceSchema, + mode: z.string(), timestamp: z.number(), }), z.object({ - type: z.literal(ExtensionBridgeEventName.HeartbeatUpdated), + 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.ModeChanged), + type: z.literal(ExtensionBridgeEventName.InstanceRegistered), instance: extensionInstanceSchema, - mode: z.string(), timestamp: z.number(), }), z.object({ - type: z.literal(ExtensionBridgeEventName.ProviderProfileChanged), + type: z.literal(ExtensionBridgeEventName.InstanceUnregistered), + instance: extensionInstanceSchema, + timestamp: z.number(), + }), + z.object({ + type: z.literal(ExtensionBridgeEventName.HeartbeatUpdated), instance: extensionInstanceSchema, - providerProfile: z.object({ name: z.string(), provider: z.string().optional() }), timestamp: z.number(), }), ]) diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 590c2bdc885..b33320c7be5 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 378506973c2..2303b0f6fd4 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 b0058521195..9eb201bd831 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 00000000000..fe38bf0194f --- /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 dccdf084706..c8656002bde 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 655983db20b..1c4d9ec6c7c 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 a8d64d66000..5ce80c6e9d8 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), ]) } @@ -1187,14 +1190,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 { @@ -1204,6 +1209,7 @@ export class ClineProvider this.log( `Error exchanging code for API key: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) + throw error } @@ -1221,8 +1227,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 { @@ -1232,10 +1240,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, @@ -1250,7 +1259,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, @@ -1508,7 +1517,7 @@ export class ClineProvider } } - async getStateToPostToWebview() { + async getStateToPostToWebview(): Promise { const { apiConfiguration, lastShownAnnouncementId, @@ -1598,6 +1607,7 @@ export class ClineProvider remoteControlEnabled, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + openRouterUseMiddleOutTransform, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1635,6 +1645,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), @@ -1731,6 +1742,7 @@ export class ClineProvider remoteControlEnabled, openRouterImageApiKey, openRouterImageGenerationSelectedModel, + openRouterUseMiddleOutTransform, } } @@ -1740,7 +1752,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() @@ -1808,7 +1830,7 @@ export class ClineProvider ) } - // Return the same structure as before + // Return the same structure as before. return { apiConfiguration: providerSettings, lastShownAnnouncementId: stateValues.lastShownAnnouncementId, @@ -1832,7 +1854,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, @@ -1879,7 +1901,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, @@ -1893,7 +1915,6 @@ export class ClineProvider sharingEnabled, organizationAllowList, organizationSettingsVersion, - // Explicitly add condensing settings condensingApiConfigId: stateValues.condensingApiConfigId, customCondensingPrompt: stateValues.customCondensingPrompt, codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, @@ -1913,12 +1934,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() @@ -1930,7 +1948,6 @@ export class ClineProvider return false } })(), - // Add image generation settings openRouterImageApiKey: stateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, } @@ -2367,12 +2384,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 } @@ -2423,7 +2440,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 ff16e64094c..b9a30708dd5 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 d0173d106db..beaf2f17e9c 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 c08f4771d10..1202f48a218 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 85840267fe1..81b196d83c0 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,128 +577,70 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - try { - 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 }]) + (text: string, images: string[]) => { + text = text.trim() + + 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() - } - } 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 + return } - // For direct sends, we could show an error to the user - // but for now we'll just log it - } - }, - [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) + // Mark that user has responded - this prevents any pending auto-approvals. + userRespondedRef.current = true - // Get current retry count - const retryCount = retryCountRef.current.get(nextMessage.id) || 0 + if (messagesRef.current.length === 0) { + vscode.postMessage({ type: "newTask", text, images }) + } else if (clineAskRef.current) { + if (clineAskRef.current === "followup") { + markFollowUpAsAnswered() + } - // 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]) + // 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 { - console.error(`Message ${nextMessage.id} failed after ${MAX_RETRY_ATTEMPTS} attempts, discarding`) - retryCountRef.current.delete(nextMessage.id) + // This is a new message in an ongoing task. + vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) } - }) - .finally(() => { - isProcessingQueueRef.current = false - }) - // Cleanup function to handle component unmount - return () => { - isProcessingQueueRef.current = false - } - }, [sendingDisabled, messageQueue, handleSendMessage, clineAsk]) + handleChatReset() + } + }, + [handleChatReset, markFollowUpAsAnswered, sendingDisabled], // messagesRef and clineAskRef are stable + ) const handleSetChatBoxMessage = useCallback( (text: string, images: string[]) => { @@ -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, @@ -1998,9 +1919,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 38624f674ae..09d46083d45 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 cbaf6fa0452..2f4af84f580 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: [],