From af00c627b73ba46d64bba4f1533997a1fc351956 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Thu, 28 Aug 2025 11:18:45 -0700 Subject: [PATCH] Move @roo-code/cloud to the Roo-Code repo (#7503) --- packages/cloud/src/WebAuthService.ts | 27 +- packages/cloud/src/__mocks__/vscode.ts | 4 - .../src/__tests__/WebAuthService.spec.ts | 8 +- .../src/bridge/ExtensionBridgeService.ts | 290 +++++ packages/cloud/src/bridge/ExtensionManager.ts | 297 ++++++ .../src/bridge/SocketConnectionManager.ts | 289 +++++ packages/cloud/src/bridge/TaskManager.ts | 279 +++++ packages/cloud/src/importVscode.ts | 23 +- packages/cloud/src/index.ts | 6 +- packages/types/npm/package.metadata.json | 2 +- packages/types/src/cloud.ts | 125 +-- pnpm-lock.yaml | 170 +-- src/core/webview/ClineProvider.ts | 991 ++++++------------ src/extension.ts | 89 +- src/package.json | 6 +- src/shared/ExtensionMessage.ts | 4 +- src/shared/WebviewMessage.ts | 16 +- src/utils/remoteControl.ts | 11 + .../cloud/__tests__/CloudView.spec.tsx | 103 +- .../ImageGenerationSettings.spec.tsx | 5 +- 20 files changed, 1723 insertions(+), 1022 deletions(-) create mode 100644 packages/cloud/src/bridge/ExtensionBridgeService.ts create mode 100644 packages/cloud/src/bridge/ExtensionManager.ts create mode 100644 packages/cloud/src/bridge/SocketConnectionManager.ts create mode 100644 packages/cloud/src/bridge/TaskManager.ts create mode 100644 src/utils/remoteControl.ts diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts index 934ca90b71..cb0e087547 100644 --- a/packages/cloud/src/WebAuthService.ts +++ b/packages/cloud/src/WebAuthService.ts @@ -129,7 +129,6 @@ export class WebAuthService extends EventEmitter implements A private changeState(newState: AuthState): void { const previousState = this.state this.state = newState - this.log(`[auth] changeState: ${previousState} -> ${newState}`) this.emit("auth-state-changed", { state: newState, previousState }) } @@ -163,6 +162,8 @@ export class WebAuthService extends EventEmitter implements A this.userInfo = null this.changeState("logged-out") + + this.log("[auth] Transitioned to logged-out state") } private transitionToAttemptingSession(credentials: AuthCredentials): void { @@ -175,6 +176,8 @@ export class WebAuthService extends EventEmitter implements A this.changeState("attempting-session") this.timer.start() + + this.log("[auth] Transitioned to attempting-session state") } private transitionToInactiveSession(): void { @@ -182,6 +185,8 @@ export class WebAuthService extends EventEmitter implements A this.userInfo = null this.changeState("inactive-session") + + this.log("[auth] Transitioned to inactive-session state") } /** @@ -417,6 +422,7 @@ export class WebAuthService extends EventEmitter implements A if (previousState !== "active-session") { this.changeState("active-session") + this.log("[auth] Transitioned to active-session state") this.fetchUserInfo() } else { this.state = "active-session" @@ -563,7 +569,11 @@ export class WebAuthService extends EventEmitter implements A )?.email_address } - let extensionBridgeEnabled = true + // Check for extension_bridge_enabled in user's public metadata + let extensionBridgeEnabled = false + if (userData.public_metadata?.extension_bridge_enabled === true) { + extensionBridgeEnabled = true + } // Fetch organization info if user is in organization context try { @@ -579,7 +589,11 @@ export class WebAuthService extends EventEmitter implements A if (userMembership) { this.setUserOrganizationInfo(userInfo, userMembership) - extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(storedOrgId) + // Check organization public metadata for extension_bridge_enabled + // Organization setting takes precedence over user setting + if (await this.isExtensionBridgeEnabledForOrganization(storedOrgId)) { + extensionBridgeEnabled = true + } this.log("[auth] User in organization context:", { id: userMembership.organization.id, @@ -600,9 +614,10 @@ export class WebAuthService extends EventEmitter implements A if (primaryOrgMembership) { this.setUserOrganizationInfo(userInfo, primaryOrgMembership) - extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization( - primaryOrgMembership.organization.id, - ) + // Check organization public metadata for extension_bridge_enabled + if (await this.isExtensionBridgeEnabledForOrganization(primaryOrgMembership.organization.id)) { + extensionBridgeEnabled = true + } this.log("[auth] Legacy credentials: Found organization membership:", { id: primaryOrgMembership.organization.id, diff --git a/packages/cloud/src/__mocks__/vscode.ts b/packages/cloud/src/__mocks__/vscode.ts index 5258543786..09384d195f 100644 --- a/packages/cloud/src/__mocks__/vscode.ts +++ b/packages/cloud/src/__mocks__/vscode.ts @@ -13,10 +13,6 @@ export const Uri = { parse: vi.fn((uri: string) => ({ toString: () => uri })), } -export const commands = { - executeCommand: vi.fn().mockResolvedValue(undefined), -} - export interface ExtensionContext { secrets: { get: (key: string) => Promise diff --git a/packages/cloud/src/__tests__/WebAuthService.spec.ts b/packages/cloud/src/__tests__/WebAuthService.spec.ts index fc6bfa90e8..dbcaf388d3 100644 --- a/packages/cloud/src/__tests__/WebAuthService.spec.ts +++ b/packages/cloud/src/__tests__/WebAuthService.spec.ts @@ -560,7 +560,7 @@ describe("WebAuthService", () => { name: "John Doe", email: "john@example.com", picture: "https://example.com/avatar.jpg", - extensionBridgeEnabled: true, + extensionBridgeEnabled: false, }, }) }) @@ -725,7 +725,7 @@ describe("WebAuthService", () => { name: "Jane Smith", email: "jane@example.com", picture: "https://example.com/jane.jpg", - extensionBridgeEnabled: true, + extensionBridgeEnabled: false, }) }) @@ -844,7 +844,7 @@ describe("WebAuthService", () => { name: "John Doe", email: undefined, picture: undefined, - extensionBridgeEnabled: true, + extensionBridgeEnabled: false, }) }) }) @@ -969,7 +969,7 @@ describe("WebAuthService", () => { name: "Test User", email: undefined, picture: undefined, - extensionBridgeEnabled: true, + extensionBridgeEnabled: false, }, }) }) diff --git a/packages/cloud/src/bridge/ExtensionBridgeService.ts b/packages/cloud/src/bridge/ExtensionBridgeService.ts new file mode 100644 index 0000000000..0ab7e304f2 --- /dev/null +++ b/packages/cloud/src/bridge/ExtensionBridgeService.ts @@ -0,0 +1,290 @@ +import crypto from "crypto" + +import { + type TaskProviderLike, + type TaskLike, + type CloudUserInfo, + type ExtensionBridgeCommand, + type TaskBridgeCommand, + ConnectionState, + ExtensionSocketEvents, + TaskSocketEvents, +} from "@roo-code/types" + +import { SocketConnectionManager } from "./SocketConnectionManager.js" +import { ExtensionManager } from "./ExtensionManager.js" +import { TaskManager } from "./TaskManager.js" + +export interface ExtensionBridgeServiceOptions { + userId: string + socketBridgeUrl: string + token: string + provider: TaskProviderLike + sessionId?: string +} + +export class ExtensionBridgeService { + private static instance: ExtensionBridgeService | null = null + + // Core + private readonly userId: string + private readonly socketBridgeUrl: string + private readonly token: string + private readonly provider: TaskProviderLike + private readonly instanceId: string + + // Managers + private connectionManager: SocketConnectionManager + private extensionManager: ExtensionManager + private taskManager: TaskManager + + // Reconnection + private readonly MAX_RECONNECT_ATTEMPTS = Infinity + private readonly RECONNECT_DELAY = 1_000 + private readonly RECONNECT_DELAY_MAX = 30_000 + + public static getInstance(): ExtensionBridgeService | null { + return ExtensionBridgeService.instance + } + + public static async createInstance(options: ExtensionBridgeServiceOptions) { + console.log("[ExtensionBridgeService] createInstance") + ExtensionBridgeService.instance = new ExtensionBridgeService(options) + await ExtensionBridgeService.instance.initialize() + return ExtensionBridgeService.instance + } + + public static resetInstance() { + if (ExtensionBridgeService.instance) { + console.log("[ExtensionBridgeService] resetInstance") + ExtensionBridgeService.instance.disconnect().catch(() => {}) + ExtensionBridgeService.instance = null + } + } + + public static async handleRemoteControlState( + userInfo: CloudUserInfo | null, + remoteControlEnabled: boolean | undefined, + options: ExtensionBridgeServiceOptions, + logger?: (message: string) => void, + ) { + if (userInfo?.extensionBridgeEnabled && remoteControlEnabled) { + const existingService = ExtensionBridgeService.getInstance() + + if (!existingService) { + try { + const service = await ExtensionBridgeService.createInstance(options) + const state = service.getConnectionState() + + logger?.(`[ExtensionBridgeService#handleRemoteControlState] Instance created (state: ${state})`) + + if (state !== ConnectionState.CONNECTED) { + logger?.( + `[ExtensionBridgeService#handleRemoteControlState] Service is not connected yet, will retry in background`, + ) + } + } catch (error) { + const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to create instance: ${ + error instanceof Error ? error.message : String(error) + }` + + logger?.(message) + console.error(message) + } + } else { + const state = existingService.getConnectionState() + + if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) { + logger?.( + `[ExtensionBridgeService#handleRemoteControlState] Existing service is ${state}, attempting reconnection`, + ) + + existingService.reconnect().catch((error) => { + const message = `[ExtensionBridgeService#handleRemoteControlState] Reconnection failed: ${ + error instanceof Error ? error.message : String(error) + }` + + logger?.(message) + console.error(message) + }) + } + } + } else { + const existingService = ExtensionBridgeService.getInstance() + + if (existingService) { + try { + await existingService.disconnect() + ExtensionBridgeService.resetInstance() + + logger?.(`[ExtensionBridgeService#handleRemoteControlState] Service disconnected and reset`) + } catch (error) { + const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to disconnect and reset instance: ${ + error instanceof Error ? error.message : String(error) + }` + + logger?.(message) + console.error(message) + } + } + } + } + + private constructor(options: ExtensionBridgeServiceOptions) { + this.userId = options.userId + this.socketBridgeUrl = options.socketBridgeUrl + this.token = options.token + this.provider = options.provider + this.instanceId = options.sessionId || crypto.randomUUID() + + this.connectionManager = new SocketConnectionManager({ + url: this.socketBridgeUrl, + socketOptions: { + query: { + token: this.token, + clientType: "extension", + instanceId: this.instanceId, + }, + transports: ["websocket", "polling"], + reconnection: true, + reconnectionAttempts: this.MAX_RECONNECT_ATTEMPTS, + reconnectionDelay: this.RECONNECT_DELAY, + reconnectionDelayMax: this.RECONNECT_DELAY_MAX, + }, + onConnect: () => this.handleConnect(), + onDisconnect: () => this.handleDisconnect(), + onReconnect: () => this.handleReconnect(), + }) + + this.extensionManager = new ExtensionManager(this.instanceId, this.userId, this.provider) + + this.taskManager = new TaskManager() + } + + private async initialize() { + // Populate the app and git properties before registering the instance. + await this.provider.getTelemetryProperties() + + await this.connectionManager.connect() + this.setupSocketListeners() + } + + private setupSocketListeners() { + const socket = this.connectionManager.getSocket() + + if (!socket) { + console.error("[ExtensionBridgeService] Socket not available") + return + } + + // Remove any existing listeners first to prevent duplicates. + socket.off(ExtensionSocketEvents.RELAYED_COMMAND) + socket.off(TaskSocketEvents.RELAYED_COMMAND) + socket.off("connected") + + socket.on(ExtensionSocketEvents.RELAYED_COMMAND, (message: ExtensionBridgeCommand) => { + console.log( + `[ExtensionBridgeService] on(${ExtensionSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.instanceId}`, + ) + + this.extensionManager?.handleExtensionCommand(message) + }) + + socket.on(TaskSocketEvents.RELAYED_COMMAND, (message: TaskBridgeCommand) => { + console.log( + `[ExtensionBridgeService] on(${TaskSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.taskId}`, + ) + + this.taskManager.handleTaskCommand(message) + }) + } + + private async handleConnect() { + const socket = this.connectionManager.getSocket() + + if (!socket) { + console.error("[ExtensionBridgeService] Socket not available after connect") + + return + } + + await this.extensionManager.onConnect(socket) + await this.taskManager.onConnect(socket) + } + + private handleDisconnect() { + this.extensionManager.onDisconnect() + this.taskManager.onDisconnect() + } + + private async handleReconnect() { + const socket = this.connectionManager.getSocket() + + if (!socket) { + console.error("[ExtensionBridgeService] Socket not available after reconnect") + + return + } + + // Re-setup socket listeners to ensure they're properly configured + // after automatic reconnection (Socket.IO's built-in reconnection) + // The socket.off() calls in setupSocketListeners prevent duplicates + this.setupSocketListeners() + + await this.extensionManager.onReconnect(socket) + await this.taskManager.onReconnect(socket) + } + + // Task API + + public async subscribeToTask(task: TaskLike): Promise { + const socket = this.connectionManager.getSocket() + + if (!socket || !this.connectionManager.isConnected()) { + console.warn("[ExtensionBridgeService] Cannot subscribe to task: not connected. Will retry when connected.") + + this.taskManager.addPendingTask(task) + + const state = this.connectionManager.getConnectionState() + + if (state === ConnectionState.DISCONNECTED || state === ConnectionState.FAILED) { + this.initialize() + } + + return + } + + await this.taskManager.subscribeToTask(task, socket) + } + + public async unsubscribeFromTask(taskId: string): Promise { + const socket = this.connectionManager.getSocket() + + if (!socket) { + return + } + + await this.taskManager.unsubscribeFromTask(taskId, socket) + } + + // Shared API + + public getConnectionState(): ConnectionState { + return this.connectionManager.getConnectionState() + } + + public async disconnect(): Promise { + await this.extensionManager.cleanup(this.connectionManager.getSocket()) + await this.taskManager.cleanup(this.connectionManager.getSocket()) + await this.connectionManager.disconnect() + ExtensionBridgeService.instance = null + } + + public async reconnect(): Promise { + await this.connectionManager.reconnect() + + // After a manual reconnect, we have a new socket instance + // so we need to set up listeners again. + this.setupSocketListeners() + } +} diff --git a/packages/cloud/src/bridge/ExtensionManager.ts b/packages/cloud/src/bridge/ExtensionManager.ts new file mode 100644 index 0000000000..335245e24c --- /dev/null +++ b/packages/cloud/src/bridge/ExtensionManager.ts @@ -0,0 +1,297 @@ +import type { Socket } from "socket.io-client" + +import { + type TaskProviderLike, + type ExtensionInstance, + type ExtensionBridgeCommand, + type ExtensionBridgeEvent, + RooCodeEventName, + TaskStatus, + ExtensionBridgeCommandName, + ExtensionBridgeEventName, + ExtensionSocketEvents, + HEARTBEAT_INTERVAL_MS, +} from "@roo-code/types" + +export class ExtensionManager { + private instanceId: string + private userId: string + private provider: TaskProviderLike + private extensionInstance: ExtensionInstance + private heartbeatInterval: NodeJS.Timeout | null = null + private socket: Socket | null = null + + constructor(instanceId: string, userId: string, provider: TaskProviderLike) { + this.instanceId = instanceId + this.userId = userId + this.provider = provider + + this.extensionInstance = { + instanceId: this.instanceId, + userId: this.userId, + workspacePath: this.provider.cwd, + appProperties: this.provider.appProperties, + gitProperties: this.provider.gitProperties, + lastHeartbeat: Date.now(), + task: { + taskId: "", + taskStatus: TaskStatus.None, + }, + taskHistory: [], + } + + this.setupListeners() + } + + public async onConnect(socket: Socket): Promise { + this.socket = socket + await this.registerInstance(socket) + this.startHeartbeat(socket) + } + + public onDisconnect(): void { + this.stopHeartbeat() + this.socket = null + } + + public async onReconnect(socket: Socket): Promise { + this.socket = socket + await this.registerInstance(socket) + this.startHeartbeat(socket) + } + + public async cleanup(socket: Socket | null): Promise { + this.stopHeartbeat() + + if (socket) { + await this.unregisterInstance(socket) + } + + this.socket = null + } + + public handleExtensionCommand(message: ExtensionBridgeCommand): void { + if (message.instanceId !== this.instanceId) { + console.log(`[ExtensionManager] command -> instance id mismatch | ${this.instanceId}`, { + messageInstanceId: message.instanceId, + }) + + return + } + + switch (message.type) { + case ExtensionBridgeCommandName.StartTask: { + console.log(`[ExtensionManager] command -> createTask() | ${message.instanceId}`, { + text: message.payload.text?.substring(0, 100) + "...", + hasImages: !!message.payload.images, + }) + + this.provider.createTask(message.payload.text, message.payload.images) + + break + } + case ExtensionBridgeCommandName.StopTask: { + const instance = this.updateInstance() + + if (instance.task.taskStatus === TaskStatus.Running) { + console.log(`[ExtensionManager] command -> cancelTask() | ${message.instanceId}`) + + this.provider.cancelTask() + this.provider.postStateToWebview() + } else if (instance.task.taskId) { + console.log(`[ExtensionManager] command -> clearTask() | ${message.instanceId}`) + + this.provider.clearTask() + this.provider.postStateToWebview() + } + + break + } + case ExtensionBridgeCommandName.ResumeTask: { + console.log(`[ExtensionManager] command -> resumeTask() | ${message.instanceId}`, { + taskId: message.payload.taskId, + }) + + // Resume the task from history by taskId + this.provider.resumeTask(message.payload.taskId) + + this.provider.postStateToWebview() + + break + } + } + } + + private async registerInstance(socket: Socket): Promise { + const instance = this.updateInstance() + + try { + socket.emit(ExtensionSocketEvents.REGISTER, instance) + + console.log( + `[ExtensionManager] emit() -> ${ExtensionSocketEvents.REGISTER}`, + // instance, + ) + } catch (error) { + console.error( + `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.REGISTER}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + + return + } + } + + private async unregisterInstance(socket: Socket): Promise { + const instance = this.updateInstance() + + try { + socket.emit(ExtensionSocketEvents.UNREGISTER, instance) + + console.log( + `[ExtensionManager] emit() -> ${ExtensionSocketEvents.UNREGISTER}`, + // instance, + ) + } catch (error) { + console.error( + `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.UNREGISTER}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + private startHeartbeat(socket: Socket): void { + this.stopHeartbeat() + + this.heartbeatInterval = setInterval(async () => { + const instance = this.updateInstance() + + try { + socket.emit(ExtensionSocketEvents.HEARTBEAT, instance) + + // console.log( + // `[ExtensionManager] emit() -> ${ExtensionSocketEvents.HEARTBEAT}`, + // instance, + // ); + } catch (error) { + console.error( + `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.HEARTBEAT}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }, HEARTBEAT_INTERVAL_MS) + } + + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval) + this.heartbeatInterval = null + } + } + + private setupListeners(): void { + const eventMapping = [ + { + from: RooCodeEventName.TaskCreated, + to: ExtensionBridgeEventName.TaskCreated, + }, + { + from: RooCodeEventName.TaskStarted, + to: ExtensionBridgeEventName.TaskStarted, + }, + { + from: RooCodeEventName.TaskCompleted, + to: ExtensionBridgeEventName.TaskCompleted, + }, + { + from: RooCodeEventName.TaskAborted, + to: ExtensionBridgeEventName.TaskAborted, + }, + { + from: RooCodeEventName.TaskFocused, + to: ExtensionBridgeEventName.TaskFocused, + }, + { + from: RooCodeEventName.TaskUnfocused, + to: ExtensionBridgeEventName.TaskUnfocused, + }, + { + from: RooCodeEventName.TaskActive, + to: ExtensionBridgeEventName.TaskActive, + }, + { + from: RooCodeEventName.TaskInteractive, + to: ExtensionBridgeEventName.TaskInteractive, + }, + { + from: RooCodeEventName.TaskResumable, + to: ExtensionBridgeEventName.TaskResumable, + }, + { + from: RooCodeEventName.TaskIdle, + to: ExtensionBridgeEventName.TaskIdle, + }, + ] as const + + const addListener = + (type: ExtensionBridgeEventName) => + async (..._args: unknown[]) => { + this.publishEvent({ + type, + instance: this.updateInstance(), + timestamp: Date.now(), + }) + } + + eventMapping.forEach(({ from, to }) => this.provider.on(from, addListener(to))) + } + + private async publishEvent(message: ExtensionBridgeEvent): Promise { + if (!this.socket) { + console.error("[ExtensionManager] publishEvent -> socket not available") + return false + } + + try { + this.socket.emit(ExtensionSocketEvents.EVENT, message) + + console.log(`[ExtensionManager] emit() -> ${ExtensionSocketEvents.EVENT} ${message.type}`, message) + + return true + } catch (error) { + console.error( + `[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.EVENT}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + + return false + } + } + + private updateInstance(): ExtensionInstance { + const task = this.provider?.getCurrentTask() + const taskHistory = this.provider?.getRecentTasks() ?? [] + + this.extensionInstance = { + ...this.extensionInstance, + appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties, + gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties, + lastHeartbeat: Date.now(), + task: task + ? { + taskId: task.taskId, + taskStatus: task.taskStatus, + ...task.metadata, + } + : { taskId: "", taskStatus: TaskStatus.None }, + taskAsk: task?.taskAsk, + taskHistory, + } + + return this.extensionInstance + } +} diff --git a/packages/cloud/src/bridge/SocketConnectionManager.ts b/packages/cloud/src/bridge/SocketConnectionManager.ts new file mode 100644 index 0000000000..3ba9631fec --- /dev/null +++ b/packages/cloud/src/bridge/SocketConnectionManager.ts @@ -0,0 +1,289 @@ +import { io, type Socket } from "socket.io-client" + +import { ConnectionState, type RetryConfig } from "@roo-code/types" + +export interface SocketConnectionOptions { + url: string + socketOptions: Record + onConnect?: () => void | Promise + onDisconnect?: (reason: string) => void + onReconnect?: (attemptNumber: number) => void | Promise + onError?: (error: Error) => void + logger?: { + log: (message: string, ...args: unknown[]) => void + error: (message: string, ...args: unknown[]) => void + warn: (message: string, ...args: unknown[]) => void + } +} + +export class SocketConnectionManager { + private socket: Socket | null = null + private connectionState: ConnectionState = ConnectionState.DISCONNECTED + private retryAttempt: number = 0 + private retryTimeout: NodeJS.Timeout | null = null + private hasConnectedOnce: boolean = false + + private readonly retryConfig: RetryConfig = { + maxInitialAttempts: 10, + initialDelay: 1_000, + maxDelay: 15_000, + backoffMultiplier: 2, + } + + private readonly CONNECTION_TIMEOUT = 2_000 + private readonly options: SocketConnectionOptions + + constructor(options: SocketConnectionOptions, retryConfig?: Partial) { + this.options = options + + if (retryConfig) { + this.retryConfig = { ...this.retryConfig, ...retryConfig } + } + } + + public async connect(): Promise { + if (this.connectionState === ConnectionState.CONNECTED) { + console.log(`[SocketConnectionManager] Already connected`) + return + } + + if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) { + console.log(`[SocketConnectionManager] Connection attempt already in progress`) + + return + } + + // Start connection attempt without blocking. + this.startConnectionAttempt() + } + + private async startConnectionAttempt() { + this.retryAttempt = 0 + + try { + await this.connectWithRetry() + } catch (error) { + console.error(`[SocketConnectionManager] Initial connection attempts failed:`, error) + + // If we've never connected successfully, we've exhausted our retry attempts + // The user will need to manually retry or fix the issue + this.connectionState = ConnectionState.FAILED + } + } + + private async connectWithRetry(): Promise { + let delay = this.retryConfig.initialDelay + + while (this.retryAttempt < this.retryConfig.maxInitialAttempts) { + try { + this.connectionState = this.retryAttempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING + + console.log( + `[SocketConnectionManager] Connection attempt ${this.retryAttempt + 1} / ${this.retryConfig.maxInitialAttempts}`, + ) + + await this.connectSocket() + + console.log(`[SocketConnectionManager] Connected to ${this.options.url}`) + + this.connectionState = ConnectionState.CONNECTED + this.retryAttempt = 0 + + this.clearRetryTimeouts() + + if (this.options.onConnect) { + await this.options.onConnect() + } + + return + } catch (error) { + this.retryAttempt++ + + console.error(`[SocketConnectionManager] Connection attempt ${this.retryAttempt} failed:`, error) + + if (this.socket) { + this.socket.disconnect() + this.socket = null + } + + if (this.retryAttempt >= this.retryConfig.maxInitialAttempts) { + this.connectionState = ConnectionState.FAILED + + throw new Error(`Failed to connect after ${this.retryConfig.maxInitialAttempts} attempts`) + } + + console.log(`[SocketConnectionManager] Waiting ${delay}ms before retry...`) + + await this.delay(delay) + + delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay) + } + } + } + + private async connectSocket(): Promise { + return new Promise((resolve, reject) => { + this.socket = io(this.options.url, this.options.socketOptions) + + const connectionTimeout = setTimeout(() => { + console.error(`[SocketConnectionManager] Connection timeout`) + + if (this.connectionState !== ConnectionState.CONNECTED) { + this.socket?.disconnect() + reject(new Error("Connection timeout")) + } + }, this.CONNECTION_TIMEOUT) + + this.socket.on("connect", async () => { + clearTimeout(connectionTimeout) + + const isReconnection = this.hasConnectedOnce + + // If this is a reconnection (not the first connect), treat it as a + // reconnect. + // This handles server restarts where 'reconnect' event might not fire. + if (isReconnection) { + console.log( + `[SocketConnectionManager] Treating connect as reconnection (server may have restarted)`, + ) + + this.connectionState = ConnectionState.CONNECTED + + if (this.options.onReconnect) { + // Call onReconnect to re-register instance. + await this.options.onReconnect(0) + } + } + + this.hasConnectedOnce = true + resolve() + }) + + this.socket.on("disconnect", (reason: string) => { + console.log(`[SocketConnectionManager] Disconnected (reason: ${reason})`) + + this.connectionState = ConnectionState.DISCONNECTED + + if (this.options.onDisconnect) { + this.options.onDisconnect(reason) + } + + // Don't attempt to reconnect if we're manually disconnecting. + const isManualDisconnect = reason === "io client disconnect" + + if (!isManualDisconnect && this.hasConnectedOnce) { + // After successful initial connection, rely entirely on Socket.IO's + // reconnection. + console.log(`[SocketConnectionManager] Socket.IO will handle reconnection (reason: ${reason})`) + } + }) + + // Listen for reconnection attempts. + this.socket.on("reconnect_attempt", (attemptNumber: number) => { + console.log(`[SocketConnectionManager] Socket.IO reconnect attempt:`, { + attemptNumber, + }) + }) + + this.socket.on("reconnect", (attemptNumber: number) => { + console.log(`[SocketConnectionManager] Socket reconnected (attempt: ${attemptNumber})`) + + this.connectionState = ConnectionState.CONNECTED + + if (this.options.onReconnect) { + this.options.onReconnect(attemptNumber) + } + }) + + this.socket.on("reconnect_error", (error: Error) => { + console.error(`[SocketConnectionManager] Socket.IO reconnect error:`, error) + }) + + this.socket.on("reconnect_failed", () => { + console.error(`[SocketConnectionManager] Socket.IO reconnection failed after all attempts`) + + this.connectionState = ConnectionState.FAILED + + // Socket.IO has exhausted its reconnection attempts + // The connection is now permanently failed until manual intervention + }) + + this.socket.on("error", (error) => { + console.error(`[SocketConnectionManager] Socket error:`, error) + + if (this.connectionState !== ConnectionState.CONNECTED) { + clearTimeout(connectionTimeout) + reject(error) + } + + if (this.options.onError) { + this.options.onError(error) + } + }) + + this.socket.on("auth_error", (error) => { + console.error(`[SocketConnectionManager] Authentication error:`, error) + clearTimeout(connectionTimeout) + reject(new Error(error.message || "Authentication failed")) + }) + }) + } + + private delay(ms: number): Promise { + return new Promise((resolve) => { + this.retryTimeout = setTimeout(resolve, ms) + }) + } + + // 1. Custom retry for initial connection attempts. + // 2. Socket.IO's built-in reconnection after successful initial connection. + + private clearRetryTimeouts() { + if (this.retryTimeout) { + clearTimeout(this.retryTimeout) + this.retryTimeout = null + } + } + + public async disconnect(): Promise { + console.log(`[SocketConnectionManager] Disconnecting...`) + + this.clearRetryTimeouts() + + if (this.socket) { + this.socket.removeAllListeners() + this.socket.disconnect() + this.socket = null + } + + this.connectionState = ConnectionState.DISCONNECTED + + console.log(`[SocketConnectionManager] Disconnected`) + } + + public getSocket(): Socket | null { + return this.socket + } + + public getConnectionState(): ConnectionState { + return this.connectionState + } + + public isConnected(): boolean { + return this.connectionState === ConnectionState.CONNECTED && this.socket?.connected === true + } + + public async reconnect(): Promise { + if (this.connectionState === ConnectionState.CONNECTED) { + console.log(`[SocketConnectionManager] Already connected`) + return + } + + console.log(`[SocketConnectionManager] Manual reconnection requested`) + + this.hasConnectedOnce = false + + await this.disconnect() + await this.connect() + } +} diff --git a/packages/cloud/src/bridge/TaskManager.ts b/packages/cloud/src/bridge/TaskManager.ts new file mode 100644 index 0000000000..3940d59f25 --- /dev/null +++ b/packages/cloud/src/bridge/TaskManager.ts @@ -0,0 +1,279 @@ +import type { Socket } from "socket.io-client" + +import { + type ClineMessage, + type TaskEvents, + type TaskLike, + type TaskBridgeCommand, + type TaskBridgeEvent, + RooCodeEventName, + TaskBridgeEventName, + TaskBridgeCommandName, + TaskSocketEvents, +} from "@roo-code/types" + +type TaskEventListener = { + [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise +}[keyof TaskEvents] + +const TASK_EVENT_MAPPING: Record = { + [TaskBridgeEventName.Message]: RooCodeEventName.Message, + [TaskBridgeEventName.TaskModeSwitched]: RooCodeEventName.TaskModeSwitched, + [TaskBridgeEventName.TaskInteractive]: RooCodeEventName.TaskInteractive, +} + +export class TaskManager { + private subscribedTasks: Map = new Map() + private pendingTasks: Map = new Map() + private socket: Socket | null = null + + private taskListeners: Map> = new Map() + + constructor() {} + + public async onConnect(socket: Socket): Promise { + this.socket = socket + + // Rejoin all subscribed tasks. + for (const taskId of this.subscribedTasks.keys()) { + try { + socket.emit(TaskSocketEvents.JOIN, { taskId }) + + console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + // Subscribe to any pending tasks. + for (const task of this.pendingTasks.values()) { + await this.subscribeToTask(task, socket) + } + + this.pendingTasks.clear() + } + + public onDisconnect(): void { + this.socket = null + } + + public async onReconnect(socket: Socket): Promise { + this.socket = socket + + // Rejoin all subscribed tasks. + for (const taskId of this.subscribedTasks.keys()) { + try { + socket.emit(TaskSocketEvents.JOIN, { taskId }) + + console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + } + + public async cleanup(socket: Socket | null): Promise { + if (!socket) { + return + } + + const unsubscribePromises = [] + + for (const taskId of this.subscribedTasks.keys()) { + unsubscribePromises.push(this.unsubscribeFromTask(taskId, socket)) + } + + await Promise.allSettled(unsubscribePromises) + this.subscribedTasks.clear() + this.taskListeners.clear() + this.pendingTasks.clear() + this.socket = null + } + + public addPendingTask(task: TaskLike): void { + this.pendingTasks.set(task.taskId, task) + } + + public async subscribeToTask(task: TaskLike, socket: Socket): Promise { + const taskId = task.taskId + this.subscribedTasks.set(taskId, task) + this.setupListeners(task) + + try { + socket.emit(TaskSocketEvents.JOIN, { taskId }) + console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`) + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + public async unsubscribeFromTask(taskId: string, socket: Socket): Promise { + const task = this.subscribedTasks.get(taskId) + + if (task) { + this.removeListeners(task) + this.subscribedTasks.delete(taskId) + } + + try { + socket.emit(TaskSocketEvents.LEAVE, { taskId }) + + console.log(`[TaskManager] emit() -> ${TaskSocketEvents.LEAVE} ${taskId}`) + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.LEAVE}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + + public handleTaskCommand(message: TaskBridgeCommand): void { + const task = this.subscribedTasks.get(message.taskId) + + if (!task) { + console.error(`[TaskManager#handleTaskCommand] Unable to find task ${message.taskId}`) + + return + } + + switch (message.type) { + case TaskBridgeCommandName.Message: + console.log( + `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.Message} ${message.taskId} -> submitUserMessage()`, + message, + ) + + task.submitUserMessage(message.payload.text, message.payload.images) + break + case TaskBridgeCommandName.ApproveAsk: + console.log( + `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.ApproveAsk} ${message.taskId} -> approveAsk()`, + message, + ) + + task.approveAsk(message.payload) + break + case TaskBridgeCommandName.DenyAsk: + console.log( + `[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.DenyAsk} ${message.taskId} -> denyAsk()`, + message, + ) + + task.denyAsk(message.payload) + break + } + } + + private setupListeners(task: TaskLike): void { + if (this.taskListeners.has(task.taskId)) { + console.warn("[TaskManager] Listeners already exist for task, removing old listeners:", task.taskId) + + this.removeListeners(task) + } + + const listeners = new Map() + + const onMessage = ({ action, message }: { action: string; message: ClineMessage }) => { + this.publishEvent({ + type: TaskBridgeEventName.Message, + taskId: task.taskId, + action, + message, + }) + } + + task.on(RooCodeEventName.Message, onMessage) + listeners.set(TaskBridgeEventName.Message, onMessage) + + const onTaskModeSwitched = (mode: string) => { + this.publishEvent({ + type: TaskBridgeEventName.TaskModeSwitched, + taskId: task.taskId, + mode, + }) + } + + task.on(RooCodeEventName.TaskModeSwitched, onTaskModeSwitched) + listeners.set(TaskBridgeEventName.TaskModeSwitched, onTaskModeSwitched) + + const onTaskInteractive = (_taskId: string) => { + this.publishEvent({ + type: TaskBridgeEventName.TaskInteractive, + taskId: task.taskId, + }) + } + + task.on(RooCodeEventName.TaskInteractive, onTaskInteractive) + + listeners.set(TaskBridgeEventName.TaskInteractive, onTaskInteractive) + + this.taskListeners.set(task.taskId, listeners) + + console.log("[TaskManager] Task listeners setup complete for:", task.taskId) + } + + private removeListeners(task: TaskLike): void { + const listeners = this.taskListeners.get(task.taskId) + + if (!listeners) { + return + } + + console.log("[TaskManager] Removing task listeners for:", task.taskId) + + listeners.forEach((listener, eventName) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + task.off(TASK_EVENT_MAPPING[eventName], listener as any) + } catch (error) { + console.error( + `[TaskManager] Error removing listener for ${String(eventName)} on task ${task.taskId}:`, + error, + ) + } + }) + + this.taskListeners.delete(task.taskId) + } + + private async publishEvent(message: TaskBridgeEvent): Promise { + if (!this.socket) { + console.error("[TaskManager] publishEvent -> socket not available") + return false + } + + try { + this.socket.emit(TaskSocketEvents.EVENT, message) + + if (message.type !== TaskBridgeEventName.Message) { + console.log( + `[TaskManager] emit() -> ${TaskSocketEvents.EVENT} ${message.taskId} ${message.type}`, + message, + ) + } + + return true + } catch (error) { + console.error( + `[TaskManager] emit() failed -> ${TaskSocketEvents.EVENT}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + + return false + } + } +} diff --git a/packages/cloud/src/importVscode.ts b/packages/cloud/src/importVscode.ts index f389555afa..b3c3c94150 100644 --- a/packages/cloud/src/importVscode.ts +++ b/packages/cloud/src/importVscode.ts @@ -7,38 +7,43 @@ let vscodeModule: typeof import("vscode") | undefined /** - * Attempts to dynamically import the `vscode` module. - * Returns undefined if not running in a VSCode extension context. + * Attempts to dynamically import the VS Code module. + * Returns undefined if not running in a VS Code/Cursor extension context. */ export async function importVscode(): Promise { + // Check if already loaded if (vscodeModule) { return vscodeModule } try { + // Method 1: Check if vscode is available in global scope (common in extension hosts). + if (typeof globalThis !== "undefined" && "acquireVsCodeApi" in globalThis) { + // We're in a webview context, vscode module won't be available. + return undefined + } + + // Method 2: Try to require the module (works in most extension contexts). if (typeof require !== "undefined") { try { // eslint-disable-next-line @typescript-eslint/no-require-imports vscodeModule = require("vscode") if (vscodeModule) { - console.log("VS Code module loaded from require") return vscodeModule } } catch (error) { - console.error(`Error loading VS Code module: ${error instanceof Error ? error.message : String(error)}`) + console.error("Error loading VS Code module:", error) // Fall through to dynamic import. } } + // Method 3: Dynamic import (original approach, works in VSCode). vscodeModule = await import("vscode") - console.log("VS Code module loaded from dynamic import") return vscodeModule } catch (error) { - console.warn( - `VS Code module not available in this environment: ${error instanceof Error ? error.message : String(error)}`, - ) - + // Log the original error for debugging. + console.warn("VS Code module not available in this environment:", error) return undefined } } diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index dd40e6fc52..6ba2d3e61e 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -1,5 +1,5 @@ export * from "./config.js" -export { CloudService } from "./CloudService.js" - -export { BridgeOrchestrator } from "./bridge/index.js" +export * from "./CloudAPI.js" +export * from "./CloudService.js" +export * from "./bridge/ExtensionBridgeService.js" diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 46978350d6..1b1d0d9892 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.63.0", + "version": "1.64.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 827ec2d7da..b80c562fa3 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, queuedMessageSchema, tokenUsageSchema } from "./message.js" +import { clineMessageSchema } from "./message.js" import { staticAppPropertiesSchema, gitPropertiesSchema } from "./telemetry.js" /** @@ -359,11 +359,6 @@ 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(), - parentTaskId: z.string().optional(), - childTaskId: z.string().optional(), - tokenUsage: tokenUsageSchema.optional(), ...taskMetadataSchema.shape, }) @@ -383,10 +378,6 @@ export const extensionInstanceSchema = z.object({ task: extensionTaskSchema, taskAsk: clineMessageSchema.optional(), taskHistory: z.array(z.string()), - mode: z.string().optional(), - modes: z.array(z.object({ slug: z.string(), name: z.string() })).optional(), - providerProfile: z.string().optional(), - providerProfiles: z.array(z.object({ name: z.string(), provider: z.string().optional() })).optional(), }) export type ExtensionInstance = z.infer @@ -407,17 +398,6 @@ export enum ExtensionBridgeEventName { TaskResumable = RooCodeEventName.TaskResumable, TaskIdle = RooCodeEventName.TaskIdle, - TaskPaused = RooCodeEventName.TaskPaused, - TaskUnpaused = RooCodeEventName.TaskUnpaused, - TaskSpawned = RooCodeEventName.TaskSpawned, - - TaskUserMessage = RooCodeEventName.TaskUserMessage, - - TaskTokenUsageUpdated = RooCodeEventName.TaskTokenUsageUpdated, - - ModeChanged = RooCodeEventName.ModeChanged, - ProviderProfileChanged = RooCodeEventName.ProviderProfileChanged, - InstanceRegistered = "instance_registered", InstanceUnregistered = "instance_unregistered", HeartbeatUpdated = "heartbeat_updated", @@ -474,48 +454,6 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ instance: extensionInstanceSchema, timestamp: z.number(), }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskPaused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskUnpaused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskSpawned), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskUserMessage), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskTokenUsageUpdated), - 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, @@ -552,8 +490,6 @@ export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [ payload: z.object({ text: z.string(), images: z.array(z.string()).optional(), - mode: z.string().optional(), - providerProfile: z.string().optional(), }), timestamp: z.number(), }), @@ -566,7 +502,9 @@ export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(ExtensionBridgeCommandName.ResumeTask), instanceId: z.string(), - payload: z.object({ taskId: z.string() }), + payload: z.object({ + taskId: z.string(), + }), timestamp: z.number(), }), ]) @@ -620,8 +558,6 @@ export const taskBridgeCommandSchema = z.discriminatedUnion("type", [ payload: z.object({ text: z.string(), images: z.array(z.string()).optional(), - mode: z.string().optional(), - providerProfile: z.string().optional(), }), timestamp: z.number(), }), @@ -651,49 +587,32 @@ export type TaskBridgeCommand = z.infer * ExtensionSocketEvents */ -export enum ExtensionSocketEvents { - CONNECTED = "extension:connected", +export const ExtensionSocketEvents = { + CONNECTED: "extension:connected", - REGISTER = "extension:register", - UNREGISTER = "extension:unregister", + REGISTER: "extension:register", + UNREGISTER: "extension:unregister", - HEARTBEAT = "extension:heartbeat", + HEARTBEAT: "extension:heartbeat", - EVENT = "extension:event", // event from extension instance - RELAYED_EVENT = "extension:relayed_event", // relay from server + EVENT: "extension:event", // event from extension instance + RELAYED_EVENT: "extension:relayed_event", // relay from server - COMMAND = "extension:command", // command from user - RELAYED_COMMAND = "extension:relayed_command", // relay from server -} + COMMAND: "extension:command", // command from user + RELAYED_COMMAND: "extension:relayed_command", // relay from server +} as const /** * TaskSocketEvents */ -export enum TaskSocketEvents { - JOIN = "task:join", - LEAVE = "task:leave", +export const TaskSocketEvents = { + JOIN: "task:join", + LEAVE: "task:leave", - EVENT = "task:event", // event from extension task - RELAYED_EVENT = "task:relayed_event", // relay from server + EVENT: "task:event", // event from extension task + RELAYED_EVENT: "task:relayed_event", // relay from server - COMMAND = "task:command", // command from user - RELAYED_COMMAND = "task:relayed_command", // relay from server -} - -/** - * `emit()` Response Types - */ - -export type JoinResponse = { - success: boolean - error?: string - taskId?: string - timestamp?: string -} - -export type LeaveResponse = { - success: boolean - taskId?: string - timestamp?: string -} + COMMAND: "task:command", // command from user + RELAYED_COMMAND: "task:relayed_command", // relay from server +} as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e01247290..f9ccd8512a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: ^4.19.3 version: 4.19.4 turbo: - specifier: ^2.5.3 - version: 2.5.4 + specifier: ^2.5.6 + version: 2.5.6 typescript: specifier: ^5.4.5 version: 5.8.3 @@ -285,8 +285,8 @@ importers: specifier: ^8.6.0 version: 8.6.0(react@18.3.1) framer-motion: - specifier: ^12.15.0 - version: 12.16.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 12.15.0 + version: 12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.518.0 version: 0.518.0(react@18.3.1) @@ -371,6 +371,46 @@ importers: specifier: ^3.2.3 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + packages/cloud: + dependencies: + '@roo-code/types': + specifier: workspace:^ + version: link:../types + ioredis: + specifier: ^5.6.1 + version: 5.6.1 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 + p-wait-for: + specifier: ^5.0.2 + version: 5.0.2 + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@roo-code/config-eslint': + specifier: workspace:^ + version: link:../config-eslint + '@roo-code/config-typescript': + specifier: workspace:^ + version: link:../config-typescript + '@types/node': + specifier: ^24.1.0 + version: 24.2.1 + '@types/vscode': + specifier: ^1.102.0 + version: 1.103.0 + globals: + specifier: ^16.3.0 + version: 16.3.0 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + packages/config-eslint: devDependencies: '@eslint/js': @@ -396,7 +436,7 @@ importers: version: 5.2.0(eslint@9.27.0(jiti@2.4.2)) eslint-plugin-turbo: specifier: ^2.4.4 - version: 2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.4) + version: 2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.6) globals: specifier: ^16.0.0 version: 16.1.0 @@ -578,14 +618,14 @@ importers: specifier: ^1.9.18 version: 1.9.18(zod@3.25.61) '@modelcontextprotocol/sdk': - specifier: ^1.9.0 + specifier: 1.12.0 version: 1.12.0 '@qdrant/js-client-rest': specifier: ^1.14.0 version: 1.14.0(typescript@5.8.3) '@roo-code/cloud': - specifier: ^0.29.0 - version: 0.29.0 + specifier: workspace:^ + version: link:../packages/cloud '@roo-code/ipc': specifier: workspace:^ version: link:../packages/ipc @@ -595,9 +635,6 @@ importers: '@roo-code/types': specifier: workspace:^ version: link:../packages/types - '@types/lodash.debounce': - specifier: ^4.0.9 - version: 4.0.9 '@vscode/codicons': specifier: ^0.0.36 version: 0.0.36 @@ -803,6 +840,9 @@ importers: '@types/glob': specifier: ^8.1.0 version: 8.1.0 + '@types/lodash.debounce': + specifier: ^4.0.9 + version: 4.0.9 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -3346,12 +3386,6 @@ packages: cpu: [x64] os: [win32] - '@roo-code/cloud@0.29.0': - resolution: {integrity: sha512-fXN0mdkd5GezpVrCspe6atUkwvSk5D4wF80g+lc8E3aPVqEAozoI97kHNulRChGlBw7UIdd5xxbr1Z8Jtn+S/Q==} - - '@roo-code/types@1.63.0': - resolution: {integrity: sha512-pX8ftkDq1CySBbkUTIW9/QEG52ttFT/kl0ID286l0L3W22wpGRUct6PCedNI9kLDM4s5sxaUeZx7b3rUChikkw==} - '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -4225,6 +4259,9 @@ packages: '@types/vscode@1.100.0': resolution: {integrity: sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==} + '@types/vscode@1.103.0': + resolution: {integrity: sha512-o4hanZAQdNfsKecexq9L3eHICd0AAvdbLk6hA60UzGXbGH/q8b/9xv2RgR7vV3ZcHuyKVq7b37IGd/+gM4Tu+Q==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -6129,8 +6166,8 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - framer-motion@12.16.0: - resolution: {integrity: sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==} + framer-motion@12.15.0: + resolution: {integrity: sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -9470,38 +9507,38 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - turbo-darwin-64@2.5.4: - resolution: {integrity: sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ==} + turbo-darwin-64@2.5.6: + resolution: {integrity: sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.5.4: - resolution: {integrity: sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A==} + turbo-darwin-arm64@2.5.6: + resolution: {integrity: sha512-LyiG+rD7JhMfYwLqB6k3LZQtYn8CQQUePbpA8mF/hMLPAekXdJo1g0bUPw8RZLwQXUIU/3BU7tXENvhSGz5DPA==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.5.4: - resolution: {integrity: sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA==} + turbo-linux-64@2.5.6: + resolution: {integrity: sha512-GOcUTT0xiT/pSnHL4YD6Yr3HreUhU8pUcGqcI2ksIF9b2/r/kRHwGFcsHgpG3+vtZF/kwsP0MV8FTlTObxsYIA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.5.4: - resolution: {integrity: sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg==} + turbo-linux-arm64@2.5.6: + resolution: {integrity: sha512-10Tm15bruJEA3m0V7iZcnQBpObGBcOgUcO+sY7/2vk1bweW34LMhkWi8svjV9iDF68+KJDThnYDlYE/bc7/zzQ==} cpu: [arm64] os: [linux] - turbo-windows-64@2.5.4: - resolution: {integrity: sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA==} + turbo-windows-64@2.5.6: + resolution: {integrity: sha512-FyRsVpgaj76It0ludwZsNN40ytHN+17E4PFJyeliBEbxrGTc5BexlXVpufB7XlAaoaZVxbS6KT8RofLfDRyEPg==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.5.4: - resolution: {integrity: sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A==} + turbo-windows-arm64@2.5.6: + resolution: {integrity: sha512-j/tWu8cMeQ7HPpKri6jvKtyXg9K1gRyhdK4tKrrchH8GNHscPX/F71zax58yYtLRWTiK04zNzPcUJuoS0+v/+Q==} cpu: [arm64] os: [win32] - turbo@2.5.4: - resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==} + turbo@2.5.6: + resolution: {integrity: sha512-gxToHmi9oTBNB05UjUsrWf0OyN5ZXtD0apOarC1KIx232Vp3WimRNy3810QzeNSgyD5rsaIDXlxlbnOzlouo+w==} hasBin: true turndown@7.2.0: @@ -11424,8 +11461,8 @@ snapshots: '@modelcontextprotocol/sdk': 1.12.0 google-auth-library: 9.15.1 ws: 8.18.2 - zod: 3.25.61 - zod-to-json-schema: 3.24.5(zod@3.25.61) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - bufferutil - encoding @@ -11684,8 +11721,8 @@ snapshots: '@lmstudio/lms-isomorphic': 0.4.5 chalk: 4.1.2 jsonschema: 1.5.0 - zod: 3.25.61 - zod-to-json-schema: 3.24.5(zod@3.25.61) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11751,8 +11788,8 @@ snapshots: express-rate-limit: 7.5.0(express@5.1.0) pkce-challenge: 5.0.0 raw-body: 3.0.0 - zod: 3.25.61 - zod-to-json-schema: 3.24.5(zod@3.25.61) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -12732,23 +12769,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.2': optional: true - '@roo-code/cloud@0.29.0': - dependencies: - '@roo-code/types': 1.63.0 - ioredis: 5.6.1 - jwt-decode: 4.0.0 - p-wait-for: 5.0.2 - socket.io-client: 4.8.1 - zod: 3.25.76 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@roo-code/types@1.63.0': - dependencies: - zod: 3.25.76 - '@sec-ant/readable-stream@0.4.1': {} '@sevinf/maybe@0.5.0': {} @@ -13799,6 +13819,8 @@ snapshots: '@types/vscode@1.100.0': {} + '@types/vscode@1.103.0': {} + '@types/ws@8.18.1': dependencies: '@types/node': 24.2.1 @@ -15551,11 +15573,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.4): + eslint-plugin-turbo@2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.6): dependencies: dotenv: 16.0.3 eslint: 9.27.0(jiti@2.4.2) - turbo: 2.5.4 + turbo: 2.5.6 eslint-scope@8.3.0: dependencies: @@ -16026,7 +16048,7 @@ snapshots: fraction.js@4.3.7: {} - framer-motion@12.16.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@12.15.0(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: motion-dom: 12.16.0 motion-utils: 12.12.1 @@ -19977,32 +19999,32 @@ snapshots: tunnel@0.0.6: {} - turbo-darwin-64@2.5.4: + turbo-darwin-64@2.5.6: optional: true - turbo-darwin-arm64@2.5.4: + turbo-darwin-arm64@2.5.6: optional: true - turbo-linux-64@2.5.4: + turbo-linux-64@2.5.6: optional: true - turbo-linux-arm64@2.5.4: + turbo-linux-arm64@2.5.6: optional: true - turbo-windows-64@2.5.4: + turbo-windows-64@2.5.6: optional: true - turbo-windows-arm64@2.5.4: + turbo-windows-arm64@2.5.6: optional: true - turbo@2.5.4: + turbo@2.5.6: optionalDependencies: - turbo-darwin-64: 2.5.4 - turbo-darwin-arm64: 2.5.4 - turbo-linux-64: 2.5.4 - turbo-linux-arm64: 2.5.4 - turbo-windows-64: 2.5.4 - turbo-windows-arm64: 2.5.4 + turbo-darwin-64: 2.5.6 + turbo-darwin-arm64: 2.5.6 + turbo-linux-64: 2.5.6 + turbo-linux-arm64: 2.5.6 + turbo-windows-64: 2.5.6 + turbo-windows-arm64: 2.5.6 turndown@7.2.0: dependencies: @@ -20836,6 +20858,10 @@ snapshots: dependencies: zod: 3.25.61 + zod-to-json-schema@3.24.5(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.61): dependencies: typescript: 5.8.3 diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d41c0ee3ba..320a9ff024 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -30,8 +30,6 @@ import { type TerminalActionPromptType, type HistoryItem, type CloudUserInfo, - type CreateTaskOptions, - type TokenUsage, RooCodeEventName, requestyDefaultModelId, openRouterDefaultModelId, @@ -39,16 +37,15 @@ import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, DEFAULT_WRITE_DELAY_MS, ORGANIZATION_ALLOW_ALL, - DEFAULT_MODES, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" +import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" import { Package } from "../../shared/package" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" -import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage" +import { ExtensionMessage, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage" import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" import { experimentDefault } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" @@ -73,7 +70,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" import { getWorkspaceGitInfo } from "../../utils/git" import { getWorkspacePath } from "../../utils/path" -import { OrganizationAllowListViolationError } from "../../utils/errors" +import { isRemoteControlEnabled } from "../../utils/remoteControl" import { setPanel } from "../../activate/registerCommands" @@ -85,7 +82,7 @@ import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/provi import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" -import { Task } from "../task/Task" +import { Task, TaskOptions } from "../task/Task" import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt" import { webviewMessageHandler } from "./webviewMessageHandler" @@ -98,20 +95,6 @@ import { FCOMessageHandler } from "../../services/file-changes/FCOMessageHandler * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts */ -export type ClineProviderEvents = { - clineCreated: [cline: Task] -} - -interface PendingEditOperation { - messageTs: number - editedContent: string - images?: string[] - messageIndex: number - apiConversationHistoryIndex: number - timeoutId: NodeJS.Timeout - createdAt: number -} - export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -127,18 +110,15 @@ export class ClineProvider private view?: vscode.WebviewView | vscode.WebviewPanel private clineStack: Task[] = [] private codeIndexStatusSubscription?: vscode.Disposable - private codeIndexManager?: CodeIndexManager + private currentWorkspaceManager?: CodeIndexManager private _workspaceTracker?: WorkspaceTracker // workSpaceTracker read-only for access outside this class protected mcpHub?: McpHub // Change from private to protected private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void private taskEventListeners: WeakMap void>> = new WeakMap() - private currentWorkspacePath: string | undefined private recentTasksCache?: string[] - private pendingOperations: Map = new Map() - private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private globalFileChangeManager?: import("../../services/file-changes/FileChangeManager").FileChangeManager public isViewLaunched = false @@ -155,8 +135,8 @@ export class ClineProvider mdmService?: MdmService, ) { super() - this.currentWorkspacePath = getWorkspacePath() + this.log("ClineProvider instantiated") ClineProvider.activeInstances.add(this) this.mdmService = mdmService @@ -189,8 +169,6 @@ export class ClineProvider this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) - // Forward task events to the provider. - // We do something fairly similar for the IPC-based API. this.taskCreationCallback = (instance: Task) => { this.emit(RooCodeEventName.TaskCreated, instance) @@ -205,12 +183,6 @@ 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 onTaskPaused = (taskId: string) => this.emit(RooCodeEventName.TaskPaused, taskId) - const onTaskUnpaused = (taskId: string) => this.emit(RooCodeEventName.TaskUnpaused, taskId) - const onTaskSpawned = (taskId: string) => this.emit(RooCodeEventName.TaskSpawned, taskId) - const onTaskUserMessage = (taskId: string) => this.emit(RooCodeEventName.TaskUserMessage, taskId) - const onTaskTokenUsageUpdated = (taskId: string, tokenUsage: TokenUsage) => - this.emit(RooCodeEventName.TaskTokenUsageUpdated, taskId, tokenUsage) // Attach the listeners. instance.on(RooCodeEventName.TaskStarted, onTaskStarted) @@ -222,11 +194,6 @@ export class ClineProvider instance.on(RooCodeEventName.TaskInteractive, onTaskInteractive) instance.on(RooCodeEventName.TaskResumable, onTaskResumable) instance.on(RooCodeEventName.TaskIdle, onTaskIdle) - instance.on(RooCodeEventName.TaskPaused, onTaskPaused) - instance.on(RooCodeEventName.TaskUnpaused, onTaskUnpaused) - instance.on(RooCodeEventName.TaskSpawned, onTaskSpawned) - instance.on(RooCodeEventName.TaskUserMessage, onTaskUserMessage) - instance.on(RooCodeEventName.TaskTokenUsageUpdated, onTaskTokenUsageUpdated) // Store the cleanup functions for later removal. this.taskEventListeners.set(instance, [ @@ -239,22 +206,13 @@ export class ClineProvider () => instance.off(RooCodeEventName.TaskInteractive, onTaskInteractive), () => instance.off(RooCodeEventName.TaskResumable, onTaskResumable), () => instance.off(RooCodeEventName.TaskIdle, onTaskIdle), - () => instance.off(RooCodeEventName.TaskUserMessage, onTaskUserMessage), - () => instance.off(RooCodeEventName.TaskPaused, onTaskPaused), - () => instance.off(RooCodeEventName.TaskUnpaused, onTaskUnpaused), - () => instance.off(RooCodeEventName.TaskSpawned, onTaskSpawned), - () => instance.off(RooCodeEventName.TaskTokenUsageUpdated, onTaskTokenUsageUpdated), ]) } // Initialize Roo Code Cloud profile sync. - if (CloudService.hasInstance()) { - this.initializeCloudProfileSync().catch((error) => { - this.log(`Failed to initialize cloud profile sync: ${error}`) - }) - } else { - this.log("CloudService not ready, deferring cloud profile sync") - } + this.initializeCloudProfileSync().catch((error) => { + this.log(`Failed to initialize cloud profile sync: ${error}`) + }) } /** @@ -308,29 +266,27 @@ export class ClineProvider } /** - * Synchronize cloud profiles with local profiles. + * Synchronize cloud profiles with local profiles */ private async syncCloudProfiles() { try { const settings = CloudService.instance.getOrganizationSettings() - if (!settings?.providerProfiles) { return } const currentApiConfigName = this.getGlobalState("currentApiConfigName") - const result = await this.providerSettingsManager.syncCloudProfiles( settings.providerProfiles, currentApiConfigName, ) if (result.hasChanges) { - // Update list. + // Update list await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) if (result.activeProfileChanged && result.activeProfileId) { - // Reload full settings for new active profile. + // Reload full settings for new active profile const profile = await this.providerSettingsManager.getProfile({ id: result.activeProfileId, }) @@ -344,32 +300,13 @@ export class ClineProvider } } - /** - * Initialize cloud profile synchronization when CloudService is ready - * This method is called externally after CloudService has been initialized - */ - public async initializeCloudProfileSyncWhenReady(): Promise { - try { - if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) { - await this.syncCloudProfiles() - } - - if (CloudService.hasInstance()) { - CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) - CloudService.instance.on("settings-updated", this.handleCloudSettingsUpdate) - } - } catch (error) { - this.log(`Failed to initialize cloud profile sync when ready: ${error}`) - } - } - // Adds a new Task instance to clineStack, marking the start of a new task. // The instance is pushed to the top of the stack (LIFO order). - // When the task is completed, the top instance is removed, reactivating the - // previous task. + // When the task is completed, the top instance is removed, reactivating the previous task. async addClineToStack(task: Task) { - // Add this cline instance into the stack that represents the order of - // all the called tasks. + console.log(`[subtasks] adding task ${task.taskId}.${task.instanceId} to stack`) + + // Add this cline instance into the stack that represents the order of all the called tasks. this.clineStack.push(task) task.emit(RooCodeEventName.TaskFocused) @@ -413,7 +350,7 @@ export class ClineProvider let task = this.clineStack.pop() if (task) { - task.emit(RooCodeEventName.TaskUnfocused) + console.log(`[subtasks] removing task ${task.taskId}.${task.instanceId} from stack`) try { // Abort the running task and set isAbandoned to true so @@ -421,10 +358,12 @@ export class ClineProvider await task.abortTask(true) } catch (e) { this.log( - `[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`, + `[subtasks] encountered error while aborting task ${task.taskId}.${task.instanceId}: ${e.message}`, ) } + task.emit(RooCodeEventName.TaskUnfocused) + // Remove event listeners before clearing the reference. const cleanupFunctions = this.taskEventListeners.get(task) @@ -439,6 +378,16 @@ export class ClineProvider } } + // returns the current cline object in the stack (the top one) + // if the stack is empty, returns undefined + getCurrentTask(): Task | undefined { + if (this.clineStack.length === 0) { + return undefined + } + return this.clineStack[this.clineStack.length - 1] + } + + // returns the current clineStack length (how many cline objects are in the stack) getTaskStackSize(): number { return this.clineStack.length } @@ -447,83 +396,73 @@ export class ClineProvider return this.clineStack.map((cline) => cline.taskId) } - // Remove the current task/cline instance (at the top of the stack), so this - // task is finished and resume the previous task/cline instance (if it - // exists). - // This is used when a subtask is finished and the parent task needs to be - // resumed. + // remove the current task/cline instance (at the top of the stack), so this task is finished + // and resume the previous task/cline instance (if it exists) + // this is used when a sub task is finished and the parent task needs to be resumed async finishSubTask(lastMessage: string) { - // Remove the last cline instance from the stack (this is the finished - // subtask). + console.log(`[subtasks] finishing subtask ${lastMessage}`) + // remove the last cline instance from the stack (this is the finished sub task) await this.removeClineFromStack() - // Resume the last cline instance in the stack (if it exists - this is - // the 'parent' calling task). - await this.getCurrentTask()?.completeSubtask(lastMessage) + // resume the last cline instance in the stack (if it exists - this is the 'parent' calling task) + await this.getCurrentTask()?.resumePausedTask(lastMessage) } - // Pending Edit Operations Management - /** - * Sets a pending edit operation with automatic timeout cleanup - */ - public setPendingEditOperation( - operationId: string, - editData: { - messageTs: number - editedContent: string - images?: string[] - messageIndex: number - apiConversationHistoryIndex: number - }, - ): void { - // Clear any existing operation with the same ID - this.clearPendingEditOperation(operationId) + // Clear the current task without treating it as a subtask + // This is used when the user cancels a task that is not a subtask + async clearTask() { + await this.removeClineFromStack() + } - // Create timeout for automatic cleanup - const timeoutId = setTimeout(() => { - this.clearPendingEditOperation(operationId) - this.log(`[setPendingEditOperation] Automatically cleared stale pending operation: ${operationId}`) - }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) - - // Store the operation - this.pendingOperations.set(operationId, { - ...editData, - timeoutId, - createdAt: Date.now(), + resumeTask(taskId: string): void { + // Use the existing showTaskWithId method which handles both current and historical tasks + this.showTaskWithId(taskId).catch((error) => { + this.log(`Failed to resume task ${taskId}: ${error.message}`) }) - - this.log(`[setPendingEditOperation] Set pending operation: ${operationId}`) } - /** - * Gets a pending edit operation by ID - */ - private getPendingEditOperation(operationId: string): PendingEditOperation | undefined { - return this.pendingOperations.get(operationId) - } - - /** - * Clears a specific pending edit operation - */ - private clearPendingEditOperation(operationId: string): boolean { - const operation = this.pendingOperations.get(operationId) - if (operation) { - clearTimeout(operation.timeoutId) - this.pendingOperations.delete(operationId) - this.log(`[clearPendingEditOperation] Cleared pending operation: ${operationId}`) - return true + getRecentTasks(): string[] { + if (this.recentTasksCache) { + return this.recentTasksCache } - return false - } - /** - * Clears all pending edit operations - */ - private clearAllPendingEditOperations(): void { - for (const [operationId, operation] of this.pendingOperations) { - clearTimeout(operation.timeoutId) + const history = this.getGlobalState("taskHistory") ?? [] + const workspaceTasks: HistoryItem[] = [] + + for (const item of history) { + if (!item.ts || !item.task || item.workspace !== this.cwd) { + continue + } + + workspaceTasks.push(item) } - this.pendingOperations.clear() - this.log(`[clearAllPendingEditOperations] Cleared all pending operations`) + + if (workspaceTasks.length === 0) { + this.recentTasksCache = [] + return this.recentTasksCache + } + + workspaceTasks.sort((a, b) => b.ts - a.ts) + let recentTaskIds: string[] = [] + + if (workspaceTasks.length >= 100) { + // If we have at least 100 tasks, return tasks from the last 7 days. + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 + + for (const item of workspaceTasks) { + // Stop when we hit tasks older than 7 days. + if (item.ts < sevenDaysAgo) { + break + } + + recentTaskIds.push(item.id) + } + } else { + // Otherwise, return the most recent 100 tasks (or all if less than 100). + recentTaskIds = workspaceTasks.slice(0, Math.min(100, workspaceTasks.length)).map((item) => item.id) + } + + this.recentTasksCache = recentTaskIds + return this.recentTasksCache } /* @@ -550,10 +489,6 @@ export class ClineProvider this.log("Cleared all tasks") - // Clear all pending edit operations to prevent memory leaks - this.clearAllPendingEditOperations() - this.log("Cleared pending operations") - if (this.view && "dispose" in this.view) { this.view.dispose() this.log("Disposed webview") @@ -690,6 +625,8 @@ export class ClineProvider } async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + this.log("Resolving webview view") + this.view = webviewView const inTabMode = "onDidChangeViewState" in webviewView @@ -731,17 +668,9 @@ export class ClineProvider setTtsSpeed(ttsSpeed ?? 1) }) - // Set up webview options with proper resource roots - const resourceRoots = [this.contextProxy.extensionUri] - - // Add workspace folders to allow access to workspace files - if (vscode.workspace.workspaceFolders) { - resourceRoots.push(...vscode.workspace.workspaceFolders.map((folder) => folder.uri)) - } - webviewView.webview.options = { enableScripts: true, - localResourceRoots: resourceRoots, + localResourceRoots: [this.contextProxy.extensionUri], } webviewView.webview.html = @@ -798,7 +727,7 @@ export class ClineProvider this.log("Clearing webview resources for sidebar view") this.clearWebviewResources() // Reset current workspace manager reference when view is disposed - this.codeIndexManager = undefined + this.currentWorkspaceManager = undefined } }, null, @@ -816,6 +745,73 @@ export class ClineProvider // If the extension is starting a new session, clear previous task state. await this.removeClineFromStack() + + this.log("Webview view resolved") + } + + // When initializing a new task, (not from history but from a tool command + // new_task) there is no need to remove the previous task since the new + // task is a subtask of the previous one, and when it finishes it is removed + // from the stack and the caller is resumed in this way we can have a chain + // of tasks, each one being a sub task of the previous one until the main + // task is finished. + public async createTask( + text?: string, + images?: string[], + parentTask?: Task, + options: Partial< + Pick< + TaskOptions, + | "enableDiff" + | "enableCheckpoints" + | "fuzzyMatchThreshold" + | "consecutiveMistakeLimit" + | "experiments" + | "initialTodos" + > + > = {}, + ) { + const { + apiConfiguration, + organizationAllowList, + diffEnabled: enableDiff, + enableCheckpoints, + fuzzyMatchThreshold, + experiments, + cloudUserInfo, + remoteControlEnabled, + } = await this.getState() + + if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { + throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) + } + + const task = new Task({ + provider: this, + apiConfiguration, + enableDiff, + enableCheckpoints, + fuzzyMatchThreshold, + consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, + task: text, + images, + experiments, + rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined, + parentTask, + taskNumber: this.clineStack.length + 1, + onCreated: this.taskCreationCallback, + enableTaskBridge: isRemoteControlEnabled(cloudUserInfo, remoteControlEnabled), + initialTodos: options.initialTodos, + ...options, + }) + + await this.addClineToStack(task) + + this.log( + `[subtasks] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, + ) + + return task } public async createTaskWithHistoryItem( @@ -823,14 +819,14 @@ export class ClineProvider ) { await this.removeClineFromStack() - // If the history item has a saved mode, restore it and its associated API configuration. + // If the history item has a saved mode, restore it and its associated API configuration if (historyItem.mode) { // Validate that the mode still exists const customModes = await this.customModesManager.getCustomModes() const modeExists = getModeBySlug(historyItem.mode, customModes) !== undefined if (!modeExists) { - // Mode no longer exists, fall back to default mode. + // Mode no longer exists, fall back to default mode this.log( `Mode '${historyItem.mode}' from history no longer exists. Falling back to default mode '${defaultModeSlug}'.`, ) @@ -839,14 +835,14 @@ export class ClineProvider await this.updateGlobalState("mode", historyItem.mode) - // Load the saved API config for the restored mode if it exists. + // Load the saved API config for the restored mode if it exists const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) const listApiConfig = await this.providerSettingsManager.listConfig() - // Update listApiConfigMeta first to ensure UI has latest data. + // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig) - // If this mode has a saved config, use it. + // If this mode has a saved config, use it if (savedConfigId) { const profile = listApiConfig.find(({ id }) => id === savedConfigId) @@ -854,13 +850,13 @@ export class ClineProvider try { await this.activateProviderProfile({ name: profile.name }) } catch (error) { - // Log the error but continue with task restoration. + // Log the error but continue with task restoration this.log( `Failed to restore API configuration for mode '${historyItem.mode}': ${ error instanceof Error ? error.message : String(error) }. Continuing with default configuration.`, ) - // The task will continue with the current/default configuration. + // The task will continue with the current/default configuration } } } @@ -876,6 +872,9 @@ export class ClineProvider remoteControlEnabled, } = await this.getState() + // Determine if TaskBridge should be enabled + const enableTaskBridge = isRemoteControlEnabled(cloudUserInfo, remoteControlEnabled) + const task = new Task({ provider: this, apiConfiguration, @@ -888,60 +887,16 @@ export class ClineProvider rootTask: historyItem.rootTask, parentTask: historyItem.parentTask, taskNumber: historyItem.number, - workspacePath: historyItem.workspace, onCreated: this.taskCreationCallback, - enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), + enableTaskBridge, }) await this.addClineToStack(task) this.log( - `[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, + `[subtasks] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, ) - // Check if there's a pending edit after checkpoint restoration - const operationId = `task-${task.taskId}` - const pendingEdit = this.getPendingEditOperation(operationId) - if (pendingEdit) { - this.clearPendingEditOperation(operationId) // Clear the pending edit - - this.log(`[createTaskWithHistoryItem] Processing pending edit after checkpoint restoration`) - - // Process the pending edit after a short delay to ensure the task is fully initialized - setTimeout(async () => { - try { - // Find the message index in the restored state - const { messageIndex, apiConversationHistoryIndex } = (() => { - const messageIndex = task.clineMessages.findIndex((msg) => msg.ts === pendingEdit.messageTs) - const apiConversationHistoryIndex = task.apiConversationHistory.findIndex( - (msg) => msg.ts === pendingEdit.messageTs, - ) - return { messageIndex, apiConversationHistoryIndex } - })() - - if (messageIndex !== -1) { - // Remove the target message and all subsequent messages - await task.overwriteClineMessages(task.clineMessages.slice(0, messageIndex)) - - if (apiConversationHistoryIndex !== -1) { - await task.overwriteApiConversationHistory( - task.apiConversationHistory.slice(0, apiConversationHistoryIndex), - ) - } - - // Process the edited message - await task.handleWebviewAskResponse( - "messageResponse", - pendingEdit.editedContent, - pendingEdit.images, - ) - } - } catch (error) { - this.log(`[createTaskWithHistoryItem] Error processing pending edit: ${error}`) - } - }, 100) // Small delay to ensure task is fully ready - } - // Restore preserved FCO state if provided (from task abort/cancel) if (historyItem.preservedFCOState) { try { @@ -1177,45 +1132,45 @@ export class ClineProvider * @param newMode The mode to switch to */ public async handleModeSwitch(newMode: Mode) { - const task = this.getCurrentTask() + const cline = this.getCurrentTask() - if (task) { - TelemetryService.instance.captureModeSwitch(task.taskId, newMode) - task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + if (cline) { + TelemetryService.instance.captureModeSwitch(cline.taskId, newMode) + cline.emit(RooCodeEventName.TaskModeSwitched, cline.taskId, newMode) + + // Store the current mode in case we need to rollback + const previousMode = (cline as any)._taskMode try { - // Update the task history with the new mode first. + // Update the task history with the new mode first const history = this.getGlobalState("taskHistory") ?? [] - const taskHistoryItem = history.find((item) => item.id === task.taskId) - + const taskHistoryItem = history.find((item) => item.id === cline.taskId) if (taskHistoryItem) { taskHistoryItem.mode = newMode await this.updateTaskHistory(taskHistoryItem) } - // Only update the task's mode after successful persistence. - ;(task as any)._taskMode = newMode + // Only update the task's mode after successful persistence + ;(cline as any)._taskMode = newMode } catch (error) { - // If persistence fails, log the error but don't update the in-memory state. + // If persistence fails, log the error but don't update the in-memory state this.log( - `Failed to persist mode switch for task ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to persist mode switch for task ${cline.taskId}: ${error instanceof Error ? error.message : String(error)}`, ) - // Optionally, we could emit an event to notify about the failure. - // This ensures the in-memory state remains consistent with persisted state. + // Optionally, we could emit an event to notify about the failure + // This ensures the in-memory state remains consistent with persisted state throw error } } await this.updateGlobalState("mode", newMode) - this.emit(RooCodeEventName.ModeChanged, newMode) - - // Load the saved API config for the new mode if it exists. + // Load the saved API config for the new mode if it exists const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) const listApiConfig = await this.providerSettingsManager.listConfig() - // Update listApiConfigMeta first to ensure UI has latest data. + // Update listApiConfigMeta first to ensure UI has latest data await this.updateGlobalState("listApiConfigMeta", listApiConfig) // If this mode has a saved config, use it. @@ -1358,10 +1313,63 @@ export class ClineProvider } await this.postStateToWebview() + } - if (providerSettings.apiProvider) { - this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider }) + // Task Management + + async cancelTask() { + const cline = this.getCurrentTask() + + if (!cline) { + return } + + console.log(`[subtasks] cancelling task ${cline.taskId}.${cline.instanceId}`) + + const { historyItem } = await this.getTaskWithId(cline.taskId) + // Preserve parent and root task information for history item. + const rootTask = cline.rootTask + const parentTask = cline.parentTask + + // Preserve FCO state before aborting task to prevent FCO from disappearing + let preservedFCOState: any = undefined + try { + const fileChangeManager = this.getFileChangeManager() + if (fileChangeManager) { + preservedFCOState = fileChangeManager.getChanges() + this.log(`[cancelTask] Preserved FCO state with ${preservedFCOState.files.length} files`) + } + } catch (error) { + this.log(`[cancelTask] Failed to preserve FCO state: ${error}`) + } + + cline.abortTask() + + await pWaitFor( + () => + this.getCurrentTask()! === undefined || + this.getCurrentTask()!.isStreaming === false || + this.getCurrentTask()!.didFinishAbortingStream || + // If only the first chunk is processed, then there's no + // need to wait for graceful abort (closes edits, browser, + // etc). + this.getCurrentTask()!.isWaitingForFirstChunk, + { + timeout: 3_000, + }, + ).catch(() => { + console.error("Failed to abort task") + }) + + if (this.getCurrentTask()) { + // 'abandoned' will prevent this Cline instance from affecting + // future Cline instances. This may happen if its hanging on a + // streaming request. + this.getCurrentTask()!.abandoned = true + } + + // Clears task again, so we need to abortTask manually above. + await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask, preservedFCOState }) } async updateCustomInstructions(instructions?: string) { @@ -1404,16 +1412,14 @@ export class ClineProvider // OpenRouter async handleOpenRouterCallback(code: string) { - let { apiConfiguration, currentApiConfigName = "default" } = await this.getState() + let { apiConfiguration, currentApiConfigName } = 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 { @@ -1423,7 +1429,6 @@ export class ClineProvider this.log( `Error exchanging code for API key: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) - throw error } @@ -1441,10 +1446,8 @@ 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 { @@ -1454,11 +1457,10 @@ export class ClineProvider this.log( `Error exchanging code for API key: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) - throw error } - const { apiConfiguration, currentApiConfigName = "default" } = await this.getState() + const { apiConfiguration, currentApiConfigName } = await this.getState() const newConfiguration: ProviderSettings = { ...apiConfiguration, @@ -1473,7 +1475,7 @@ export class ClineProvider // Requesty async handleRequestyCallback(code: string) { - let { apiConfiguration, currentApiConfigName = "default" } = await this.getState() + let { apiConfiguration, currentApiConfigName } = await this.getState() const newConfiguration: ProviderSettings = { ...apiConfiguration, @@ -1611,11 +1613,6 @@ export class ClineProvider await this.postStateToWebview() } - async refreshWorkspace() { - this.currentWorkspacePath = getWorkspacePath() - await this.postStateToWebview() - } - async postStateToWebview() { const state = await this.getStateToPostToWebview() this.postMessageToWebview({ type: "state", state }) @@ -1623,7 +1620,7 @@ export class ClineProvider // Check MDM compliance and send user to account tab if not compliant // Only redirect if there's an actual MDM policy requiring authentication if (this.mdmService?.requiresCloudAuth() && !this.checkMdmCompliance()) { - await this.postMessageToWebview({ type: "action", action: "cloudButtonClicked" }) + await this.postMessageToWebview({ type: "action", action: "accountButtonClicked" }) } } @@ -1653,7 +1650,6 @@ export class ClineProvider }) } catch (error) { console.error("Failed to fetch marketplace data:", error) - // Send empty data on error to prevent UI from hanging this.postMessageToWebview({ type: "marketplaceData", @@ -1736,7 +1732,7 @@ export class ClineProvider } } - async getStateToPostToWebview(): Promise { + async getStateToPostToWebview() { const { apiConfiguration, lastShownAnnouncementId, @@ -1824,9 +1820,6 @@ export class ClineProvider maxDiagnosticMessages, includeTaskHistoryInEnhance, remoteControlEnabled, - openRouterImageApiKey, - openRouterImageGenerationSelectedModel, - openRouterUseMiddleOutTransform, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1864,7 +1857,6 @@ 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), @@ -1960,9 +1952,6 @@ export class ClineProvider includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, remoteControlEnabled: remoteControlEnabled ?? false, filesChangedEnabled: this.getGlobalState("filesChangedEnabled") ?? true, - openRouterImageApiKey, - openRouterImageGenerationSelectedModel, - openRouterUseMiddleOutTransform, } } @@ -1972,17 +1961,7 @@ export class ClineProvider * https://www.eliostruyf.com/devhack-code-extension-storage-options/ */ - async getState(): Promise< - Omit< - ExtensionState, - | "clineMessages" - | "renderContext" - | "hasOpenedModeSelector" - | "version" - | "shouldShowAnnouncement" - | "hasSystemPromptOverride" - > - > { + async getState() { const stateValues = this.contextProxy.getValues() const customModes = await this.customModesManager.getCustomModes() @@ -2050,7 +2029,7 @@ export class ClineProvider ) } - // Return the same structure as before. + // Return the same structure as before return { apiConfiguration: providerSettings, lastShownAnnouncementId: stateValues.lastShownAnnouncementId, @@ -2074,7 +2053,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, @@ -2121,7 +2100,7 @@ export class ClineProvider customModes, maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform, + openRouterUseMiddleOutTransform: stateValues.openRouterUseMiddleOutTransform ?? true, browserToolEnabled: stateValues.browserToolEnabled ?? true, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, @@ -2135,6 +2114,7 @@ export class ClineProvider sharingEnabled, organizationAllowList, organizationSettingsVersion, + // Explicitly add condensing settings condensingApiConfigId: stateValues.condensingApiConfigId, customCondensingPrompt: stateValues.customCondensingPrompt, codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, @@ -2154,22 +2134,13 @@ 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, - remoteControlEnabled: (() => { - try { - const cloudSettings = CloudService.instance.getUserSettings() - return cloudSettings?.settings?.extensionBridgeEnabled ?? false - } catch (error) { - console.error( - `[getState] failed to get remote control setting from cloud: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - })(), - openRouterImageApiKey: stateValues.openRouterImageApiKey, - openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, + // Add remoteControlEnabled setting + remoteControlEnabled: stateValues.remoteControlEnabled ?? false, } } @@ -2217,6 +2188,12 @@ export class ClineProvider await this.contextProxy.setValues(values) } + // cwd + + get cwd() { + return getWorkspacePath() + } + // dev async resetState() { @@ -2281,363 +2258,60 @@ export class ClineProvider return true } - public async remoteControlEnabled(enabled: boolean) { - const userInfo = CloudService.instance.getUserInfo() - const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined) + public async handleRemoteControlToggle(enabled: boolean) { + const { CloudService: CloudServiceImport, ExtensionBridgeService } = await import("@roo-code/cloud") - if (!config) { - this.log("[ClineProvider#remoteControlEnabled] Failed to get bridge config") + const userInfo = CloudServiceImport.instance.getUserInfo() + + const bridgeConfig = await CloudServiceImport.instance.cloudAPI?.bridgeConfig().catch(() => undefined) + + if (!bridgeConfig) { + this.log("[ClineProvider#handleRemoteControlToggle] Failed to get bridge config") return } - await BridgeOrchestrator.connectOrDisconnect(userInfo, enabled, { - ...config, - provider: this, - sessionId: vscode.env.sessionId, - }) + await ExtensionBridgeService.handleRemoteControlState( + userInfo, + enabled, + { ...bridgeConfig, provider: this, sessionId: vscode.env.sessionId }, + (message: string) => this.log(message), + ) - const bridge = BridgeOrchestrator.getInstance() - - if (bridge) { + if (isRemoteControlEnabled(userInfo, enabled)) { const currentTask = this.getCurrentTask() - if (currentTask && !currentTask.enableBridge) { + if (currentTask && !currentTask.bridgeService) { try { - currentTask.enableBridge = true - await BridgeOrchestrator.subscribeToTask(currentTask) + currentTask.bridgeService = ExtensionBridgeService.getInstance() + + if (currentTask.bridgeService) { + await currentTask.bridgeService.subscribeToTask(currentTask) + } } catch (error) { - const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}` + const message = `[ClineProvider#handleRemoteControlToggle] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}` this.log(message) console.error(message) } } } else { for (const task of this.clineStack) { - if (task.enableBridge) { + if (task.bridgeService) { try { - await BridgeOrchestrator.getInstance()?.unsubscribeFromTask(task.taskId) + await task.bridgeService.unsubscribeFromTask(task.taskId) + task.bridgeService = null } catch (error) { - const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}` + const message = `[ClineProvider#handleRemoteControlToggle] unsubscribeFromTask failed - ${error instanceof Error ? error.message : String(error)}` this.log(message) console.error(message) } } } + + ExtensionBridgeService.resetInstance() } } - /** - * Gets the CodeIndexManager for the current active workspace - * @returns CodeIndexManager instance for the current workspace or the default one - */ - public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined { - return CodeIndexManager.getInstance(this.context) - } - - /** - * Updates the code index status subscription to listen to the current workspace manager - */ - private updateCodeIndexStatusSubscription(): void { - // Get the current workspace manager - const currentManager = this.getCurrentWorkspaceCodeIndexManager() - - // If the manager hasn't changed, no need to update subscription - if (currentManager === this.codeIndexManager) { - return - } - - // Dispose the old subscription if it exists - if (this.codeIndexStatusSubscription) { - this.codeIndexStatusSubscription.dispose() - this.codeIndexStatusSubscription = undefined - } - - // Update the current workspace manager reference - this.codeIndexManager = currentManager - - // Subscribe to the new manager's progress updates if it exists - if (currentManager) { - this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => { - // Only send updates if this manager is still the current one - if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) { - // Get the full status from the manager to ensure we have all fields correctly formatted - const fullStatus = currentManager.getCurrentStatus() - this.postMessageToWebview({ - type: "indexingStatusUpdate", - values: fullStatus, - }) - } - }) - - if (this.view) { - this.webviewDisposables.push(this.codeIndexStatusSubscription) - } - - // Send initial status for the current workspace - this.postMessageToWebview({ - type: "indexingStatusUpdate", - values: currentManager.getCurrentStatus(), - }) - } - } - - /** - * TaskProviderLike, TelemetryPropertiesProvider - */ - - public getCurrentTask(): Task | undefined { - if (this.clineStack.length === 0) { - return undefined - } - - return this.clineStack[this.clineStack.length - 1] - } - - public getRecentTasks(): string[] { - if (this.recentTasksCache) { - return this.recentTasksCache - } - - const history = this.getGlobalState("taskHistory") ?? [] - const workspaceTasks: HistoryItem[] = [] - - for (const item of history) { - if (!item.ts || !item.task || item.workspace !== this.cwd) { - continue - } - - workspaceTasks.push(item) - } - - if (workspaceTasks.length === 0) { - this.recentTasksCache = [] - return this.recentTasksCache - } - - workspaceTasks.sort((a, b) => b.ts - a.ts) - let recentTaskIds: string[] = [] - - if (workspaceTasks.length >= 100) { - // If we have at least 100 tasks, return tasks from the last 7 days. - const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 - - for (const item of workspaceTasks) { - // Stop when we hit tasks older than 7 days. - if (item.ts < sevenDaysAgo) { - break - } - - recentTaskIds.push(item.id) - } - } else { - // Otherwise, return the most recent 100 tasks (or all if less than 100). - recentTaskIds = workspaceTasks.slice(0, Math.min(100, workspaceTasks.length)).map((item) => item.id) - } - - this.recentTasksCache = recentTaskIds - return this.recentTasksCache - } - - // When initializing a new task, (not from history but from a tool command - // new_task) there is no need to remove the previous task since the new - // task is a subtask of the previous one, and when it finishes it is removed - // from the stack and the caller is resumed in this way we can have a chain - // of tasks, each one being a sub task of the previous one until the main - // task is finished. - public async createTask( - text?: string, - images?: string[], - parentTask?: Task, - options: CreateTaskOptions = {}, - configuration: RooCodeSettings = {}, - ): Promise { - if (configuration) { - await this.setValues(configuration) - - if (configuration.allowedCommands) { - await vscode.workspace - .getConfiguration(Package.name) - .update("allowedCommands", configuration.allowedCommands, vscode.ConfigurationTarget.Global) - } - - if (configuration.deniedCommands) { - await vscode.workspace - .getConfiguration(Package.name) - .update("deniedCommands", configuration.deniedCommands, vscode.ConfigurationTarget.Global) - } - - if (configuration.commandExecutionTimeout !== undefined) { - await vscode.workspace - .getConfiguration(Package.name) - .update( - "commandExecutionTimeout", - configuration.commandExecutionTimeout, - vscode.ConfigurationTarget.Global, - ) - } - - if (configuration.currentApiConfigName) { - await this.setProviderProfile(configuration.currentApiConfigName) - } - } - - const { - apiConfiguration, - organizationAllowList, - diffEnabled: enableDiff, - enableCheckpoints, - fuzzyMatchThreshold, - experiments, - cloudUserInfo, - remoteControlEnabled, - } = await this.getState() - - if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) { - throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) - } - - const task = new Task({ - provider: this, - apiConfiguration, - enableDiff, - enableCheckpoints, - fuzzyMatchThreshold, - consecutiveMistakeLimit: apiConfiguration.consecutiveMistakeLimit, - task: text, - images, - experiments, - rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined, - parentTask, - taskNumber: this.clineStack.length + 1, - onCreated: this.taskCreationCallback, - enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), - initialTodos: options.initialTodos, - ...options, - }) - - await this.addClineToStack(task) - - this.log( - `[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`, - ) - - return task - } - - public async cancelTask(): Promise { - const task = this.getCurrentTask() - - if (!task) { - return - } - - console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) - - const { historyItem } = await this.getTaskWithId(task.taskId) - - // Preserve parent and root task information for history item. - const rootTask = task.rootTask - const parentTask = task.parentTask - - // Preserve FCO state before aborting task to prevent FCO from disappearing - let preservedFCOState: any = undefined - try { - const fileChangeManager = this.getFileChangeManager() - if (fileChangeManager) { - preservedFCOState = fileChangeManager.getChanges() - this.log(`[cancelTask] Preserved FCO state with ${preservedFCOState.files.length} files`) - } - } catch (error) { - this.log(`[cancelTask] Failed to preserve FCO state: ${error}`) - } - - task.abortTask() - - await pWaitFor( - () => - this.getCurrentTask()! === undefined || - this.getCurrentTask()!.isStreaming === false || - this.getCurrentTask()!.didFinishAbortingStream || - // If only the first chunk is processed, then there's no - // need to wait for graceful abort (closes edits, browser, - // etc). - this.getCurrentTask()!.isWaitingForFirstChunk, - { - timeout: 3_000, - }, - ).catch(() => { - console.error("Failed to abort task") - }) - - if (this.getCurrentTask()) { - // 'abandoned' will prevent this Cline instance from affecting - // future Cline instances. This may happen if its hanging on a - // streaming request. - this.getCurrentTask()!.abandoned = true - } - - // Clears task again, so we need to abortTask manually above. - await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask, preservedFCOState }) - } - - // Clear the current task without treating it as a subtask. - // This is used when the user cancels a task that is not a subtask. - public async clearTask(): Promise { - if (this.clineStack.length > 0) { - const task = this.clineStack[this.clineStack.length - 1] - console.log(`[clearTask] clearing task ${task.taskId}.${task.instanceId}`) - await this.removeClineFromStack() - } - } - - public resumeTask(taskId: string): void { - // Use the existing showTaskWithId method which handles both current and - // historical tasks. - this.showTaskWithId(taskId).catch((error) => { - this.log(`Failed to resume task ${taskId}: ${error.message}`) - }) - } - - // Modes - - public async getModes(): Promise<{ slug: string; name: string }[]> { - try { - const customModes = await this.customModesManager.getCustomModes() - return [...DEFAULT_MODES, ...customModes].map(({ slug, name }) => ({ slug, name })) - } catch (error) { - return DEFAULT_MODES.map(({ slug, name }) => ({ slug, name })) - } - } - - public async getMode(): Promise { - const { mode } = await this.getState() - return mode - } - - public async setMode(mode: string): Promise { - await this.setValues({ mode }) - } - - // Provider Profiles - - public async getProviderProfiles(): Promise<{ name: string; provider?: string }[]> { - const { listApiConfigMeta = [] } = await this.getState() - return listApiConfigMeta.map((profile) => ({ name: profile.name, provider: profile.apiProvider })) - } - - public async getProviderProfile(): Promise { - const { currentApiConfigName = "default" } = await this.getState() - return currentApiConfigName - } - - public async setProviderProfile(name: string): Promise { - await this.activateProviderProfile({ name }) - } - - // Telemetry - private _appProperties?: StaticAppProperties - private _gitProperties?: GitProperties private getAppProperties(): StaticAppProperties { if (!this._appProperties) { @@ -2677,7 +2351,7 @@ export class ClineProvider } private async getTaskProperties(): Promise { - const { language = "en", mode, apiConfiguration } = await this.getState() + const { language, mode, apiConfiguration } = await this.getState() const task = this.getCurrentTask() const todoList = task?.todoList @@ -2704,6 +2378,8 @@ export class ClineProvider } } + private _gitProperties?: GitProperties + private async getGitProperties(): Promise { if (!this._gitProperties) { this._gitProperties = await getWorkspaceGitInfo() @@ -2725,43 +2401,58 @@ export class ClineProvider } } - public get cwd() { - return this.currentWorkspacePath || getWorkspacePath() + /** + * Gets the CodeIndexManager for the current active workspace + * @returns CodeIndexManager instance for the current workspace or the default one + */ + public getCurrentWorkspaceCodeIndexManager(): CodeIndexManager | undefined { + return CodeIndexManager.getInstance(this.context) } /** - * Convert a file path to a webview-accessible URI - * This method safely converts file paths to URIs that can be loaded in the webview - * - * @param filePath - The absolute file path to convert - * @returns The webview URI string, or the original file URI if conversion fails - * @throws {Error} When webview is not available - * @throws {TypeError} When file path is invalid + * Updates the code index status subscription to listen to the current workspace manager */ - public convertToWebviewUri(filePath: string): string { - try { - const fileUri = vscode.Uri.file(filePath) + private updateCodeIndexStatusSubscription(): void { + // Get the current workspace manager + const currentManager = this.getCurrentWorkspaceCodeIndexManager() - // Check if we have a webview available - if (this.view?.webview) { - const webviewUri = this.view.webview.asWebviewUri(fileUri) - return webviewUri.toString() + // If the manager hasn't changed, no need to update subscription + if (currentManager === this.currentWorkspaceManager) { + return + } + + // Dispose the old subscription if it exists + if (this.codeIndexStatusSubscription) { + this.codeIndexStatusSubscription.dispose() + this.codeIndexStatusSubscription = undefined + } + + // Update the current workspace manager reference + this.currentWorkspaceManager = currentManager + + // Subscribe to the new manager's progress updates if it exists + if (currentManager) { + this.codeIndexStatusSubscription = currentManager.onProgressUpdate((update: IndexProgressUpdate) => { + // Only send updates if this manager is still the current one + if (currentManager === this.getCurrentWorkspaceCodeIndexManager()) { + // Get the full status from the manager to ensure we have all fields correctly formatted + const fullStatus = currentManager.getCurrentStatus() + this.postMessageToWebview({ + type: "indexingStatusUpdate", + values: fullStatus, + }) + } + }) + + if (this.view) { + this.webviewDisposables.push(this.codeIndexStatusSubscription) } - // Specific error for no webview available - const error = new Error("No webview available for URI conversion") - console.error(error.message) - // Fallback to file URI if no webview available - return fileUri.toString() - } catch (error) { - // More specific error handling - if (error instanceof TypeError) { - console.error("Invalid file path provided for URI conversion:", error) - } else { - console.error("Failed to convert to webview URI:", error) - } - // Return file URI as fallback - return vscode.Uri.file(filePath).toString() + // Send initial status for the current workspace + this.postMessageToWebview({ + type: "indexingStatusUpdate", + values: currentManager.getCurrentStatus(), + }) } } @@ -2781,3 +2472,9 @@ export class ClineProvider return this.globalFileChangeManager } } + +class OrganizationAllowListViolationError extends Error { + constructor(message: string) { + super(message) + } +} diff --git a/src/extension.ts b/src/extension.ts index c1f8e0764e..6060bb341f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,8 +12,8 @@ try { console.warn("Failed to load environment variables:", e) } -import type { CloudUserInfo, AuthState } from "@roo-code/types" -import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" +import type { CloudUserInfo } from "@roo-code/types" +import { CloudService, ExtensionBridgeService } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import "./utils/path" // Necessary to have access to String.prototype.toPosix. @@ -30,6 +30,7 @@ import { CodeIndexManager } from "./services/code-index/manager" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" +import { isRemoteControlEnabled } from "./utils/remoteControl" import { API } from "./extension/api" import { @@ -53,7 +54,7 @@ let outputChannel: vscode.OutputChannel let extensionContext: vscode.ExtensionContext let cloudService: CloudService | undefined -let authStateChangedHandler: ((data: { state: AuthState; previousState: AuthState }) => Promise) | undefined +let authStateChangedHandler: (() => void) | undefined let settingsUpdatedHandler: (() => void) | undefined let userInfoHandler: ((data: { userInfo: CloudUserInfo }) => Promise) | undefined @@ -127,50 +128,8 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize Roo Code Cloud service. const postStateListener = () => ClineProvider.getVisibleInstance()?.postStateToWebview() - - authStateChangedHandler = async (data: { state: AuthState; previousState: AuthState }) => { - postStateListener() - - if (data.state === "logged-out") { - try { - await BridgeOrchestrator.disconnect() - cloudLogger("[CloudService] BridgeOrchestrator disconnected on logout") - } catch (error) { - cloudLogger( - `[CloudService] Failed to disconnect BridgeOrchestrator on logout: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - } - - settingsUpdatedHandler = async () => { - const userInfo = CloudService.instance.getUserInfo() - - if (userInfo && CloudService.instance.cloudAPI) { - try { - const config = await CloudService.instance.cloudAPI.bridgeConfig() - - const isCloudAgent = - typeof process.env.ROO_CODE_CLOUD_TOKEN === "string" && process.env.ROO_CODE_CLOUD_TOKEN.length > 0 - - const remoteControlEnabled = isCloudAgent - ? true - : (CloudService.instance.getUserSettings()?.settings?.extensionBridgeEnabled ?? false) - - await BridgeOrchestrator.connectOrDisconnect(userInfo, remoteControlEnabled, { - ...config, - provider, - sessionId: vscode.env.sessionId, - }) - } catch (error) { - cloudLogger( - `[CloudService] BridgeOrchestrator#connectOrDisconnect failed on settings change: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - - postStateListener() - } + authStateChangedHandler = postStateListener + settingsUpdatedHandler = postStateListener userInfoHandler = async ({ userInfo }: { userInfo: CloudUserInfo }) => { postStateListener() @@ -186,18 +145,21 @@ export async function activate(context: vscode.ExtensionContext) { const isCloudAgent = typeof process.env.ROO_CODE_CLOUD_TOKEN === "string" && process.env.ROO_CODE_CLOUD_TOKEN.length > 0 - const remoteControlEnabled = isCloudAgent - ? true - : (CloudService.instance.getUserSettings()?.settings?.extensionBridgeEnabled ?? false) + cloudLogger(`[CloudService] isCloudAgent = ${isCloudAgent}, socketBridgeUrl = ${config.socketBridgeUrl}`) - await BridgeOrchestrator.connectOrDisconnect(userInfo, remoteControlEnabled, { - ...config, - provider, - sessionId: vscode.env.sessionId, - }) + ExtensionBridgeService.handleRemoteControlState( + userInfo, + isCloudAgent ? true : contextProxy.getValue("remoteControlEnabled"), + { + ...config, + provider, + sessionId: vscode.env.sessionId, + }, + cloudLogger, + ) } catch (error) { cloudLogger( - `[CloudService] BridgeOrchestrator#connectOrDisconnect failed on user change: ${error instanceof Error ? error.message : String(error)}`, + `[CloudService] Failed to fetch bridgeConfig: ${error instanceof Error ? error.message : String(error)}`, ) } } @@ -221,15 +183,6 @@ export async function activate(context: vscode.ExtensionContext) { // Add to subscriptions for proper cleanup on deactivate. context.subscriptions.push(cloudService) - // Trigger initial cloud profile sync now that CloudService is ready - try { - await provider.initializeCloudProfileSyncWhenReady() - } catch (error) { - outputChannel.appendLine( - `[CloudService] Failed to initialize cloud profile sync: ${error instanceof Error ? error.message : String(error)}`, - ) - } - // Finish initializing the provider. TelemetryService.instance.setProvider(provider) @@ -380,10 +333,10 @@ export async function deactivate() { } } - const bridge = BridgeOrchestrator.getInstance() + const bridgeService = ExtensionBridgeService.getInstance() - if (bridge) { - await bridge.disconnect() + if (bridgeService) { + await bridgeService.disconnect() } await McpServerManager.cleanup(extensionContext) diff --git a/src/package.json b/src/package.json index 21bf9513bd..fb236d515e 100644 --- a/src/package.json +++ b/src/package.json @@ -427,13 +427,12 @@ "@google/genai": "^1.0.0", "@lmstudio/sdk": "^1.1.1", "@mistralai/mistralai": "^1.9.18", - "@modelcontextprotocol/sdk": "^1.9.0", + "@modelcontextprotocol/sdk": "1.12.0", "@qdrant/js-client-rest": "^1.14.0", - "@roo-code/cloud": "^0.29.0", + "@roo-code/cloud": "workspace:^", "@roo-code/ipc": "workspace:^", "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^", - "@types/lodash.debounce": "^4.0.9", "@vscode/codicons": "^0.0.36", "async-mutex": "^0.5.0", "axios": "^1.7.4", @@ -504,6 +503,7 @@ "@types/diff": "^5.2.1", "@types/diff-match-patch": "^1.0.36", "@types/glob": "^8.1.0", + "@types/lodash.debounce": "^4.0.9", "@types/mocha": "^10.0.10", "@types/node": "20.x", "@types/node-cache": "^4.1.3", diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index c224901088..b39ebdf91b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -11,8 +11,10 @@ import type { TodoItem, ClineSay, FileChangeset, + CloudUserInfo, + OrganizationAllowList, + ShareVisibility, } from "@roo-code/types" -import type { CloudUserInfo, OrganizationAllowList, ShareVisibility } from "@roo-code/cloud" import { GitCommit } from "../utils/git" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 33e044109b..a66fa8c79c 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -7,7 +7,6 @@ import { type InstallMarketplaceItemOptions, type MarketplaceItem, type ShareVisibility, - type QueuedMessage, marketplaceItemSchema, } from "@roo-code/types" @@ -23,8 +22,6 @@ export interface UpdateTodoListPayload { todos: any[] } -export type EditQueuedMessagePayload = Pick - export interface WebviewMessage { type: | "updateTodoList" @@ -177,7 +174,7 @@ export interface WebviewMessage { | "toggleApiConfigPin" | "setHistoryPreviewCollapsed" | "hasOpenedModeSelector" - | "cloudButtonClicked" + | "accountButtonClicked" | "rooCloudSignIn" | "rooCloudSignOut" | "condenseTaskContextRequest" @@ -213,12 +210,6 @@ export interface WebviewMessage { | "createCommand" | "insertTextIntoTextarea" | "showMdmAuthRequiredNotification" - | "imageGenerationSettings" - | "openRouterImageApiKey" - | "openRouterImageGenerationSelectedModel" - | "queueMessage" - | "removeQueuedMessage" - | "editQueuedMessage" | "viewDiff" | "acceptFileChange" | "rejectFileChange" @@ -229,7 +220,7 @@ export interface WebviewMessage { | "filesChangedBaselineUpdate" text?: string editedMessageContent?: string - tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" disabled?: boolean context?: string dataUri?: string @@ -261,10 +252,8 @@ export interface WebviewMessage { hasSystemPromptOverride?: boolean terminalOperation?: "continue" | "abort" messageTs?: number - restoreCheckpoint?: boolean historyPreviewCollapsed?: boolean filters?: { type?: string; search?: string; tags?: string[] } - settings?: any url?: string // For openExternal mpItem?: MarketplaceItem mpInstallOptions?: InstallMarketplaceItemOptions @@ -354,7 +343,6 @@ export type WebViewMessagePayload = | IndexClearedPayload | InstallMarketplaceItemWithParametersPayload | UpdateTodoListPayload - | EditQueuedMessagePayload // Alias for consistent naming (prefer 'Webview' spelling in new code) export type WebviewMessagePayload = WebViewMessagePayload diff --git a/src/utils/remoteControl.ts b/src/utils/remoteControl.ts new file mode 100644 index 0000000000..f003b522d1 --- /dev/null +++ b/src/utils/remoteControl.ts @@ -0,0 +1,11 @@ +import type { CloudUserInfo } from "@roo-code/types" + +/** + * Determines if remote control features should be enabled + * @param cloudUserInfo - User information from cloud service + * @param remoteControlEnabled - User's remote control setting + * @returns true if remote control should be enabled + */ +export function isRemoteControlEnabled(cloudUserInfo?: CloudUserInfo | null, remoteControlEnabled?: boolean): boolean { + return !!(cloudUserInfo?.id && cloudUserInfo.extensionBridgeEnabled && remoteControlEnabled) +} diff --git a/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx b/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx index 212cfbc612..63058bd5b2 100644 --- a/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx +++ b/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx @@ -1,27 +1,26 @@ import { render, screen } from "@/utils/test-utils" -import { CloudView } from "../CloudView" +import { AccountView } from "../AccountView" // Mock the translation context vi.mock("@src/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ t: (key: string) => { const translations: Record = { - "cloud:title": "Cloud", + "account:title": "Account", "settings:common.done": "Done", - "cloud:signIn": "Connect to Roo Code Cloud", - "cloud:cloudBenefitsTitle": "Connect to Roo Code Cloud", - "cloud:cloudBenefitSharing": "Share tasks with others", - "cloud:cloudBenefitHistory": "Access your task history", - "cloud:cloudBenefitMetrics": "Get a holistic view of your token consumption", - "cloud:logOut": "Log out", - "cloud:connect": "Connect Now", - "cloud:visitCloudWebsite": "Visit Roo Code Cloud", - "cloud:remoteControl": "Roomote Control", - "cloud:remoteControlDescription": + "account:signIn": "Connect to Roo Code Cloud", + "account:cloudBenefitsTitle": "Connect to Roo Code Cloud", + "account:cloudBenefitSharing": "Share tasks with others", + "account:cloudBenefitHistory": "Access your task history", + "account:cloudBenefitMetrics": "Get a holistic view of your token consumption", + "account:logOut": "Log out", + "account:connect": "Connect Now", + "account:visitCloudWebsite": "Visit Roo Code Cloud", + "account:remoteControl": "Roomote Control", + "account:remoteControlDescription": "Enable following and interacting with tasks in this workspace with Roo Code Cloud", - "cloud:profilePicture": "Profile picture", - "cloud:cloudUrlPillLabel": "Roo Code Cloud URL: ", + "account:profilePicture": "Profile picture", } return translations[key] || key }, @@ -56,10 +55,10 @@ Object.defineProperty(window, "IMAGES_BASE_URI", { writable: true, }) -describe("CloudView", () => { +describe("AccountView", () => { it("should display benefits when user is not authenticated", () => { render( - { } render( - { } render( - { } render( - { expect(screen.queryByTestId("remote-control-toggle")).not.toBeInTheDocument() expect(screen.queryByText("Roomote Control")).not.toBeInTheDocument() }) - - it("should not display cloud URL pill when pointing to production", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - } - - render( - {}} - />, - ) - - // Check that the cloud URL pill is NOT displayed for production URL - expect(screen.queryByText(/Roo Code Cloud URL:/)).not.toBeInTheDocument() - }) - - it("should display cloud URL pill when pointing to non-production environment", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - } - - render( - {}} - />, - ) - - // Check that the cloud URL pill is displayed with the staging URL - expect(screen.getByText(/Roo Code Cloud URL:/)).toBeInTheDocument() - expect(screen.getByText("https://staging.roocode.com")).toBeInTheDocument() - }) - - it("should display cloud URL pill for non-authenticated users when not pointing to production", () => { - render( - {}} - />, - ) - - // Check that the cloud URL pill is displayed even when not authenticated - expect(screen.getByText(/Roo Code Cloud URL:/)).toBeInTheDocument() - expect(screen.getByText("https://dev.roocode.com")).toBeInTheDocument() - }) - - it("should not display cloud URL pill when cloudApiUrl is undefined", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - } - - render( {}} />) - - // Check that the cloud URL pill is NOT displayed when cloudApiUrl is undefined - expect(screen.queryByText(/Roo Code Cloud URL:/)).not.toBeInTheDocument() - }) }) diff --git a/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx index ef3808c20b..2d69879772 100644 --- a/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ImageGenerationSettings.spec.tsx @@ -1,8 +1,9 @@ import { render, fireEvent } from "@testing-library/react" -import { vi } from "vitest" -import { ImageGenerationSettings } from "../ImageGenerationSettings" + import type { ProviderSettings } from "@roo-code/types" +import { ImageGenerationSettings } from "../ImageGenerationSettings" + // Mock the translation context vi.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({