Merge branch 'main' into harrison/dat-111-desktop-privacy-link

This commit is contained in:
Harrison
2026-10-22 17:00:01 -07:00
committed by GitHub
14 changed files with 127 additions and 214 deletions
@@ -419,6 +419,9 @@ test.each([
PATH: root,
JITI_FS_CACHE: "false",
CLINE_PLUGIN_MODE: mode,
// Cold Windows runners can take more than the 4s default to start a
// sandbox child; this test covers resource loading, not startup time.
CLINE_PLUGIN_IMPORT_TIMEOUT_MS: "20000",
CLINE_TEST_PLUGIN_RESULT_PATH: marker,
TMPDIR: root,
CLINE_NO_AUTO_UPDATE: "1",
@@ -98,10 +98,11 @@ describe("ChatTextArea model label", () => {
it.each([
["a new task targets Cloud", { cloudSessionsEnabled: true, cloudTaskTarget: { target: "cloud" } }],
["a cloud task is shown", { currentCloudTask: { sessionId: "ses-1", status: "running" } }],
])("shows the cloud model when %s", (_case, cloudState) => {
])("shows the cloud model and hides the Plan/Act toggle when %s", (_case, cloudState) => {
mocks.cloudState = { ...cloudState, cloudModelId: "anthropic/claude-sonnet-4.5" }
renderTextArea()
expect(screen.getByTitle("Open API Settings")).toHaveTextContent("cline:anthropic/claude-sonnet-4.5")
expect(screen.queryByTestId("mode-switch")).not.toBeInTheDocument()
})
})
@@ -116,7 +117,7 @@ describe("ChatTextArea image attachments vs. model capability", () => {
renderTextArea()
expect(screen.getByPlaceholderText("Type a message")).toBeInTheDocument()
expect(screen.getByTestId("mode-switch")).toHaveAttribute("aria-disabled", "false")
expect(screen.getByTestId("mode-switch")).toBeInTheDocument()
})
it("still takes the image attach path on paste for a text-only model, without a refusal message", () => {
@@ -96,15 +96,14 @@ interface GitCommit {
const PLAN_MODE_COLOR = "var(--vscode-activityWarningBadge-background)"
const ACT_MODE_COLOR = "var(--vscode-focusBorder)"
const SwitchContainer = styled.div<{ disabled: boolean }>`
const SwitchContainer = styled.div`
display: flex;
align-items: center;
background-color: transparent;
border: 1px solid var(--vscode-input-border);
border-radius: 12px;
overflow: hidden;
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
cursor: pointer;
transform: scale(1);
transform-origin: right center;
margin-left: 0;
@@ -233,7 +232,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
} = useExtensionState()
// Cloud sessions are Act-only (like the desktop app and the cloud dashboard).
// While a cloud task is shown, or a new task is about to run in the cloud,
// the toggle is pinned to Act and disabled; the stored mode is untouched.
// the mode is pinned to Act and the toggle is hidden; the stored mode is untouched.
const cloudActOnly =
!!currentCloudTask || (clineMessages.length === 0 && !!cloudSessionsEnabled && cloudTaskTarget?.target === "cloud")
const mode = cloudActOnly ? "act" : selectedMode
@@ -1698,47 +1697,38 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</ModelContainer>
</ButtonGroup>
</div>
{/* Tooltip for Plan/Act toggle remains outside the conditional rendering */}
<Tooltip>
<TooltipContent
className="text-xs px-2 flex flex-col gap-1"
hidden={shownTooltipMode === null}
side="top">
{cloudActOnly ? (
"Cloud sessions only support Act mode for now."
) : (
<>
{`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
<p className="text-description/80 text-xs mb-0">
Toggle w/ <kbd className="text-muted-foreground mx-1">{togglePlanActKeys}</kbd>
</p>
</>
)}
</TooltipContent>
<TooltipTrigger>
<SwitchContainer
aria-disabled={cloudActOnly}
data-testid="mode-switch"
disabled={cloudActOnly}
onClick={onModeToggle}>
<Slider isAct={mode === "act"} isPlan={mode === "plan"} />
{["Plan", "Act"].map((m) => (
<div
aria-checked={mode === m.toLowerCase()}
className={cn(
"pt-0.5 pb-px px-2 z-10 text-xs w-1/2 text-center bg-transparent",
mode === m.toLowerCase() ? "text-white" : "text-input-foreground",
)}
key={m}
onMouseLeave={() => setShownTooltipMode(null)}
onMouseOver={() => setShownTooltipMode(m.toLowerCase() === "plan" ? "plan" : "act")}
role="switch">
{m}
</div>
))}
</SwitchContainer>
</TooltipTrigger>
</Tooltip>
{!cloudActOnly && (
<Tooltip>
<TooltipContent
className="text-xs px-2 flex flex-col gap-1"
hidden={shownTooltipMode === null}
side="top">
{`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
<p className="text-description/80 text-xs mb-0">
Toggle w/ <kbd className="text-muted-foreground mx-1">{togglePlanActKeys}</kbd>
</p>
</TooltipContent>
<TooltipTrigger>
<SwitchContainer data-testid="mode-switch" onClick={onModeToggle}>
<Slider isAct={mode === "act"} isPlan={mode === "plan"} />
{["Plan", "Act"].map((m) => (
<div
aria-checked={mode === m.toLowerCase()}
className={cn(
"pt-0.5 pb-px px-2 z-10 text-xs w-1/2 text-center bg-transparent",
mode === m.toLowerCase() ? "text-white" : "text-input-foreground",
)}
key={m}
onMouseLeave={() => setShownTooltipMode(null)}
onMouseOver={() => setShownTooltipMode(m.toLowerCase() === "plan" ? "plan" : "act")}
role="switch">
{m}
</div>
))}
</SwitchContainer>
</TooltipTrigger>
</Tooltip>
)}
</div>
</div>
)
@@ -225,7 +225,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// Cloud sandboxes cannot read local files; only images (sent inline) can be attached.
const imagesOnly =
!!currentCloudTask || (messages.length === 0 && cloudSessionsEnabled && cloudTaskTarget?.target === "cloud")
const cloudAutoApprove =
// Cloud sessions always auto-approve; local auto-approve settings don't apply there.
const hideAutoApprove =
!!currentCloudTask || (messages.length === 0 && cloudSessionsEnabled && cloudTaskTarget?.target === "cloud")
const selectFilesAndImages = useCallback(async () => {
@@ -426,7 +427,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
)}
</div>
<footer className="bg-(--vscode-sidebar-background) flex flex-col" style={{ gridRow: "2" }}>
<AutoApproveBar cloudAutoApprove={cloudAutoApprove} />
{!hideAutoApprove && <AutoApproveBar />}
<ActionButtons
chatState={chatState}
messageHandlers={messageHandlers}
@@ -1,87 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import type React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import AutoApproveBar from "./AutoApproveBar"
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeCheckbox: ({ checked, children, disabled }: { checked: boolean; children: React.ReactNode; disabled: boolean }) => (
<label>
<input checked={checked} disabled={disabled} readOnly type="checkbox" />
{children}
</label>
),
}))
const mocks = vi.hoisted(() => ({
autoApprovalSettings: {
version: 1,
enabled: true,
favorites: [],
maxRequests: 20,
actions: {
readFiles: true,
editFiles: false,
executeSafeCommands: false,
executeAllCommands: false,
useBrowser: false,
useMcp: false,
},
enableNotifications: false,
},
updateAutoApproveSettings: vi.fn(),
}))
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({ autoApprovalSettings: mocks.autoApprovalSettings, hostName: "PyCharm Professional" }),
}))
vi.mock("./AutoApproveSettingsAPI", () => ({
updateAutoApproveSettings: mocks.updateAutoApproveSettings,
}))
describe("AutoApproveBar", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
it("shows forced read-only cloud approvals without changing local settings", async () => {
const localSettings = structuredClone(mocks.autoApprovalSettings)
const user = userEvent.setup()
const { rerender } = render(<AutoApproveBar cloudAutoApprove />)
const trigger = screen.getByLabelText("Open auto-approve settings")
expect(trigger).toHaveTextContent("Auto-approve:Read, Edit, Commands, Web Fetch, MCP")
await user.hover(trigger)
expect(await screen.findByText(/when PyCharm is closed\./)).toBeInTheDocument()
fireEvent.click(trigger)
expect(
screen.getByText("Cloud sessions always auto-approve these actions. Your local settings are unchanged."),
).toBeInTheDocument()
const checkboxes = screen.getAllByRole("checkbox")
expect(checkboxes).toHaveLength(5)
for (const checkbox of checkboxes) {
expect(checkbox).toBeChecked()
expect(checkbox).toBeDisabled()
fireEvent.click(checkbox)
}
expect(mocks.updateAutoApproveSettings).not.toHaveBeenCalled()
expect(mocks.autoApprovalSettings).toEqual(localSettings)
fireEvent.click(screen.getByLabelText("Close auto-approve settings"))
rerender(<AutoApproveBar />)
expect(screen.getByLabelText("Open auto-approve settings")).toHaveTextContent("Auto-approve:Read")
expect(screen.queryByText(/when PyCharm is closed\./)).not.toBeInTheDocument()
expect(mocks.autoApprovalSettings).toEqual(localSettings)
})
})
@@ -1,22 +1,16 @@
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
import { useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { editorName } from "@/utils/editorName"
import { getAsVar, VSC_TITLEBAR_INACTIVE_FOREGROUND } from "@/utils/vscStyles"
import AutoApproveModal from "./AutoApproveModal"
import { ACTION_METADATA } from "./constants"
interface AutoApproveBarProps {
style?: React.CSSProperties
cloudAutoApprove?: boolean
}
const cloudAutoApproveTooltip = (editor: string) =>
`Cloud sessions always auto-approve actions so they can continue running when ${editor} is closed. Your local auto-approve settings are unchanged.`
const AutoApproveBar = ({ style, cloudAutoApprove = false }: AutoApproveBarProps) => {
const { autoApprovalSettings, hostName } = useExtensionState()
const AutoApproveBar = ({ style }: AutoApproveBarProps) => {
const { autoApprovalSettings } = useExtensionState()
const [isModalVisible, setIsModalVisible] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
@@ -25,11 +19,9 @@ const AutoApproveBar = ({ style, cloudAutoApprove = false }: AutoApproveBarProps
const baseClasses = isModalVisible
? "text-foreground truncate"
: "text-muted-foreground group-hover:text-foreground truncate"
const enabledActionsNames = cloudAutoApprove
? ACTION_METADATA.map((action) => action.id)
: Object.keys(autoApprovalSettings.actions).filter(
(key) => autoApprovalSettings.actions[key as keyof typeof autoApprovalSettings.actions],
)
const enabledActionsNames = Object.keys(autoApprovalSettings.actions).filter(
(key) => autoApprovalSettings.actions[key as keyof typeof autoApprovalSettings.actions],
)
const enabledActions = enabledActionsNames.map((action) => {
return ACTION_METADATA.flatMap((a) => [a, a.subAction]).find((a) => a?.id === action)
})
@@ -98,37 +90,31 @@ const AutoApproveBar = ({ style, cloudAutoApprove = false }: AutoApproveBarProps
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<div
aria-label={isModalVisible ? "Close auto-approve settings" : "Open auto-approve settings"}
className="group cursor-pointer pt-3 pb-3.5 pr-2 px-3.5 flex items-center justify-between gap-0"
onClick={() => {
setIsModalVisible((prev) => !prev)
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
setIsModalVisible((prev) => !prev)
}
}}
ref={buttonRef}
tabIndex={0}>
<div className="flex flex-nowrap items-center gap-1 min-w-0 flex-1">
<span className="whitespace-nowrap">Auto-approve:</span>
{getEnabledActionsText()}
</div>
{isModalVisible ? <ChevronDownIcon size={16} /> : <ChevronRightIcon size={16} />}
</div>
</TooltipTrigger>
{cloudAutoApprove && <TooltipContent side="top">{cloudAutoApproveTooltip(editorName(hostName))}</TooltipContent>}
</Tooltip>
<div
aria-label={isModalVisible ? "Close auto-approve settings" : "Open auto-approve settings"}
className="group cursor-pointer pt-3 pb-3.5 pr-2 px-3.5 flex items-center justify-between gap-0"
onClick={() => {
setIsModalVisible((prev) => !prev)
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
setIsModalVisible((prev) => !prev)
}
}}
ref={buttonRef}
tabIndex={0}>
<div className="flex flex-nowrap items-center gap-1 min-w-0 flex-1">
<span className="whitespace-nowrap">Auto-approve:</span>
{getEnabledActionsText()}
</div>
{isModalVisible ? <ChevronDownIcon size={16} /> : <ChevronRightIcon size={16} />}
</div>
<AutoApproveModal
ACTION_METADATA={ACTION_METADATA}
buttonRef={buttonRef}
cloudAutoApprove={cloudAutoApprove}
isVisible={isModalVisible}
setIsVisible={setIsModalVisible}
/>
@@ -12,18 +12,10 @@ interface AutoApproveModalProps {
setIsVisible: (visible: boolean) => void
buttonRef: React.RefObject<HTMLDivElement>
ACTION_METADATA: ActionMetadata[]
cloudAutoApprove?: boolean
}
const AutoApproveModal: React.FC<AutoApproveModalProps> = ({
isVisible,
setIsVisible,
buttonRef,
ACTION_METADATA,
cloudAutoApprove = false,
}) => {
const AutoApproveModal: React.FC<AutoApproveModalProps> = ({ isVisible, setIsVisible, buttonRef, ACTION_METADATA }) => {
const { isChecked, updateAction } = useAutoApproveActions()
const displayedIsChecked = cloudAutoApprove ? () => true : isChecked
const modalRef = useRef<HTMLDivElement>(null)
const itemsContainerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
@@ -76,9 +68,7 @@ const AutoApproveModal: React.FC<AutoApproveModalProps> = ({
maxHeight: "60vh",
}}>
<div className="mb-2.5 text-muted-foreground text-xs cursor-pointer" onClick={() => setIsVisible(false)}>
{cloudAutoApprove
? "Cloud sessions always auto-approve these actions. Your local settings are unchanged. "
: "Let Cline take these actions without asking for approval. "}
Let Cline take these actions without asking for approval.{" "}
<a
className="text-link hover:text-link-hover"
href="https://docs.cline.bot/features/auto-approve#auto-approve"
@@ -109,13 +99,7 @@ const AutoApproveModal: React.FC<AutoApproveModalProps> = ({
{/* All items in a single list - CSS Grid will handle the column distribution */}
{ACTION_METADATA.map((action) => (
<AutoApproveMenuItem
action={action}
disabled={cloudAutoApprove}
isChecked={displayedIsChecked}
key={action.id}
onToggle={updateAction}
/>
<AutoApproveMenuItem action={action} isChecked={isChecked} key={action.id} onToggle={updateAction} />
))}
</div>
</div>
@@ -91,6 +91,31 @@ describe("useGitHubConnection", () => {
await waitFor(() => expect(result.current.connection).toEqual(disconnected))
})
it("re-checks in the background without showing loading until the user refreshes", async () => {
const recheck = deferred<typeof connected>()
mocks.getGitHubConnection.mockResolvedValueOnce(disconnected).mockReturnValueOnce(recheck.promise)
const { result } = renderHook(() => useGitHubConnection(true))
await waitFor(() => expect(result.current.connection).toEqual(disconnected))
act(() => {
window.dispatchEvent(new Event("focus"))
})
expect(mocks.getGitHubConnection).toHaveBeenCalledTimes(2)
expect(result.current.loading).toBe(false)
act(() => {
void result.current.refresh()
})
expect(mocks.getGitHubConnection).toHaveBeenCalledTimes(2)
expect(result.current.loading).toBe(true)
await act(async () => {
recheck.resolve(connected)
})
expect(result.current.connection).toEqual(connected)
expect(result.current.loading).toBe(false)
})
it("hides repositories during a switch and refetches even if the switch is refused", async () => {
mocks.getGitHubConnection.mockResolvedValueOnce(connected).mockResolvedValueOnce(disconnected)
const { result, rerender } = renderHook(() => useGitHubConnection(true))
@@ -25,14 +25,16 @@ export function useGitHubConnection(enabled: boolean) {
const inFlight = useRef<{ scope: object; promise: Promise<void> } | undefined>(undefined)
const load = useCallback(
async (refresh: boolean) => {
async ({ refresh = false, userInitiated = false }: { refresh?: boolean; userInitiated?: boolean } = {}) => {
if (!available) {
return
}
if (userInitiated) {
setLoading(true)
}
if (inFlight.current?.scope === scope) {
return inFlight.current.promise
}
setLoading(true)
const request = {
scope,
promise: CloudServiceClient.getGitHubConnection(GitHubConnectionRequest.create({ refresh }))
@@ -72,12 +74,12 @@ export function useGitHubConnection(enabled: boolean) {
// not-yet-connected poll ask the control plane again.
useEffect(() => {
setLoading(false)
void load(false)
void load({ userInitiated: true })
return () => {
inFlight.current = undefined
}
}, [load])
const refresh = useCallback(() => load(true), [load])
const refresh = useCallback(() => load({ refresh: true, userInitiated: true }), [load])
const needsPolling =
available &&
@@ -89,14 +91,14 @@ export function useGitHubConnection(enabled: boolean) {
if (!needsPolling) {
return
}
const timer = setInterval(() => void refresh(), DISCONNECTED_POLL_MS)
const onFocus = () => void refresh()
window.addEventListener("focus", onFocus)
const recheck = () => void load({ refresh: true })
const timer = setInterval(recheck, DISCONNECTED_POLL_MS)
window.addEventListener("focus", recheck)
return () => {
clearInterval(timer)
window.removeEventListener("focus", onFocus)
window.removeEventListener("focus", recheck)
}
}, [needsPolling, refresh])
}, [needsPolling, load])
return { connection, loading, refresh }
}
@@ -1,3 +1,4 @@
import { getGeneratedModelsForProvider } from "@cline/llms";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearLiveModelsCatalogCache,
@@ -467,9 +468,14 @@ describe("resolveProviderConfig", () => {
url: "https://models.test/api.json",
});
const [generatedId, generatedModel] =
Object.entries(getGeneratedModelsForProvider("cline-pass")).find(([id]) =>
id.startsWith("cline-pass/"),
) ?? [];
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(resolved?.knownModels?.["cline-pass/mimo-v2.5-pro"]?.name).toBe(
"MiMo-V2.5-Pro",
expect(generatedId).toBeDefined();
expect(resolved?.knownModels?.[generatedId ?? ""]?.name).toBe(
generatedModel?.name,
);
expect(
resolved?.knownModels?.["vendor/live-openrouter-model"],
@@ -1090,10 +1090,12 @@ describe("addLocalProvider – model ID parsing via modelsSourceUrl", () => {
// models.dev, the recommended-models feed via the live catalog, and
// the recommended-models feed again for the featured-tier overlay
// (separately cached; both caches are cold here).
const generatedId = Object.keys(
LlmsModels.getGeneratedModelsForProvider("cline-pass"),
).find((id) => id.startsWith("cline-pass/"));
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(models.map((model) => model.id)).toContain(
"cline-pass/mimo-v2.5-pro",
);
expect(generatedId).toBeDefined();
expect(models.map((model) => model.id)).toContain(generatedId);
expect(models.map((model) => model.id)).not.toContain(
"vendor/live-openrouter-model",
);
+1 -1
View File
@@ -1035,7 +1035,7 @@ const OPENAI_COMPATIBLE_SPEC_OVERRIDES: BuiltinSpecOverride[] = [
description: "Z.AI's coding-focused models",
family: "openai-compatible",
capabilities: ["reasoning", "tools"],
defaultModelId: "glm-5.2",
defaultModelId: "glm-5.3",
apiKeyEnv: ["ZHIPU_API_KEY"],
modelsProviderId: "zai-coding-plan",
defaults: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
+2 -2
View File
@@ -228,12 +228,12 @@ describe("provider-ids", () => {
await expect(getProvider("zai-coding-plan")).resolves.toMatchObject({
id: "zai-coding-plan",
baseUrl: "https://api.z.ai/api/coding/paas/v4",
defaultModelId: "glm-5.2",
defaultModelId: "glm-5.3",
client: "openai-compatible",
});
const models = await getModelsForProvider("zai-coding-plan");
expect(Object.hasOwn(models, "glm-5.2")).toBe(true);
expect(Object.hasOwn(models, "glm-5.3")).toBe(true);
});
it("routes SAP AI Core through the SAP AI SDK provider factory", async () => {
+2 -2
View File
@@ -264,10 +264,10 @@ describe("resolveBedrockModelId", () => {
}),
).toBe("us.anthropic.claude-sonnet-4-6");
expect(
resolveBedrockModelId("anthropic.claude-sonnet-4-5-20250929-v1:0", {
resolveBedrockModelId("anthropic.claude-haiku-4-5-20251001-v1:0", {
region: "eu-central-1",
}),
).toBe("eu.anthropic.claude-sonnet-4-5-20250929-v1:0");
).toBe("eu.anthropic.claude-haiku-4-5-20251001-v1:0");
expect(
resolveBedrockModelId("anthropic.claude-sonnet-5", {
region: "us-east-1",