feat(vscode): pick the cloud task model in Settings

Settings > API Configuration gets a Local | Cloud toggle when cloud sessions
are on. Local keeps the provider and Plan/Act settings; Cloud picks the Cline
model new cloud tasks run on, saved separately from the local models. The
section opens on Cloud when the chat view is in a cloud context, so clicking
the composer's model label from a cloud session lands there.
This commit is contained in:
Saoud Rizwan
2026-10-20 22:42:03 +00:00
parent d553858849
commit 01ec21d79f
12 changed files with 223 additions and 80 deletions
+2
View File
@@ -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
@@ -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<Empty> {
controller.stateManager.setGlobalState("cloudModelId", request.value.trim() || undefined)
await controller.postStateToWebview()
return Empty.create()
}
+5 -1
View File
@@ -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,
+1
View File
@@ -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,
}
@@ -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)
@@ -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()
@@ -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
}
@@ -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
@@ -215,7 +215,7 @@ export function TaskTargetPanel() {
)
}
function TargetButton({
export function TargetButton({
active,
icon,
label,
@@ -111,6 +111,17 @@ describe("ClineModelPicker", () => {
})
})
it("edits a caller-owned selection without touching the Plan/Act model", async () => {
const onChange = vi.fn()
render(<ClineModelPicker currentMode="act" modelSelection={{ modelId: "cline-default", onChange }} />)
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.
@@ -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<ClineModelPickerProps> = ({ isPopup, currentMode, showProviderRouting, initialTab }) => {
const ClineModelPicker: React.FC<ClineModelPickerProps> = ({
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<ClineModelPickerProps> = ({ 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<ClineModelPickerProps> = ({ 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<ClineModelPickerProps> = ({ 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<ClineModelPickerProps> = ({ isPopup, currentMod
effectiveClineModels,
freeClineModelIdSet,
modeFields.clineModelInfo,
modelSelection,
])
useEffect(() => {
@@ -559,7 +576,7 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
{hasInfo ? (
<>
{showReasoningEffort && (
{showReasoningEffort && !modelSelection && (
<ReasoningEffortSelector
allowedEfforts={
showAdaptiveThinkingEffort ? (["none", "low", "medium", "high", "xhigh"] as const) : undefined
@@ -1,11 +1,15 @@
import { StringRequest } from "@shared/proto/cline/common"
import { UpdateSettingsRequest } from "@shared/proto/cline/state"
import { Mode } from "@shared/storage/types"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { CloudIcon, MonitorIcon } from "lucide-react"
import { useState } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { StateServiceClient } from "@/services/grpc-client"
import { CloudServiceClient, StateServiceClient } from "@/services/grpc-client"
import { TargetButton } from "../../cloud/TaskTargetPanel"
import { TabButton } from "../../mcp/configuration/McpConfigurationView"
import ApiOptions from "../ApiOptions"
import ClineModelPicker from "../ClineModelPicker"
import Section from "../Section"
import { syncModeConfigurations } from "../utils/providerUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
@@ -16,78 +20,143 @@ interface ApiConfigurationSectionProps {
}
const ApiConfigurationSection = ({ renderSectionHeader, initialModelTab }: ApiConfigurationSectionProps) => {
const { planActSeparateModelsSetting, mode, apiConfiguration } = useExtensionState()
const [currentTab, setCurrentTab] = useState<Mode>(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 (
<div>
{renderSectionHeader?.("api-config")}
<Section>
{/* Tabs container */}
{planActSeparateModelsSetting ? (
<div className="rounded-md mb-5">
<div className="flex gap-px mb-[10px] -mt-2 border-0 border-b border-solid border-(--vscode-panel-border)">
<TabButton
disabled={currentTab === "plan"}
isActive={currentTab === "plan"}
onClick={() => setCurrentTab("plan")}
style={{
opacity: 1,
cursor: "pointer",
}}>
Plan Mode
</TabButton>
<TabButton
disabled={currentTab === "act"}
isActive={currentTab === "act"}
onClick={() => setCurrentTab("act")}
style={{
opacity: 1,
cursor: "pointer",
}}>
Act Mode
</TabButton>
</div>
{/* Content container */}
<div className="-mb-3">
<ApiOptions currentMode={currentTab} initialModelTab={initialModelTab} showModelOptions={true} />
</div>
{showCloud && (
<div
aria-label="Model settings for"
className="inline-flex rounded-sm border border-input-foreground/20 p-0.5 mb-4"
role="radiogroup">
<TargetButton
active={!isCloud}
icon={<MonitorIcon className="size-3" />}
label="Local"
onClick={() => setTarget("local")}
/>
<TargetButton
active={isCloud}
icon={<CloudIcon className="size-3" />}
label="Cloud"
onClick={() => setTarget("cloud")}
/>
</div>
) : (
<ApiOptions currentMode={mode} initialModelTab={initialModelTab} showModelOptions={true} />
)}
<div className="mb-[5px]">
<VSCodeCheckbox
checked={planActSeparateModelsSetting}
className="mb-[5px]"
onChange={async (e: any) => {
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
</VSCodeCheckbox>
<p className="text-xs mt-[5px] text-(--vscode-descriptionForeground)">
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.
</p>
</div>
{isCloud ? <CloudModelSettings /> : <LocalModelSettings initialModelTab={initialModelTab} />}
</Section>
</div>
)
}
const LocalModelSettings = ({ initialModelTab }: Pick<ApiConfigurationSectionProps, "initialModelTab">) => {
const { planActSeparateModelsSetting, mode, apiConfiguration } = useExtensionState()
const [currentTab, setCurrentTab] = useState<Mode>(mode)
const { handleFieldsChange } = useApiConfigurationHandlers()
return (
<>
{/* Tabs container */}
{planActSeparateModelsSetting ? (
<div className="rounded-md mb-5">
<div className="flex gap-px mb-[10px] -mt-2 border-0 border-b border-solid border-(--vscode-panel-border)">
<TabButton
disabled={currentTab === "plan"}
isActive={currentTab === "plan"}
onClick={() => setCurrentTab("plan")}
style={{
opacity: 1,
cursor: "pointer",
}}>
Plan Mode
</TabButton>
<TabButton
disabled={currentTab === "act"}
isActive={currentTab === "act"}
onClick={() => setCurrentTab("act")}
style={{
opacity: 1,
cursor: "pointer",
}}>
Act Mode
</TabButton>
</div>
{/* Content container */}
<div className="-mb-3">
<ApiOptions currentMode={currentTab} initialModelTab={initialModelTab} showModelOptions={true} />
</div>
</div>
) : (
<ApiOptions currentMode={mode} initialModelTab={initialModelTab} showModelOptions={true} />
)}
<div className="mb-[5px]">
<VSCodeCheckbox
checked={planActSeparateModelsSetting}
className="mb-[5px]"
onChange={async (e: any) => {
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
</VSCodeCheckbox>
<p className="text-xs mt-[5px] text-(--vscode-descriptionForeground)">
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.
</p>
</div>
</>
)
}
const CloudModelSettings = () => {
const { nextCloudModelId, cloudModelId, currentCloudTask } = useExtensionState()
const runningModelId = currentCloudTask ? cloudModelId : undefined
return (
<>
<p className="text-xs mt-0 mb-3 text-(--vscode-descriptionForeground)">
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.
</p>
{runningModelId && (
<p className="text-xs mt-0 mb-3 text-(--vscode-descriptionForeground)">
The current cloud task runs on <code>{runningModelId}</code>. A model change applies to the next cloud task.
</p>
)}
<ClineModelPicker
currentMode="act"
modelSelection={{
modelId: nextCloudModelId ?? "",
onChange: (modelId) =>
CloudServiceClient.setCloudModel(StringRequest.create({ value: modelId })).catch((error) =>
console.error("Failed to save cloud model:", error),
),
}}
/>
</>
)
}
export default ApiConfigurationSection