diff --git a/src/core/controller/index.ts b/src/core/controller/index.ts index e0afbc4024..fae00a44c7 100644 --- a/src/core/controller/index.ts +++ b/src/core/controller/index.ts @@ -16,7 +16,7 @@ import { McpHub } from "@services/mcp/McpHub" import { telemetryService } from "@/services/posthog/telemetry/TelemetryService" import { ApiProvider, ModelInfo } from "@shared/api" import { ChatContent } from "@shared/ChatContent" -import { ChatSettings } from "@shared/ChatSettings" +import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings" import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage" import { HistoryItem } from "@shared/HistoryItem" import { McpMarketplaceCatalog } from "@shared/mcp" @@ -56,6 +56,7 @@ export class Controller { private postMessage: (message: ExtensionMessage) => Thenable | undefined private disposables: vscode.Disposable[] = [] + private mode: "plan" | "act" = "plan" // In-memory plan/act mode state task?: Task workspaceTracker: WorkspaceTracker mcpHub: McpHub @@ -135,7 +136,7 @@ export class Controller { apiConfiguration, autoApprovalSettings, browserSettings, - chatSettings, + chatSettings: storedChatSettings, shellIntegrationTimeout, terminalReuseEnabled, terminalOutputLineLimit, @@ -145,6 +146,12 @@ export class Controller { taskHistory, } = await getAllExtensionState(this.context) + // Reconstruct ChatSettings with in-memory mode and stored preferences + const chatSettings: ChatSettings = { + ...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort) + mode: this.mode, // Use in-memory mode (override any stored mode) + } + const NEW_USER_TASK_COUNT_THRESHOLD = 10 // Check if the user has completed enough tasks to no longer be considered a "new user" @@ -265,6 +272,9 @@ export class Controller { async togglePlanActModeWithChatSettings(chatSettings: ChatSettings, chatContent?: ChatContent): Promise { const didSwitchToActMode = chatSettings.mode === "act" + // Store mode in-memory only + this.mode = chatSettings.mode + // Capture mode switch telemetry | Capture regardless of if we know the taskId telemetryService.captureModeSwitch(this.task?.taskId ?? "0", chatSettings.mode) @@ -434,7 +444,9 @@ export class Controller { } } - await updateWorkspaceState(this.context, "chatSettings", chatSettings) + // Save only non-mode properties to workspace storage + const { mode, ...persistentChatSettings }: { mode: string } & StoredChatSettings = chatSettings + await updateWorkspaceState(this.context, "chatSettings", persistentChatSettings) await this.postStateToWebview() if (this.task) { @@ -948,7 +960,7 @@ export class Controller { async postStateToWebview() { const state = await this.getStateToPostToWebview() - await sendStateUpdate(state) + await sendStateUpdate(this.id, state) } async getStateToPostToWebview(): Promise { @@ -958,7 +970,7 @@ export class Controller { taskHistory, autoApprovalSettings, browserSettings, - chatSettings, + chatSettings: storedChatSettings, userInfo, mcpMarketplaceEnabled, mcpRichDisplayEnabled, @@ -975,6 +987,12 @@ export class Controller { terminalOutputLineLimit, } = await getAllExtensionState(this.context) + // Reconstruct ChatSettings with in-memory mode and stored preferences + const chatSettings: ChatSettings = { + ...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort) + mode: this.mode, // Use in-memory mode (override any stored mode) + } + const localClineRulesToggles = ((await getWorkspaceState(this.context, "localClineRulesToggles")) as ClineRulesToggles) || {} diff --git a/src/core/controller/state/subscribeToState.ts b/src/core/controller/state/subscribeToState.ts index c92096d7ec..019605b503 100644 --- a/src/core/controller/state/subscribeToState.ts +++ b/src/core/controller/state/subscribeToState.ts @@ -3,8 +3,8 @@ import { Controller } from "../index" import { EmptyRequest } from "../../../shared/proto/common" import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler" -// Keep track of active state subscriptions -const activeStateSubscriptions = new Set() +// Keep track of active state subscriptions by controller ID +const activeStateSubscriptions = new Map() /** * Subscribe to state updates @@ -19,23 +19,25 @@ export async function subscribeToState( responseStream: StreamingResponseHandler, requestId?: string, ): Promise { + const controllerId = controller.id + // Send the initial state const initialState = await controller.getStateToPostToWebview() const initialStateJson = JSON.stringify(initialState) - console.log("[DEBUG] set up state subscription") + console.log(`[DEBUG] set up state subscription for controller ${controllerId}`) await responseStream({ stateJson: initialStateJson, }) - // Add this subscription to the active subscriptions - activeStateSubscriptions.add(responseStream) + // Add this subscription to the active subscriptions with the controller ID + activeStateSubscriptions.set(controllerId, responseStream) // Register cleanup when the connection is closed const cleanup = () => { - activeStateSubscriptions.delete(responseStream) - console.log("[DEBUG] Cleaned up state subscription") + activeStateSubscriptions.delete(controllerId) + console.log(`[DEBUG] Cleaned up state subscription for controller ${controllerId}`) } // Register the cleanup function with the request registry if we have a requestId @@ -45,30 +47,31 @@ export async function subscribeToState( } /** - * Send a state update to all active subscribers + * Send a state update to a specific controller's subscription + * @param controllerId The ID of the controller to send the state to * @param state The state to send */ -export async function sendStateUpdate(state: any): Promise { - const stateJson = JSON.stringify(state) +export async function sendStateUpdate(controllerId: string, state: any): Promise { + // Get the subscription for this specific controller + const responseStream = activeStateSubscriptions.get(controllerId) - // Send the update to all active subscribers - const promises = Array.from(activeStateSubscriptions).map(async (responseStream) => { - try { - // The issue might be that we're not properly formatting the response - // Let's ensure we're sending a properly formatted State message - await responseStream( - { - stateJson, - }, - false, // Not the last message - ) - console.log("[DEBUG] sending followup state", stateJson.length, "chars") - } catch (error) { - console.error("Error sending state update:", error) - // Remove the subscription if there was an error - activeStateSubscriptions.delete(responseStream) - } - }) + if (!responseStream) { + console.log(`[DEBUG] No active state subscription for controller ${controllerId}`) + return + } - await Promise.all(promises) + try { + const stateJson = JSON.stringify(state) + await responseStream( + { + stateJson, + }, + false, // Not the last message + ) + console.log(`[DEBUG] sending followup state to controller ${controllerId}`, stateJson.length, "chars") + } catch (error) { + console.error(`Error sending state update to controller ${controllerId}:`, error) + // Remove the subscription if there was an error + activeStateSubscriptions.delete(controllerId) + } } diff --git a/src/core/storage/state.ts b/src/core/storage/state.ts index 5c88967150..a9749da5d7 100644 --- a/src/core/storage/state.ts +++ b/src/core/storage/state.ts @@ -7,7 +7,7 @@ import { ApiConfiguration, ApiProvider, BedrockModelId, ModelInfo } from "@share import { HistoryItem } from "@shared/HistoryItem" import { AutoApprovalSettings } from "@shared/AutoApprovalSettings" import { BrowserSettings } from "@shared/BrowserSettings" -import { ChatSettings } from "@shared/ChatSettings" +import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings" import { TelemetrySetting } from "@shared/TelemetrySetting" import { UserInfo } from "@shared/UserInfo" import { ClineRulesToggles } from "@shared/cline-rules" @@ -173,6 +173,28 @@ export async function migrateCustomInstructionsToGlobalRules(context: vscode.Ext } } +export async function cleanupModeFromWorkspaceStorage(context: vscode.ExtensionContext) { + try { + // Get current chatSettings from workspace storage + const chatSettings = (await getWorkspaceState(context, "chatSettings")) as any + + if (chatSettings && typeof chatSettings === "object" && "mode" in chatSettings) { + console.log("Cleaning up mode from workspace storage...") + + // Remove mode property from chatSettings + const { mode, ...cleanedChatSettings } = chatSettings + + // Save cleaned chatSettings back to workspace storage + await updateWorkspaceState(context, "chatSettings", cleanedChatSettings) + + console.log("Successfully removed mode from workspace storage chatSettings") + } + } catch (error) { + console.error("Failed to cleanup mode from workspace storage:", error) + // Continue execution - migration failure shouldn't break extension startup + } +} + export async function getAllExtensionState(context: vscode.ExtensionContext) { const [ isNewUser, @@ -360,7 +382,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) { previousModeSapAiCoreResourceGroup, previousModeSapAiCoreModelId, ] = await Promise.all([ - getWorkspaceState(context, "chatSettings") as Promise, + getWorkspaceState(context, "chatSettings") as Promise, getWorkspaceState(context, "apiProvider") as Promise, getWorkspaceState(context, "apiModelId") as Promise, getWorkspaceState(context, "thinkingBudgetTokens") as Promise, diff --git a/src/extension.ts b/src/extension.ts index f615a1cdcb..39e293dfa7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,7 +22,11 @@ import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/ui import { WebviewProviderType } from "./shared/webview/types" import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked" import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked" -import { migratePlanActGlobalToWorkspaceStorage, migrateCustomInstructionsToGlobalRules } from "./core/storage/state" +import { + migratePlanActGlobalToWorkspaceStorage, + migrateCustomInstructionsToGlobalRules, + cleanupModeFromWorkspaceStorage, +} from "./core/storage/state" import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput" import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker" @@ -60,6 +64,9 @@ export async function activate(context: vscode.ExtensionContext) { // Migrate custom instructions to global Cline rules (one-time cleanup) await migrateCustomInstructionsToGlobalRules(context) + // Clean up mode from workspace storage (one-time cleanup) + await cleanupModeFromWorkspaceStorage(context) + // Clean up orphaned file context warnings (startup cleanup) await FileContextTracker.cleanupOrphanedWarnings(context) diff --git a/src/shared/ChatSettings.ts b/src/shared/ChatSettings.ts index ffab987391..f8a9a721af 100644 --- a/src/shared/ChatSettings.ts +++ b/src/shared/ChatSettings.ts @@ -8,6 +8,9 @@ export interface ChatSettings { export type PartialChatSettings = Partial +// Type for chat settings stored in workspace (excludes in-memory mode) +export type StoredChatSettings = Omit + export const DEFAULT_CHAT_SETTINGS: ChatSettings = { mode: "act", preferredLanguage: "English",