feat: add cc-switch compatibility mode#435
Conversation
|
@WhiskyChoy is attempting to deploy a commit to the op7418's projects Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
This PR introduces an optional cc-switch compatibility mode that preserves env as a valid default provider target and persists/restores provider-level effort defaults so new chats start with more consistent settings.
Changes:
- Add
cc_switch_compat_modeapp setting + Settings UI toggle with i18n + docs updates. - Adjust default-provider “auto-heal” behavior to keep
envwhen compat mode is enabled. - Persist/re-hydrate provider
effortvia provider options API/DB, and gate first-send until compat-mode + provider options are loaded.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/index.ts | Extends ProviderOptions with effort. |
| src/lib/provider-doctor.ts | Treats env as a valid default provider under compat mode for diagnostics. |
| src/lib/db.ts | Persists effort for env via settings; supports options_json for DB providers. |
| src/i18n/en.ts / src/i18n/zh.ts | Adds UI strings for compat-mode toggle. |
| src/hooks/useCcSwitchCompatMode.ts | New hook to load compat-mode setting client-side. |
| src/components/settings/GeneralSection.tsx | Adds compat-mode toggle and persists it via app settings API. |
| src/components/chat/ChatView.tsx | Rehydrates provider effort (compat mode) and gates sending on readiness. |
| src/app/chat/page.tsx | Same as ChatView for new chat flow; gates first send. |
| src/app/api/settings/app/route.ts | Allows persisting cc_switch_compat_mode. |
| src/app/api/providers/options/route.ts | Documents effort support for provider options API. |
| src/app/api/providers/models/route.ts | Skips auto-heal for env default when compat mode is enabled. |
| src/tests/unit/stale-default-provider.test.ts | Adds tests for compat-mode default-provider behavior. |
| src/tests/unit/provider-options.test.ts | Adds env provider options test for effort persistence. |
| apps/site/content/docs/en/providers.mdx / zh/providers.mdx | Documents compat-mode behavior for cc-switch users. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| protocol: 'anthropic', | ||
| provider_type: 'anthropic', | ||
| api_key: process.env.ANTHROPIC_API_KEY || '', | ||
| base_url: process.env.ANTHROPIC_BASE_URL || '', | ||
| } |
There was a problem hiding this comment.
When cc-switch compat mode treats env as the default provider, this synthetic provider only considers ANTHROPIC_API_KEY. Users authenticating via ANTHROPIC_AUTH_TOKEN (or the DB anthropic_auth_token) will be incorrectly flagged as “no API key” even though credentials are present. Consider either (1) extending the check to treat auth-token credentials as valid for env, or (2) setting a dedicated hasCredentials flag / adjusting the warning logic for env defaults.
There was a problem hiding this comment.
Good catch. I updated the compat-mode env path so Provider Doctor now treats ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, and the stored app auth token as valid env credentials before warning about the default provider. Fixed in 0af8419.
src/components/chat/ChatView.tsx
Outdated
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| providerId: currentProviderId || 'env', | ||
| options: { effort: effort || '' }, |
There was a problem hiding this comment.
handleEffortChange serializes effort as effort || ''. Sending an empty string can end up persisting an invalid effort value (especially for non-env providers where options are stored as JSON) and makes it harder to distinguish “unset” from a real value. Prefer omitting the effort key when unset, or normalize '' to undefined server-side before persisting.
| options: { effort: effort || '' }, | |
| options: effort === undefined ? {} : { effort }, |
There was a problem hiding this comment.
Agreed. I stopped sending empty-string effort values from the client, and the provider-options API now treats null/missing as an explicit clear instead of persisting an invalid sentinel. Fixed in 0af8419.
src/app/chat/page.tsx
Outdated
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| providerId: currentProviderId || 'env', | ||
| options: { effort: effort || '' }, |
There was a problem hiding this comment.
Same as ChatView: sending effort: effort || '' can persist an invalid empty-string effort value in options_json for DB providers. Prefer omitting the key when unset, or normalize empty strings to “unset” on the API side.
| options: { effort: effort || '' }, | |
| options: effort ? { effort } : {}, |
There was a problem hiding this comment.
Fixed in 0af8419. New-chat and existing-session flows now both avoid effort: ""; they send a real effort value or an explicit clear, and the API normalizes that consistently.
| /** | ||
| * PUT /api/providers/options | ||
| * Update per-provider options. Body: { providerId, options: { thinking_mode?, context_1m? } } | ||
| * Update per-provider options. Body: { providerId, options: { thinking_mode?, context_1m?, effort? } } | ||
| */ |
There was a problem hiding this comment.
This endpoint now advertises effort, but it still accepts and persists arbitrary values without validation/normalization. It would be safer to explicitly validate effort against the supported set (and optionally treat ''/null as “unset”) before merging and writing to DB, to avoid storing invalid provider option state.
There was a problem hiding this comment.
Accepted. I introduced a shared provider-options sanitizer so the route now validates effort against the supported set and rejects malformed values instead of storing them. It also preserves an explicit clear path for compat mode. Fixed in 0af8419.
src/lib/db.ts
Outdated
| const effort = getSetting('effort') || undefined; | ||
| return { | ||
| thinking_mode: thinkingMode as 'adaptive' | 'enabled' | 'disabled', | ||
| context_1m: context1m, | ||
| ...(effort ? { effort: effort as import('@/types').ProviderOptions['effort'] } : {}), |
There was a problem hiding this comment.
effort is read from the settings table and type-cast without validating that the stored value is one of the supported levels. If an invalid value is written (e.g., from a malformed API call), it will be rehydrated as-if valid. Consider validating/sanitizing here (and/or in the API route) and dropping unknown effort values.
There was a problem hiding this comment.
Good point. The same shared sanitizer is now used on DB reads/writes for provider options, so invalid stored effort values are dropped during rehydration as well. I added targeted tests for invalid and clear cases in 0af8419.
src/hooks/useCcSwitchCompatMode.ts
Outdated
| import { useCallback, useEffect, useState } from 'react'; | ||
|
|
||
| export function useCcSwitchCompatMode(): { enabled: boolean; ready: boolean } { | ||
| const [enabled, setEnabled] = useState(false); | ||
| const [ready, setReady] = useState(false); | ||
|
|
||
| const fetchSetting = useCallback(() => { | ||
| setReady(false); | ||
| fetch('/api/settings/app') | ||
| .then(res => res.ok ? res.json() : null) | ||
| .then(data => { | ||
| setEnabled(data?.settings?.cc_switch_compat_mode === 'true'); | ||
| }) | ||
| .catch(() => { | ||
| setEnabled(false); | ||
| }) | ||
| .finally(() => { | ||
| setReady(true); | ||
| }); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| fetchSetting(); | ||
| window.addEventListener('app-settings-changed', fetchSetting); | ||
| return () => window.removeEventListener('app-settings-changed', fetchSetting); |
There was a problem hiding this comment.
This hook updates state after an async fetch without any cancellation/guard. If the component unmounts while the request is in-flight, React can warn about state updates on unmounted components. Consider using an AbortController (and ignoring AbortError) or a let cancelled = false guard in the effect.
| import { useCallback, useEffect, useState } from 'react'; | |
| export function useCcSwitchCompatMode(): { enabled: boolean; ready: boolean } { | |
| const [enabled, setEnabled] = useState(false); | |
| const [ready, setReady] = useState(false); | |
| const fetchSetting = useCallback(() => { | |
| setReady(false); | |
| fetch('/api/settings/app') | |
| .then(res => res.ok ? res.json() : null) | |
| .then(data => { | |
| setEnabled(data?.settings?.cc_switch_compat_mode === 'true'); | |
| }) | |
| .catch(() => { | |
| setEnabled(false); | |
| }) | |
| .finally(() => { | |
| setReady(true); | |
| }); | |
| }, []); | |
| useEffect(() => { | |
| fetchSetting(); | |
| window.addEventListener('app-settings-changed', fetchSetting); | |
| return () => window.removeEventListener('app-settings-changed', fetchSetting); | |
| import { useCallback, useEffect, useRef, useState } from 'react'; | |
| export function useCcSwitchCompatMode(): { enabled: boolean; ready: boolean } { | |
| const [enabled, setEnabled] = useState(false); | |
| const [ready, setReady] = useState(false); | |
| const abortControllerRef = useRef<AbortController | null>(null); | |
| const isMountedRef = useRef(true); | |
| const fetchSetting = useCallback(() => { | |
| abortControllerRef.current?.abort(); | |
| const controller = new AbortController(); | |
| abortControllerRef.current = controller; | |
| if (isMountedRef.current) { | |
| setReady(false); | |
| } | |
| fetch('/api/settings/app', { signal: controller.signal }) | |
| .then(res => res.ok ? res.json() : null) | |
| .then(data => { | |
| if (!controller.signal.aborted && isMountedRef.current) { | |
| setEnabled(data?.settings?.cc_switch_compat_mode === 'true'); | |
| } | |
| }) | |
| .catch(err => { | |
| if (err?.name === 'AbortError') { | |
| return; | |
| } | |
| if (!controller.signal.aborted && isMountedRef.current) { | |
| setEnabled(false); | |
| } | |
| }) | |
| .finally(() => { | |
| if (!controller.signal.aborted && isMountedRef.current) { | |
| setReady(true); | |
| } | |
| }); | |
| }, []); | |
| useEffect(() => { | |
| fetchSetting(); | |
| window.addEventListener('app-settings-changed', fetchSetting); | |
| return () => { | |
| isMountedRef.current = false; | |
| abortControllerRef.current?.abort(); | |
| window.removeEventListener('app-settings-changed', fetchSetting); | |
| }; |
There was a problem hiding this comment.
Agreed. I added an abortable fetch guard in useCcSwitchCompatMode, including cleanup on unmount and on repeated refreshes, so the hook no longer updates state from a stale in-flight request. Fixed in 0af8419.
|
Follow-up update after local verification:
本地补充验证后,又追加了以下修复:
Extra validation / 补充验证:
|
关联 Issue / Related Issue: closes #434
Summary / 概述
This PR adds an optional
cc-switchcompatibility mode in Settings. When enabled, CodePilot keeps reusing the built-inClaude Code/envprovider, persists the selected default model back into the global default-model settings, and remembers the selected effort level so new chats start with the same cc-switch-style defaults.这个 PR 在设置中增加了一个可选的
cc-switch兼容模式。开启后,CodePilot 会继续复用内置的Claude Code/env提供商,把当前选中的默认模型回写到全局默认模型设置中,并记住已选择的 effort,让新对话从一开始就保持与 cc-switch 一致的默认状态。Motivation / 背景
Some users manage Claude Code mainly through environment switching tools such as
cc-switch. In that workflow, the effective default provider is often the shell-backedenvtarget, and users also expect both the selected model and effort choice to persist across new chats.有些用户主要通过
cc-switch这类环境切换工具来管理 Claude Code。在这种工作流下,实际默认提供商通常就是依赖 shell 环境的env,同时用户也希望已选模型和 effort 都能跨新对话延续。Changes / 修改内容
add a new Settings > General toggle for
cc-switchcompatibility modeallow app settings API to persist
cc_switch_compat_modewhen compat mode is enabled, keep
envfrom being auto-healed away as a stale default providerpersist
effortin provider options and rehydrate it for both new-chat and existing-session chat viewspersist
envmodel changes back into the global default model settings from both new-chat and existing-session model selectorsexpose effort metadata for built-in
envfallback models even before SDK model metadata is loadedhide legacy migrated empty
Defaultproviders from provider APIs so compat mode does not appear to create an extra provideradd English and Chinese UI copy plus targeted unit tests for compat-mode default persistence and legacy-placeholder filtering
新增“设置 > 通用 > 开启 cc-switch 兼容模式”开关
允许应用设置接口持久化
cc_switch_compat_mode兼容模式开启时,不再把
env误判为需要自动修复的过期默认提供商在 provider options 中持久化
effort,并在新对话与已有会话视图里恢复它在新对话页和已有会话页切换
env模型时,将其回写到全局默认模型设置,确保默认模型能持久化在 SDK 模型元数据尚未加载时,也为内置
env回退模型暴露 effort 能力信息从 provider API 中隐藏历史迁移留下的空白
Default占位提供商,避免兼容模式看起来像额外新增了一个 provider增加中英文 UI 文案,并补充针对兼容模式默认值持久化与历史占位 provider 过滤的单元测试
Test plan / 测试情况
npm run typechecknpx tsx --test src/__tests__/unit/cc-switch-compat.test.ts src/__tests__/unit/legacy-provider-placeholder.test.ts src/__tests__/unit/stale-default-provider.test.tsproviderId=__global__default model persistence (default_model=opus,default_model_provider=env)cc-switchenvironmentNotes / 说明
I kept the validation focused on the affected coverage because
npm run test:unitstill has existing unrelated failures in this repo.The local dev-only Next.js HMR adjustment I used while testing Electron on Windows is intentionally not included in this PR.
由于仓库里仍有与本 PR 无关的旧测试失败,我把验证重点放在这次改动直接影响到的覆盖范围上。
我在 Windows 下调试 Electron 时使用过一个仅本地开发需要的 Next.js HMR 调整,但它没有被包含进这个 PR。