mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
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:
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+133
-64
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user