diff --git a/apps/vscode/proto/cline/cloud.proto b/apps/vscode/proto/cline/cloud.proto index 4ac5ae1786..821186dc3f 100644 --- a/apps/vscode/proto/cline/cloud.proto +++ b/apps/vscode/proto/cline/cloud.proto @@ -19,6 +19,8 @@ service CloudService { rpc getRepositoryBranches(RepositoryBranchesRequest) returns (CloudBranchList); // Records one user choice about where new tasks run: Local/Cloud, a repository, or a branch rpc chooseCloudTaskTarget(CloudTaskTargetChoice) returns (Empty); + // Saves the Cline model new cloud tasks run on + rpc setCloudModel(StringRequest) returns (Empty); // Opens the Cline Cloud dashboard page for a cloud session rpc openCloudSessionDashboard(StringRequest) returns (Empty); // Resolves live agent status for only the cloud sessions currently displayed diff --git a/apps/vscode/src/core/controller/cloud/setCloudModel.ts b/apps/vscode/src/core/controller/cloud/setCloudModel.ts new file mode 100644 index 0000000000..2c45ea67a4 --- /dev/null +++ b/apps/vscode/src/core/controller/cloud/setCloudModel.ts @@ -0,0 +1,9 @@ +import { Empty, type StringRequest } from "@shared/proto/cline/common" +import type { Controller } from "../index" + +/** Saves the Cline model new cloud tasks run on; a running cloud task keeps its own. */ +export async function setCloudModel(controller: Controller, request: StringRequest): Promise { + controller.stateManager.setGlobalState("cloudModelId", request.value.trim() || undefined) + await controller.postStateToWebview() + return Empty.create() +} diff --git a/apps/vscode/src/sdk/SdkController.test.ts b/apps/vscode/src/sdk/SdkController.test.ts index f9cbda8780..42b209ef28 100644 --- a/apps/vscode/src/sdk/SdkController.test.ts +++ b/apps/vscode/src/sdk/SdkController.test.ts @@ -95,7 +95,11 @@ describe("SDK remote-config coordination", () => { }, }, sessions: { getActiveSession: () => undefined }, - cloud: { getCurrentTaskInfo: () => undefined, getCloudModelId: () => "cloud-model" }, + cloud: { + getCurrentTaskInfo: () => undefined, + getCloudModelId: () => "cloud-model", + nextCloudModelId: () => "cloud-model", + }, turnStateTracker: { get: () => undefined }, messageTranslatorState: { getMinter: () => minter }, getStateToPostToWebview: SdkController.prototype.getStateToPostToWebview, diff --git a/apps/vscode/src/sdk/SdkController.ts b/apps/vscode/src/sdk/SdkController.ts index faf337929b..2d6765555d 100644 --- a/apps/vscode/src/sdk/SdkController.ts +++ b/apps/vscode/src/sdk/SdkController.ts @@ -2699,6 +2699,7 @@ export class Controller { cloudTaskTarget: isCloudSessionsFeatureEnabled() ? this.cloudTaskTarget.view() : undefined, currentCloudTask, cloudModelId: isCloudSessionsFeatureEnabled() || currentCloudTask ? this.cloud.getCloudModelId() : undefined, + nextCloudModelId: isCloudSessionsFeatureEnabled() || currentCloudTask ? this.cloud.nextCloudModelId() : undefined, stateVersion: minter.nextSeq(), epoch: minter.epoch, } diff --git a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts index 95f46f850a..edeaa866d5 100644 --- a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.test.ts @@ -171,6 +171,28 @@ describe("SdkCloudSessionCoordinator ownership", () => { await coordinator.dispose() }) + it("starts the sandbox on the cloud model picked in Settings over the Act-mode Cline model", async () => { + const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost + vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) + const { coordinator, cloudSessions } = makeCoordinator({ + stateManager: makeStateManager({ cloudModelId: "settings-cloud-model" }) as never, + sessions: { + startNewSession: vi.fn(async () => ({ sdkHost: host, startResult: { sessionId: record.id } })), + fireAndForgetSend: vi.fn(), + } as never, + }) + + expect(coordinator.nextCloudModelId()).toBe("settings-cloud-model") + expect(await coordinator.beginCloudTask({ prompt: "test", repoUrl: record.repoContext.repoUrl! })()).toBe(record.id) + expect(cloudSessions.createSession).toHaveBeenCalledWith( + expect.objectContaining({ modelId: "settings-cloud-model" }), + expect.any(Function), + expect.any(AbortSignal), + expect.any(Function), + ) + await coordinator.dispose() + }) + it("updates the provisioning row in place as the sandbox reports its phase", async () => { const host = { status: "idle", readMessages: async () => [], dispose: async () => {} } as unknown as CloudSessionHost vi.spyOn(CloudSessionHost, "connect").mockResolvedValue(host) diff --git a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts index 997fd7263b..1e787e6c45 100644 --- a/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-cloud-session-coordinator.ts @@ -902,12 +902,16 @@ export class SdkCloudSessionCoordinator { // ---- Starting a task ---- /** - * The Cline model a new sandbox runs: the user's Act-mode Cline model, - * else the top recommendation known so far. Synchronous, and the only - * source for both the composer label and the start path, so the sandbox - * runs the model the user saw when they submitted. + * The Cline model a new sandbox runs: the cloud model picked in Settings, + * else the user's Act-mode Cline model, else the top recommendation known + * so far. Synchronous, and the only source for both the composer label and + * the start path, so the sandbox runs the model the user saw when they submitted. */ - private nextCloudModelId(): string { + nextCloudModelId(): string { + const chosen = this.options.stateManager.getGlobalStateKey("cloudModelId")?.trim() + if (chosen) { + return chosen + } const apiConfig = this.options.stateManager.getApiConfiguration() if (apiConfig.actModeApiProvider === "cline" && apiConfig.actModeClineModelId?.trim()) { return apiConfig.actModeClineModelId.trim() diff --git a/apps/vscode/src/shared/ExtensionMessage.ts b/apps/vscode/src/shared/ExtensionMessage.ts index 0569fcc7fe..c804e9ec49 100644 --- a/apps/vscode/src/shared/ExtensionMessage.ts +++ b/apps/vscode/src/shared/ExtensionMessage.ts @@ -157,6 +157,8 @@ export interface ExtensionState { currentCloudTask?: CurrentCloudTaskInfo /** Cline model the displayed cloud task runs on, else the one a new cloud task would use. */ cloudModelId?: string + /** Cline model a new cloud task would run on; Settings edits this one. */ + nextCloudModelId?: string openAiCodexIsAuthenticated?: boolean } diff --git a/apps/vscode/src/shared/storage/state-keys.ts b/apps/vscode/src/shared/storage/state-keys.ts index 28787b56b3..02ba75a4bd 100644 --- a/apps/vscode/src/shared/storage/state-keys.ts +++ b/apps/vscode/src/shared/storage/state-keys.ts @@ -97,6 +97,8 @@ const GLOBAL_STATE_FIELDS = { dismissedBanners: { default: [] as Array<{ bannerId: string; dismissedAt: number }> }, // Last Local/Cloud choice (and repository) picked on the home screen for new tasks cloudTaskTarget: { default: undefined as CloudTaskTargetSelection | undefined }, + // Cline model picked in Settings for new cloud tasks + cloudModelId: { default: undefined as string | undefined }, // Last agent status confirmed over a live connection, per cloud session id cloudSessionStatuses: { default: {} as RememberedCloudStatuses }, // Path to worktree that should auto-open Cline sidebar when launched diff --git a/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.tsx b/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.tsx index 74beec37fb..ac311cc04d 100644 --- a/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.tsx +++ b/apps/vscode/webview-ui/src/components/cloud/TaskTargetPanel.tsx @@ -215,7 +215,7 @@ export function TaskTargetPanel() { ) } -function TargetButton({ +export function TargetButton({ active, icon, label, diff --git a/apps/vscode/webview-ui/src/components/settings/ClineModelPicker.test.tsx b/apps/vscode/webview-ui/src/components/settings/ClineModelPicker.test.tsx index af75ea366d..2f13227ac1 100644 --- a/apps/vscode/webview-ui/src/components/settings/ClineModelPicker.test.tsx +++ b/apps/vscode/webview-ui/src/components/settings/ClineModelPicker.test.tsx @@ -111,6 +111,17 @@ describe("ClineModelPicker", () => { }) }) + it("edits a caller-owned selection without touching the Plan/Act model", async () => { + const onChange = vi.fn() + render() + + fireEvent.click(await screen.findByText("Cline Next")) + + expect(onChange).toHaveBeenCalledWith("cline-next") + expect(mocks.commitSelection).not.toHaveBeenCalled() + expect(mocks.updateApiConfigurationProto).not.toHaveBeenCalled() + }) + it("renders RPC-provided display names on featured cards, falling back to ids", async () => { // Names arrive display-ready: the extension host resolves them against // the model catalog in fetchClineRecommendedModels. diff --git a/apps/vscode/webview-ui/src/components/settings/ClineModelPicker.tsx b/apps/vscode/webview-ui/src/components/settings/ClineModelPicker.tsx index 9988717e34..b187519355 100644 --- a/apps/vscode/webview-ui/src/components/settings/ClineModelPicker.tsx +++ b/apps/vscode/webview-ui/src/components/settings/ClineModelPicker.tsx @@ -52,6 +52,8 @@ interface ClineModelPickerProps { currentMode: Mode showProviderRouting?: boolean initialTab?: "recommended" | "free" + /** Replaces the Plan/Act selection with one the caller owns, e.g. the model new cloud tasks run on. */ + modelSelection?: { modelId: string; onChange: (modelId: string) => void } } interface FeaturedModelCardEntry { @@ -94,7 +96,13 @@ const FREE_MODELS_FALLBACK: FeaturedModelCardEntry[] = CLINE_RECOMMENDED_MODELS_ .map((model) => toFeaturedModelCardEntry(model, "FREE")) .filter((model): model is FeaturedModelCardEntry => model !== null) -const ClineModelPicker: React.FC = ({ isPopup, currentMode, showProviderRouting, initialTab }) => { +const ClineModelPicker: React.FC = ({ + isPopup, + currentMode, + showProviderRouting, + initialTab, + modelSelection, +}) => { const { handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers() const { apiConfiguration, favoritedModelIds } = useExtensionState() const { models: catalogClineModels, defaultModelId: clineDefaultModelId } = useProviderModels("cline") @@ -103,12 +111,13 @@ const ClineModelPicker: React.FC = ({ isPopup, currentMod const effectiveClineModels = catalogClineModels const committedSelection = currentMode === "plan" ? config?.planSelection : config?.actSelection const committedModelInfo = committedSelection?.modelInfo ? fromProtobufModelInfo(committedSelection.modelInfo) : undefined - const currentClineModelId = - committedSelection?.modelId || - modeFields.clineModelId || - clineDefaultModelId || - Object.keys(effectiveClineModels ?? {})[0] || - "" + const currentClineModelId = modelSelection + ? modelSelection.modelId + : committedSelection?.modelId || + modeFields.clineModelId || + clineDefaultModelId || + Object.keys(effectiveClineModels ?? {})[0] || + "" const [searchTerm, setSearchTerm] = useState(currentClineModelId) const searchTermEditedByUserRef = useRef(false) const [isDropdownVisible, setIsDropdownVisible] = useState(false) @@ -212,6 +221,11 @@ const ClineModelPicker: React.FC = ({ isPopup, currentMod searchTermEditedByUserRef.current = false setSearchTerm(newModelId) + if (modelSelection) { + modelSelection.onChange(newModelId) + return + } + const modelInfo = effectiveClineModels?.[newModelId] ?? { ...openAiModelInfoSafeDefaults, name: newModelId, @@ -253,7 +267,9 @@ const ClineModelPicker: React.FC = ({ isPopup, currentMod ...selected, selectedModelId: currentClineModelId, selectedModelInfo: (() => { - const persistedModelInfo = committedModelInfo || modeFields.clineModelInfo || selected.selectedModelInfo + const persistedModelInfo = modelSelection + ? openAiModelInfoSafeDefaults + : committedModelInfo || modeFields.clineModelInfo || selected.selectedModelInfo const liveModelInfo = effectiveClineModels?.[currentClineModelId] // Persisted Cline model info is a snapshot from selection time. When the // model is still in the catalog, refresh metadata/capability flags from @@ -283,6 +299,7 @@ const ClineModelPicker: React.FC = ({ isPopup, currentMod effectiveClineModels, freeClineModelIdSet, modeFields.clineModelInfo, + modelSelection, ]) useEffect(() => { @@ -559,7 +576,7 @@ const ClineModelPicker: React.FC = ({ isPopup, currentMod {hasInfo ? ( <> - {showReasoningEffort && ( + {showReasoningEffort && !modelSelection && ( { - const { planActSeparateModelsSetting, mode, apiConfiguration } = useExtensionState() - const [currentTab, setCurrentTab] = useState(mode) - const { handleFieldsChange } = useApiConfigurationHandlers() + const { cloudSessionsEnabled, cloudTaskTarget, currentCloudTask, clineMessages } = useExtensionState() + const showCloud = !!cloudSessionsEnabled || !!currentCloudTask + // Open on the settings for where the chat view is about to run (or is running) a task. + const [target, setTarget] = useState<"local" | "cloud">(() => + currentCloudTask || (cloudSessionsEnabled && clineMessages.length === 0 && cloudTaskTarget?.target === "cloud") + ? "cloud" + : "local", + ) + const isCloud = showCloud && target === "cloud" + return (
{renderSectionHeader?.("api-config")}
- {/* Tabs container */} - {planActSeparateModelsSetting ? ( -
-
- setCurrentTab("plan")} - style={{ - opacity: 1, - cursor: "pointer", - }}> - Plan Mode - - setCurrentTab("act")} - style={{ - opacity: 1, - cursor: "pointer", - }}> - Act Mode - -
- - {/* Content container */} -
- -
+ {showCloud && ( +
+ } + label="Local" + onClick={() => setTarget("local")} + /> + } + label="Cloud" + onClick={() => setTarget("cloud")} + />
- ) : ( - )} - -
- { - const checked = e.target.checked === true - try { - // If unchecking the toggle, wait a bit for state to update, then sync configurations - if (!checked) { - await syncModeConfigurations(apiConfiguration, currentTab, handleFieldsChange) - } - await StateServiceClient.updateSettings( - UpdateSettingsRequest.create({ - planActSeparateModelsSetting: checked, - }), - ) - } catch (error) { - console.error("Failed to update separate models setting:", error) - } - }}> - Use different models for Plan and Act modes - -

- Switching between Plan and Act mode will persist the API and model used in the previous mode. This may be - helpful e.g. when using a strong reasoning model to architect a plan for a cheaper coding model to act on. -

-
+ {isCloud ? : }
) } +const LocalModelSettings = ({ initialModelTab }: Pick) => { + const { planActSeparateModelsSetting, mode, apiConfiguration } = useExtensionState() + const [currentTab, setCurrentTab] = useState(mode) + const { handleFieldsChange } = useApiConfigurationHandlers() + return ( + <> + {/* Tabs container */} + {planActSeparateModelsSetting ? ( +
+
+ setCurrentTab("plan")} + style={{ + opacity: 1, + cursor: "pointer", + }}> + Plan Mode + + setCurrentTab("act")} + style={{ + opacity: 1, + cursor: "pointer", + }}> + Act Mode + +
+ + {/* Content container */} +
+ +
+
+ ) : ( + + )} + +
+ { + const checked = e.target.checked === true + try { + // If unchecking the toggle, wait a bit for state to update, then sync configurations + if (!checked) { + await syncModeConfigurations(apiConfiguration, currentTab, handleFieldsChange) + } + await StateServiceClient.updateSettings( + UpdateSettingsRequest.create({ + planActSeparateModelsSetting: checked, + }), + ) + } catch (error) { + console.error("Failed to update separate models setting:", error) + } + }}> + Use different models for Plan and Act modes + +

+ Switching between Plan and Act mode will persist the API and model used in the previous mode. This may be + helpful e.g. when using a strong reasoning model to architect a plan for a cheaper coding model to act on. +

+
+ + ) +} + +const CloudModelSettings = () => { + const { nextCloudModelId, cloudModelId, currentCloudTask } = useExtensionState() + const runningModelId = currentCloudTask ? cloudModelId : undefined + return ( + <> +

+ Cloud tasks run in an isolated sandbox on a Cline model and use Cline credits. This model is separate from your + local Plan and Act models. +

+ {runningModelId && ( +

+ The current cloud task runs on {runningModelId}. A model change applies to the next cloud task. +

+ )} + + CloudServiceClient.setCloudModel(StringRequest.create({ value: modelId })).catch((error) => + console.error("Failed to save cloud model:", error), + ), + }} + /> + + ) +} + export default ApiConfigurationSection