From 927f4de98aad38b1b085d887cc9520e356a1bb87 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 11 Aug 2026 13:23:10 +0800 Subject: [PATCH 1/6] fix(agents): refresh OAuth model aliases at runtime --- src-tauri/src/core_config/aliases.rs | 83 +++++++++++++++++++--- src-tauri/src/tests/agent_configuration.rs | 31 ++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index e93c6be..9d10f74 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -64,6 +64,79 @@ pub(crate) async fn put_management_config_yaml( read_management_value(response).await.map(|_| ()) } +pub(crate) async fn put_management_oauth_model_aliases( + config: &GuiConfigFile, + aliases: &serde_json::Value, +) -> Result<(), String> { + let client = management_http_client()?; + let response = client + .put(management_endpoint(config, "oauth-model-alias")?) + .header("Authorization", management_authorization(config)?) + .json(aliases) + .send() + .await + .map_err(|error| format!("保存 OAuth 模型别名失败: {error}"))?; + read_management_value(response).await.map(|_| ()) +} + +#[derive(Debug, PartialEq)] +pub(crate) struct ManagementAliasConfigChanges { + pub(crate) oauth_model_aliases: Option, + pub(crate) update_config_yaml: bool, +} + +pub(crate) fn management_alias_config_changes( + current: &str, + updated: &str, +) -> Result { + let split = |content: &str| { + let mut document = serde_norway::from_str::(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let root = document + .as_mapping_mut() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let oauth_aliases = root + .remove(yaml_key("oauth-model-alias")) + .unwrap_or_else(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())); + Ok::<_, String>((document, oauth_aliases)) + }; + let (current_without_oauth, current_oauth) = split(current)?; + let (updated_without_oauth, updated_oauth) = split(updated)?; + let oauth_model_aliases = if current_oauth == updated_oauth { + None + } else { + Some( + serde_json::to_value(updated_oauth) + .map_err(|error| format!("序列化 OAuth 模型别名失败: {error}"))?, + ) + }; + Ok(ManagementAliasConfigChanges { + oauth_model_aliases, + update_config_yaml: current_without_oauth != updated_without_oauth, + }) +} + +pub(crate) async fn put_management_alias_config_changes( + config: &GuiConfigFile, + current: &str, + updated: &str, +) -> Result<(), String> { + if updated == current { + return Ok(()); + } + let changes = management_alias_config_changes(current, updated)?; + if let Some(oauth_model_aliases) = changes.oauth_model_aliases.as_ref() { + // The dedicated endpoint refreshes CPA's OAuth model registry immediately. + // Writing the same section only through config.yaml updates the file and + // management snapshot, but can leave /v1/models and routing stale. + put_management_oauth_model_aliases(config, oauth_model_aliases).await?; + } + if changes.update_config_yaml { + put_management_config_yaml(config, updated).await?; + } + Ok(()) +} + pub(crate) async fn ensure_claude_desktop_model_aliases( config: &GuiConfigFile, mappings: &ClaudeDesktopModelMappings, @@ -84,10 +157,7 @@ pub(crate) async fn ensure_claude_desktop_model_aliases( )? } }; - if updated != content { - put_management_config_yaml(config, &updated).await?; - } - Ok(()) + put_management_alias_config_changes(config, &content, &updated).await } pub(crate) async fn remove_managed_claude_model_aliases( @@ -95,10 +165,7 @@ pub(crate) async fn remove_managed_claude_model_aliases( ) -> Result<(), String> { let content = fetch_management_config_yaml(config).await?; let updated = remove_managed_claude_model_aliases_in_yaml(&content)?; - if updated != content { - put_management_config_yaml(config, &updated).await?; - } - Ok(()) + put_management_alias_config_changes(config, &content, &updated).await } pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml( diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 2cae070..a5ebd70 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -905,6 +905,37 @@ fn claude_desktop_aliases_support_codex_oauth_models() { ); } +#[test] +fn claude_oauth_alias_changes_use_the_runtime_refresh_endpoint() { + let current = "port: 8317\nopenai-compatibility: []\n"; + let oauth_only = "port: 8317\nopenai-compatibility: []\noauth-model-alias:\n codex:\n - name: gpt-5.6-sol\n alias: claude-opus-5\n fork: true\n"; + let changes = management_alias_config_changes(current, oauth_only).unwrap(); + + assert!(!changes.update_config_yaml); + assert_eq!( + changes.oauth_model_aliases, + Some(serde_json::json!({ + "codex": [{ + "name": "gpt-5.6-sol", + "alias": "claude-opus-5", + "fork": true, + }] + })) + ); + + let mixed = oauth_only.replace( + "openai-compatibility: []", + "openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1", + ); + let changes = management_alias_config_changes(current, &mixed).unwrap(); + assert!(changes.update_config_yaml); + assert!(changes.oauth_model_aliases.is_some()); + + let changes = management_alias_config_changes(oauth_only, current).unwrap(); + assert!(!changes.update_config_yaml); + assert_eq!(changes.oauth_model_aliases, Some(serde_json::json!({}))); +} + #[test] fn claude_managed_aliases_expand_flow_yaml_and_are_removed_selectively() { let compact = "# keep this comment\ncodex-api-key: [{api-key: test, models: [{name: gpt-5.6-sol, alias: ''}]}]\ncredential-concurrency: {max-limit: 1000000, cleanup-interval: 5s}\n"; From 4484e3a1901c8df0e008e6b1d6f09089dd77140d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 11 Aug 2026 14:04:46 +0800 Subject: [PATCH 2/6] fix(agents): isolate Claude client model mapping drafts --- src-tauri/src/main.rs | 2 +- src-tauri/src/tests/agent_configuration.rs | 6 ++--- src/i18n/ja.ts | 2 +- src/i18n/locales/en.ts | 2 +- src/i18n/locales/zh-CN.ts | 2 +- src/pages/AgentsPage.tsx | 29 ++++++++++++++-------- src/services/agentConfigurationDraft.ts | 9 ++++++- tests/agentModelPicker.test.ts | 29 ++++++++++++++++++++++ 8 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6567cf5..ff8b9a5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -146,7 +146,7 @@ const CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW: u64 = 1_000_000; const CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV: &str = "CLAUDE_CODE_MAX_CONTEXT_TOKENS"; const CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV: &str = "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE"; const DISABLE_AUTO_COMPACT_ENV: &str = "DISABLE_AUTO_COMPACT"; -const DEFAULT_CLAUDE_AUTO_COMPACT_PCT: u8 = 100; +const DEFAULT_CLAUDE_AUTO_COMPACT_PCT: u8 = 90; const MODEL_ALIAS_CONFIG_SECTIONS: &[&str] = &[ "codex-api-key", "openai-compatibility", diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index a5ebd70..16ea01e 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -23,7 +23,7 @@ fn claude_agent_config_preserves_existing_fields() { assert_eq!(value["env"]["ANTHROPIC_AUTH_TOKEN"], DEFAULT_API_KEY); assert_eq!(value["env"]["ANTHROPIC_MODEL"], "claude-test"); assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "200000"); - assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "100"); + assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "90"); assert!(value["env"].get(DISABLE_AUTO_COMPACT_ENV).is_none()); assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none()); assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "claude-test"); @@ -66,7 +66,7 @@ fn claude_code_inspection_normalizes_legacy_1m_suffix() { assert!(mappings.sonnet_1m); assert!(!mappings.haiku_1m); assert_eq!(mappings.max_context_tokens, 1_000_000); - assert_eq!(mappings.auto_compact_pct, 100); + assert_eq!(mappings.auto_compact_pct, 90); assert!(!mappings.disable_auto_compact); fs::remove_dir_all(directory).unwrap(); } @@ -80,7 +80,7 @@ fn claude_mapping_legacy_json_defaults_1m_preferences_off() { assert!(!mappings.sonnet_1m); assert!(!mappings.haiku_1m); assert_eq!(mappings.max_context_tokens, 200_000); - assert_eq!(mappings.auto_compact_pct, 100); + assert_eq!(mappings.auto_compact_pct, 90); assert!(!mappings.disable_auto_compact); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 5e4d014..a37d0a8 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -576,7 +576,7 @@ export const jaOverrides = { 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code の既定値は 200000 です。上流モデルの上限を超えないでください。', 'agents.claudeCodeRuntime.context1mHint': 'このマッピングで [1m] モデルを使用し、最大コンテキストを 1000000 にします。3 つすべてを無効にすると 200000 に戻ります。', 'agents.claudeCodeRuntime.autoCompactPct': '自動圧縮の開始率', - 'agents.claudeCodeRuntime.autoCompactPctHint': '既定値は 100% です。小さい値ほど早く圧縮します。', + 'agents.claudeCodeRuntime.autoCompactPctHint': '既定値は 90% です。小さい値ほど早く圧縮します。', 'agents.claudeCodeRuntime.disableAutoCompact': '自動圧縮を無効化', 'agents.claudeCodeRuntime.disableAutoCompactHint': '有効時は /compact による手動圧縮のみ利用できます。', 'agents.claudeDesktopMapping.customMapping': 'モデルエイリアスを使用', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 33ec00d..67fb223 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -571,7 +571,7 @@ export const en: Record = { 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code defaults to 200000. Do not exceed the upstream model limit.', 'agents.claudeCodeRuntime.context1mHint': 'Uses the [1m] model for this mapping and sets the maximum context to 1000000. It returns to 200000 after all three mappings are off.', 'agents.claudeCodeRuntime.autoCompactPct': 'Auto-compact trigger', - 'agents.claudeCodeRuntime.autoCompactPctHint': 'Defaults to 100%. Lower values compact earlier.', + 'agents.claudeCodeRuntime.autoCompactPctHint': 'Defaults to 90%. Lower values compact earlier.', 'agents.claudeCodeRuntime.disableAutoCompact': 'Disable auto-compaction', 'agents.claudeCodeRuntime.disableAutoCompactHint': 'Only manual /compact remains available when enabled.', 'agents.claudeDesktopMapping.customMapping': 'Use Model Aliases', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 08061f6..7075629 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -569,7 +569,7 @@ export const zhCN = { 'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code 默认 200000;请勿超过上游模型实际支持的窗口。', 'agents.claudeCodeRuntime.context1mHint': '为此映射使用 [1m] 模型并将最大窗口设为 1000000;三个映射均关闭后恢复为 200000。', 'agents.claudeCodeRuntime.autoCompactPct': '触发压缩百分比', - 'agents.claudeCodeRuntime.autoCompactPctHint': '默认 100;调低后会更早自动压缩。', + 'agents.claudeCodeRuntime.autoCompactPctHint': '默认 90%;调低后会更早自动压缩。', 'agents.claudeCodeRuntime.disableAutoCompact': '禁止自动压缩', 'agents.claudeCodeRuntime.disableAutoCompactHint': '开启后仅允许用户通过 /compact 手动压缩。', 'agents.claudeDesktopMapping.customMapping': '使用模型别名', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 279b228..d008e74 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -39,6 +39,7 @@ import { } from '../services/agentModelPicker'; import { resolveAgentConfigurationAction, + resolveAgentModelMappingsDraftSource, sameAgentModel, sameAgentModelMappings, } from '../services/agentConfigurationDraft'; @@ -111,7 +112,7 @@ type ClaudeModelMappings = { const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED'; const DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS = 200_000; -const DEFAULT_CLAUDE_AUTO_COMPACT_PCT = 100; +const DEFAULT_CLAUDE_AUTO_COMPACT_PCT = 90; const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({ opus: model, @@ -720,18 +721,24 @@ export function AgentsPage() { const appliedMappings = selected === 'claude-code' ? activeStatus?.claudeCodeModelMappings : activeStatus?.claudeDesktopModelMappings; - if (!claudeModelMappingsDirtyRef.current && appliedMappings) { - const appliedModels = claudeMappingRoles - .map((role) => findAgentModel(models, appliedMappings[role.key])) - .filter((model): model is ModelOption => model !== null); - if (appliedModels.length === claudeMappingRoles.length) { - setClaudeCustomMapping(appliedModels.every((model) => Boolean(model.isAlias))); - } + if (!claudeModelMappingsDirtyRef.current) { + const appliedModels = appliedMappings + ? claudeMappingRoles + .map((role) => findAgentModel(models, appliedMappings[role.key])) + .filter((model): model is ModelOption => model !== null) + : []; + setClaudeCustomMapping( + appliedModels.length === claudeMappingRoles.length + && appliedModels.every((model) => Boolean(model.isAlias)), + ); } setClaudeModelMappingsDraft((current) => { - const source = claudeModelMappingsDirtyRef.current - ? current - : appliedMappings ?? current; + const source = resolveAgentModelMappingsDraftSource( + current, + appliedMappings, + createClaudeModelMappings(selectedModel), + claudeModelMappingsDirtyRef.current, + ); const next: ClaudeModelMappings = { opus: findAgentModel(models, source.opus)?.name ?? selectedModel, sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel, diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index 6699acf..ad7c5a9 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -50,9 +50,16 @@ export const sameAgentModelMappings = ( && Boolean(left.sonnet1m) === Boolean(right.sonnet1m) && Boolean(left.haiku1m) === Boolean(right.haiku1m) && (left.maxContextTokens ?? 200_000) === (right.maxContextTokens ?? 200_000) - && (left.autoCompactPct ?? 100) === (right.autoCompactPct ?? 100) + && (left.autoCompactPct ?? 90) === (right.autoCompactPct ?? 90) && Boolean(left.disableAutoCompact) === Boolean(right.disableAutoCompact); +export const resolveAgentModelMappingsDraftSource = ( + current: T, + applied: T | null | undefined, + fallback: T, + dirty: boolean, +): T => (dirty ? current : applied ?? fallback); + export function resolveAgentConfigurationAction({ client, modificationState, diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index df7d980..0476abd 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -10,6 +10,8 @@ import { } from '../src/services/agentModelPicker'; import { resolveAgentConfigurationAction, + resolveAgentModelMappingsDraftSource, + sameAgentModelMappings, type AgentConfigurationClientId, } from '../src/services/agentConfigurationDraft'; @@ -98,6 +100,33 @@ const appliedConfiguration = ( }); describe('agent configuration update action', () => { + test('Claude mapping clients do not inherit another client draft', () => { + const codeDraft = { opus: 'code-opus', sonnet: 'code-sonnet', haiku: 'code-haiku' }; + const desktopFallback = { + opus: 'desktop-model', + sonnet: 'desktop-model', + haiku: 'desktop-model', + }; + expect(resolveAgentModelMappingsDraftSource( + codeDraft, + null, + desktopFallback, + false, + )).toEqual(desktopFallback); + expect(resolveAgentModelMappingsDraftSource( + codeDraft, + null, + desktopFallback, + true, + )).toEqual(codeDraft); + }); + + test('Claude Code missing auto-compact preference defaults to 90%', () => { + const mappings = { opus: 'model-a', sonnet: 'model-a', haiku: 'model-a' }; + expect(sameAgentModelMappings(mappings, { ...mappings, autoCompactPct: 90 })).toBeTrue(); + expect(sameAgentModelMappings(mappings, { ...mappings, autoCompactPct: 100 })).toBeFalse(); + }); + test.each([ 'claude-code', 'claude-desktop', From 476d6e6769aded9ff0d04336133575fb1759af5f Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 11 Aug 2026 14:12:16 +0800 Subject: [PATCH 3/6] fix(agents): isolate Claude Code and Desktop mapping state --- src/pages/AgentsPage.tsx | 136 ++++++++++++++++-------- src/services/agentConfigurationDraft.ts | 15 +++ tests/agentModelPicker.test.ts | 28 ++++- 3 files changed, 132 insertions(+), 47 deletions(-) diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index d008e74..b3cf866 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -39,7 +39,7 @@ import { } from '../services/agentModelPicker'; import { resolveAgentConfigurationAction, - resolveAgentModelMappingsDraftSource, + resolveAgentModelMappingsDraftSourceForClient, sameAgentModel, sameAgentModelMappings, } from '../services/agentConfigurationDraft'; @@ -56,6 +56,8 @@ type AgentClientId = | 'hermes' | 'pi'; +type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop'; + type AgentModificationState = 'unconfigured' | 'applied' | 'invalid'; type AgentConfigStatus = { @@ -126,6 +128,19 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({ disableAutoCompact: false, }); +const createClaudeModelMappingsByClient = (): Record< + ClaudeModelMappingClientId, + ClaudeModelMappings +> => ({ + 'claude-code': createClaudeModelMappings(''), + 'claude-desktop': createClaudeModelMappings(''), +}); + +const createClaudeBooleanByClient = (): Record => ({ + 'claude-code': false, + 'claude-desktop': false, +}); + const claudeMappingRoles = [ { key: 'opus', @@ -548,10 +563,12 @@ export function AgentsPage() { const [modelByClient, setModelByClient] = useState>>( readAgentModelSelections, ); - const [claudeModelMappingsDraft, setClaudeModelMappingsDraft] = useState( - createClaudeModelMappings(''), + const [claudeModelMappingsDraftByClient, setClaudeModelMappingsDraftByClient] = useState( + createClaudeModelMappingsByClient, + ); + const [claudeCustomMappingByClient, setClaudeCustomMappingByClient] = useState( + createClaudeBooleanByClient, ); - const [claudeCustomMapping, setClaudeCustomMapping] = useState(false); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< @@ -572,7 +589,7 @@ export function AgentsPage() { const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState(null); const modelRequestRef = useRef(0); const piUpdateRequestRef = useRef(0); - const claudeModelMappingsDirtyRef = useRef(false); + const claudeModelMappingsDirtyRef = useRef(createClaudeBooleanByClient()); const loadStatuses = useCallback(async (forceRefresh = false) => { const command = forceRefresh @@ -668,7 +685,9 @@ export function AgentsPage() { setClearConfirmOpen(false); setOauthLoginRequiredAction(null); setOauthConfigurationDraft(null); - claudeModelMappingsDirtyRef.current = false; + if (selected === 'claude-code' || selected === 'claude-desktop') { + claudeModelMappingsDirtyRef.current[selected] = false; + } }, [selected]); const activeDefinition = agentDefinitions.find((agent) => agent.id === selected) @@ -682,6 +701,12 @@ export function AgentsPage() { const selectedModel = selectedModelOption?.name ?? ''; const isPiClient = selected === 'pi'; const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop'; + const claudeModelMappingsDraft = isClaudeModelMappingClient + ? claudeModelMappingsDraftByClient[selected] + : createClaudeModelMappings(''); + const claudeCustomMapping = isClaudeModelMappingClient + ? claudeCustomMappingByClient[selected] + : false; const claudeMappingModels = useMemo( () => filterAgentModelsByAlias(models, claudeCustomMapping), [claudeCustomMapping, models], @@ -721,23 +746,27 @@ export function AgentsPage() { const appliedMappings = selected === 'claude-code' ? activeStatus?.claudeCodeModelMappings : activeStatus?.claudeDesktopModelMappings; - if (!claudeModelMappingsDirtyRef.current) { + const dirty = claudeModelMappingsDirtyRef.current[selected]; + if (!dirty) { const appliedModels = appliedMappings ? claudeMappingRoles .map((role) => findAgentModel(models, appliedMappings[role.key])) .filter((model): model is ModelOption => model !== null) : []; - setClaudeCustomMapping( - appliedModels.length === claudeMappingRoles.length + setClaudeCustomMappingByClient((current) => ({ + ...current, + [selected]: appliedModels.length === claudeMappingRoles.length && appliedModels.every((model) => Boolean(model.isAlias)), - ); + })); } - setClaudeModelMappingsDraft((current) => { - const source = resolveAgentModelMappingsDraftSource( + setClaudeModelMappingsDraftByClient((current) => { + const currentClientDraft = current[selected]; + const source = resolveAgentModelMappingsDraftSourceForClient( current, + selected, appliedMappings, createClaudeModelMappings(selectedModel), - claudeModelMappingsDirtyRef.current, + dirty, ); const next: ClaudeModelMappings = { opus: findAgentModel(models, source.opus)?.name ?? selectedModel, @@ -750,7 +779,9 @@ export function AgentsPage() { autoCompactPct: source.autoCompactPct ?? DEFAULT_CLAUDE_AUTO_COMPACT_PCT, disableAutoCompact: Boolean(source.disableAutoCompact), }; - return sameAgentModelMappings(current, next) ? current : next; + return sameAgentModelMappings(currentClientDraft, next) + ? current + : { ...current, [selected]: next }; }); }, [ activeStatus?.claudeCodeModelMappings, @@ -849,26 +880,30 @@ export function AgentsPage() { value: string, ) => { const model = findAgentModel(models, value); - if (!model) return; - claudeModelMappingsDirtyRef.current = true; + if (!model || !isClaudeModelMappingClient) return; + claudeModelMappingsDirtyRef.current[selected] = true; setModelSelectionError(''); - setClaudeModelMappingsDraft((current) => ({ ...current, [role]: model.name })); + setClaudeModelMappingsDraftByClient((current) => ({ + ...current, + [selected]: { ...current[selected], [role]: model.name }, + })); }; const changeClaude1mPreference = ( preference: 'opus1m' | 'sonnet1m' | 'haiku1m', enabled: boolean, ) => { - claudeModelMappingsDirtyRef.current = true; - setClaudeModelMappingsDraft((current) => { - const next = { ...current, [preference]: enabled }; + if (!isClaudeModelMappingClient) return; + claudeModelMappingsDirtyRef.current[selected] = true; + setClaudeModelMappingsDraftByClient((current) => { + const next = { ...current[selected], [preference]: enabled }; if (selected === 'claude-code') { const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m; next.maxContextTokens = any1mEnabled ? 1_000_000 : DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS; } - return next; + return { ...current, [selected]: next }; }); }; @@ -876,37 +911,47 @@ export function AgentsPage() { key: 'maxContextTokens' | 'autoCompactPct', value: number, ) => { - claudeModelMappingsDirtyRef.current = true; - setClaudeModelMappingsDraft((current) => ({ ...current, [key]: value })); + if (selected !== 'claude-code') return; + claudeModelMappingsDirtyRef.current[selected] = true; + setClaudeModelMappingsDraftByClient((current) => ({ + ...current, + [selected]: { ...current[selected], [key]: value }, + })); }; const changeClaudeCodeAutoCompactDisabled = (disabled: boolean) => { - claudeModelMappingsDirtyRef.current = true; - setClaudeModelMappingsDraft((current) => ({ + if (selected !== 'claude-code') return; + claudeModelMappingsDirtyRef.current[selected] = true; + setClaudeModelMappingsDraftByClient((current) => ({ ...current, - disableAutoCompact: disabled, + [selected]: { + ...current[selected], + disableAutoCompact: disabled, + }, })); }; const changeClaudeCustomMapping = (enabled: boolean) => { - setClaudeCustomMapping(enabled); + if (!isClaudeModelMappingClient) return; + setClaudeCustomMappingByClient((current) => ({ ...current, [selected]: enabled })); setModelSelectionError(''); - setClaudeModelMappingsDraft((current) => { + setClaudeModelMappingsDraftByClient((current) => { + const currentClientDraft = current[selected]; const next: ClaudeModelMappings = { - opus: resolveAgentModelForAliasMode(models, current.opus, enabled), - sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled), - haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled), - opus1m: current.opus1m, - sonnet1m: current.sonnet1m, - haiku1m: current.haiku1m, - maxContextTokens: current.maxContextTokens, - autoCompactPct: current.autoCompactPct, - disableAutoCompact: current.disableAutoCompact, + opus: resolveAgentModelForAliasMode(models, currentClientDraft.opus, enabled), + sonnet: resolveAgentModelForAliasMode(models, currentClientDraft.sonnet, enabled), + haiku: resolveAgentModelForAliasMode(models, currentClientDraft.haiku, enabled), + opus1m: currentClientDraft.opus1m, + sonnet1m: currentClientDraft.sonnet1m, + haiku1m: currentClientDraft.haiku1m, + maxContextTokens: currentClientDraft.maxContextTokens, + autoCompactPct: currentClientDraft.autoCompactPct, + disableAutoCompact: currentClientDraft.disableAutoCompact, }; - if (!sameAgentModelMappings(current, next)) { - claudeModelMappingsDirtyRef.current = true; + if (!sameAgentModelMappings(currentClientDraft, next)) { + claudeModelMappingsDirtyRef.current[selected] = true; } - return next; + return { ...current, [selected]: next }; }); }; @@ -997,9 +1042,11 @@ export function AgentsPage() { claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null, claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, }); + if (isClaudeModelMappingClient) { + claudeModelMappingsDirtyRef.current[selected] = false; + } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); - claudeModelMappingsDirtyRef.current = false; } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { setConfigurationError(String(requestError)); @@ -1075,6 +1122,9 @@ export function AgentsPage() { setBusyAction('close-config'); try { await invoke('close_agent_config_modification', { client: selected }); + if (isClaudeModelMappingClient) { + claudeModelMappingsDirtyRef.current[selected] = false; + } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); } catch (requestError) { @@ -1102,9 +1152,11 @@ export function AgentsPage() { claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, }); setDefaultConfirmOpen(false); + if (isClaudeModelMappingClient) { + claudeModelMappingsDirtyRef.current[selected] = false; + } await reloadStatusesAfterAction(); setOauthConfigurationDraft(null); - claudeModelMappingsDirtyRef.current = false; } catch (requestError) { if (!handleOAuthLoginError(requestError, 'apply')) { setDefaultError(String(requestError)); diff --git a/src/services/agentConfigurationDraft.ts b/src/services/agentConfigurationDraft.ts index ad7c5a9..18086a9 100644 --- a/src/services/agentConfigurationDraft.ts +++ b/src/services/agentConfigurationDraft.ts @@ -11,6 +11,8 @@ export type AgentConfigurationModificationState = 'unconfigured' | 'applied' | ' export type AgentConfigurationAction = 'apply' | 'update' | 'close'; +export type ClaudeModelMappingClientId = 'claude-code' | 'claude-desktop'; + export type AgentModelMappings = { opus: string; sonnet: string; @@ -60,6 +62,19 @@ export const resolveAgentModelMappingsDraftSource = ( dirty: boolean, ): T => (dirty ? current : applied ?? fallback); +export const resolveAgentModelMappingsDraftSourceForClient = ( + currentByClient: Record, + client: ClaudeModelMappingClientId, + applied: T | null | undefined, + fallback: T, + dirty: boolean, +): T => resolveAgentModelMappingsDraftSource( + currentByClient[client], + applied, + fallback, + dirty, +); + export function resolveAgentConfigurationAction({ client, modificationState, diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index 0476abd..2fad359 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -10,7 +10,7 @@ import { } from '../src/services/agentModelPicker'; import { resolveAgentConfigurationAction, - resolveAgentModelMappingsDraftSource, + resolveAgentModelMappingsDraftSourceForClient, sameAgentModelMappings, type AgentConfigurationClientId, } from '../src/services/agentConfigurationDraft'; @@ -102,19 +102,37 @@ const appliedConfiguration = ( describe('agent configuration update action', () => { test('Claude mapping clients do not inherit another client draft', () => { const codeDraft = { opus: 'code-opus', sonnet: 'code-sonnet', haiku: 'code-haiku' }; + const desktopDraft = { + opus: 'desktop-opus', + sonnet: 'desktop-sonnet', + haiku: 'desktop-haiku', + }; const desktopFallback = { opus: 'desktop-model', sonnet: 'desktop-model', haiku: 'desktop-model', }; - expect(resolveAgentModelMappingsDraftSource( - codeDraft, + const drafts = { + 'claude-code': codeDraft, + 'claude-desktop': desktopDraft, + }; + expect(resolveAgentModelMappingsDraftSourceForClient( + drafts, + 'claude-desktop', null, desktopFallback, false, )).toEqual(desktopFallback); - expect(resolveAgentModelMappingsDraftSource( - codeDraft, + expect(resolveAgentModelMappingsDraftSourceForClient( + drafts, + 'claude-desktop', + null, + desktopFallback, + true, + )).toEqual(desktopDraft); + expect(resolveAgentModelMappingsDraftSourceForClient( + drafts, + 'claude-code', null, desktopFallback, true, From 7b192cc6a446fd59f9dac32fbb171e9f5b666f6a Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 11 Aug 2026 18:18:23 +0800 Subject: [PATCH 4/6] fix(agents): preserve unapplied Claude model drafts across navigation --- src/pages/AgentsPage.tsx | 46 ++++++++++++++++++++++++++++------ tests/agentModelPicker.test.ts | 23 +++++++++-------- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index b3cf866..ef7f5a9 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -141,6 +141,10 @@ const createClaudeBooleanByClient = (): Record>>( readAgentModelSelections, ); - const [claudeModelMappingsDraftByClient, setClaudeModelMappingsDraftByClient] = useState( - createClaudeModelMappingsByClient, + const [claudeModelMappingsDraftByClient, setClaudeModelMappingsDraftByClientState] = useState( + () => ({ + 'claude-code': { ...claudeModelMappingsDraftCache['claude-code'] }, + 'claude-desktop': { ...claudeModelMappingsDraftCache['claude-desktop'] }, + }), ); - const [claudeCustomMappingByClient, setClaudeCustomMappingByClient] = useState( - createClaudeBooleanByClient, + const [claudeCustomMappingByClient, setClaudeCustomMappingByClientState] = useState( + () => ({ ...claudeCustomMappingCache }), ); const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); @@ -589,7 +596,31 @@ export function AgentsPage() { const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState(null); const modelRequestRef = useRef(0); const piUpdateRequestRef = useRef(0); - const claudeModelMappingsDirtyRef = useRef(createClaudeBooleanByClient()); + const claudeModelMappingsDirtyRef = useRef(claudeModelMappingsDirtyCache); + + const setClaudeModelMappingsDraftByClient = useCallback(( + update: ( + current: Record, + ) => Record, + ) => { + setClaudeModelMappingsDraftByClientState((current) => { + const next = update(current); + claudeModelMappingsDraftCache = next; + return next; + }); + }, []); + + const setClaudeCustomMappingByClient = useCallback(( + update: ( + current: Record, + ) => Record, + ) => { + setClaudeCustomMappingByClientState((current) => { + const next = update(current); + claudeCustomMappingCache = next; + return next; + }); + }, []); const loadStatuses = useCallback(async (forceRefresh = false) => { const command = forceRefresh @@ -685,9 +716,8 @@ export function AgentsPage() { setClearConfirmOpen(false); setOauthLoginRequiredAction(null); setOauthConfigurationDraft(null); - if (selected === 'claude-code' || selected === 'claude-desktop') { - claudeModelMappingsDirtyRef.current[selected] = false; - } + // Preserve each Claude client's unsaved draft while navigating between clients. + // Its dirty flag is cleared only after a successful apply, close, or reset action. }, [selected]); const activeDefinition = agentDefinitions.find((agent) => agent.id === selected) diff --git a/tests/agentModelPicker.test.ts b/tests/agentModelPicker.test.ts index 2fad359..8f984ae 100644 --- a/tests/agentModelPicker.test.ts +++ b/tests/agentModelPicker.test.ts @@ -100,7 +100,7 @@ const appliedConfiguration = ( }); describe('agent configuration update action', () => { - test('Claude mapping clients do not inherit another client draft', () => { + test('Claude mapping clients keep their own unsaved draft after switching away and back', () => { const codeDraft = { opus: 'code-opus', sonnet: 'code-sonnet', haiku: 'code-haiku' }; const desktopDraft = { opus: 'desktop-opus', @@ -116,26 +116,27 @@ describe('agent configuration update action', () => { 'claude-code': codeDraft, 'claude-desktop': desktopDraft, }; + const dirtyByClient = { + 'claude-code': true, + 'claude-desktop': false, + }; + + // Switch from Claude Code to Desktop: Desktop still loads its own default. expect(resolveAgentModelMappingsDraftSourceForClient( drafts, 'claude-desktop', null, desktopFallback, - false, + dirtyByClient['claude-desktop'], )).toEqual(desktopFallback); - expect(resolveAgentModelMappingsDraftSourceForClient( - drafts, - 'claude-desktop', - null, - desktopFallback, - true, - )).toEqual(desktopDraft); + + // Switch back to Claude Code: its unapplied selection must not be overwritten. expect(resolveAgentModelMappingsDraftSourceForClient( drafts, 'claude-code', - null, + { opus: 'applied', sonnet: 'applied', haiku: 'applied' }, desktopFallback, - true, + dirtyByClient['claude-code'], )).toEqual(codeDraft); }); From be08a4bfdfd0693f6709bf9a87179a8fd3023eac Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 11 Aug 2026 18:38:57 +0800 Subject: [PATCH 5/6] fix(oauth): persist successful login status across navigation --- src/pages/ManagementPages.tsx | 67 +++++++++++---------------------- src/services/oauthLoginState.ts | 14 +++++++ tests/oauthLoginState.test.ts | 22 +++++++++++ 3 files changed, 58 insertions(+), 45 deletions(-) create mode 100644 src/services/oauthLoginState.ts create mode 100644 tests/oauthLoginState.test.ts diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index 0b69ea3..ad256e2 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -20,6 +20,10 @@ import { type AuthFileSnapshot, } from '../services/authFiles'; import { managementApi, responseList } from '../services/managementApi'; +import { + createOAuthLoginSuccessCache, + shouldShowOAuthLoginStatus, +} from '../services/oauthLoginState'; import { AuthFileManagementPage } from './AuthFileManagementPage'; import { QuotaPage } from './QuotaPage'; @@ -65,8 +69,15 @@ const OAUTH_CALLBACK_SUPPORTED = new Set([ 'xai', ]); const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback'; -const OAUTH_SUCCESS_RESET_MS = 5000; const OAUTH_POLL_INTERVAL_MS = 3000; +const oauthLoginSuccessCache = createOAuthLoginSuccessCache(); + +const cachedOAuthProviderStates = (): Partial> => ( + oauthLoginSuccessCache.snapshot().reduce>>( + (states, provider) => ({ ...states, [provider]: { status: 'success' } }), + {}, + ) +); export function OAuthManagementPage() { const { t } = useI18n(); @@ -115,14 +126,15 @@ export function OAuthManagementPage() { export function OAuthLoginPage() { const { t } = useI18n(); - const [states, setStates] = useState>>({}); + const [states, setStates] = useState>>( + cachedOAuthProviderStates, + ); const [notice, setNotice] = useState<{ message: string; tone: 'success' | 'error' | 'info'; } | null>(null); const pollingTimers = useRef>>({}); const pollingRequests = useRef>>({}); - const successResetTimers = useRef>>({}); const credentialSnapshots = useRef>>({}); const noticeTimerRef = useRef(null); @@ -156,32 +168,10 @@ export function OAuthLoginPage() { delete pollingRequests.current[provider]; }, []); - const clearSuccessResetTimer = useCallback((provider: OAuthProviderId) => { - const timer = successResetTimers.current[provider]; - if (timer !== undefined) window.clearTimeout(timer); - delete successResetTimers.current[provider]; - }, []); - - const clearProviderTimers = useCallback( - (provider: OAuthProviderId) => { - clearPollingTimer(provider); - clearSuccessResetTimer(provider); - }, - [clearPollingTimer, clearSuccessResetTimer], - ); - - const resetProviderAttempt = useCallback( - (provider: OAuthProviderId) => { - clearProviderTimers(provider); - delete credentialSnapshots.current[provider]; - setStates((current) => ({ ...current, [provider]: { status: 'idle' } })); - }, - [clearProviderTimers], - ); - const completeProviderAuth = useCallback( (provider: OAuthProviderId) => { - clearProviderTimers(provider); + clearPollingTimer(provider); + oauthLoginSuccessCache.mark(provider); updateProviderState(provider, { url: undefined, state: undefined, @@ -193,11 +183,8 @@ export function OAuthLoginPage() { callbackStatus: undefined, callbackError: undefined, }); - successResetTimers.current[provider] = window.setTimeout(() => { - resetProviderAttempt(provider); - }, OAUTH_SUCCESS_RESET_MS); }, - [clearProviderTimers, resetProviderAttempt, updateProviderState], + [clearPollingTimer, updateProviderState], ); const captureCredentialSnapshot = useCallback(async (provider: OAuthProviderId) => { @@ -281,15 +268,12 @@ export function OAuthLoginPage() { Object.values(pollingTimers.current).forEach((timer) => { if (timer !== undefined) window.clearInterval(timer); }); - Object.values(successResetTimers.current).forEach((timer) => { - if (timer !== undefined) window.clearTimeout(timer); - }); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }; }, []); const startLogin = async (provider: OAuthProviderId) => { - clearProviderTimers(provider); + clearPollingTimer(provider); updateProviderState(provider, { url: undefined, state: undefined, @@ -434,9 +418,9 @@ export function OAuthLoginPage() {

{provider.name}

- - {oauthStatusLabel(state, t)} - + {shouldShowOAuthLoginStatus(state.status) ? ( + {t('oauth.status.completed')} + ) : null}
@@ -510,13 +494,6 @@ export function OAuthLoginPage() { ); } -function oauthStatusLabel(state: OAuthProviderState, t: ReturnType['t']) { - if (state.status === 'success') return t('oauth.status.completed'); - if (state.status === 'error') return t('oauth.status.failed'); - if (state.status === 'waiting' || state.polling) return t('oauth.status.waiting'); - return t('oauth.status.signedOut'); -} - function providerLabel(provider: OAuthProviderId) { return oauthProviders.find((item) => item.id === provider)?.name ?? provider; } diff --git a/src/services/oauthLoginState.ts b/src/services/oauthLoginState.ts new file mode 100644 index 0000000..6c7136b --- /dev/null +++ b/src/services/oauthLoginState.ts @@ -0,0 +1,14 @@ +export const shouldShowOAuthLoginStatus = (status: string) => status === 'success'; + +export const createOAuthLoginSuccessCache = () => { + const providers = new Set(); + + return { + mark(provider: T) { + providers.add(provider); + }, + snapshot() { + return Array.from(providers); + }, + }; +}; diff --git a/tests/oauthLoginState.test.ts b/tests/oauthLoginState.test.ts new file mode 100644 index 0000000..a3c9793 --- /dev/null +++ b/tests/oauthLoginState.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test'; +import { + createOAuthLoginSuccessCache, + shouldShowOAuthLoginStatus, +} from '../src/services/oauthLoginState'; + +describe('OAuth login display state', () => { + test('only successful login states display a status badge', () => { + expect(shouldShowOAuthLoginStatus('idle')).toBeFalse(); + expect(shouldShowOAuthLoginStatus('waiting')).toBeFalse(); + expect(shouldShowOAuthLoginStatus('error')).toBeFalse(); + expect(shouldShowOAuthLoginStatus('success')).toBeTrue(); + }); + + test('successful providers remain cached across page remounts', () => { + const cache = createOAuthLoginSuccessCache<'codex' | 'claude'>(); + cache.mark('codex'); + + expect(cache.snapshot()).toEqual(['codex']); + expect(cache.snapshot()).toEqual(['codex']); + }); +}); From 3b3f1ea15f78879d8ad491e3a2a99228d6b741ea Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 11 Aug 2026 18:52:13 +0800 Subject: [PATCH 6/6] chore:change version to 0.2.19 and core version to 7.2.128 --- core-version.txt | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core-version.txt b/core-version.txt index 3ac88da..a4e4cf0 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.127 +7.2.128 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a5b322d..eb14c78 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.17" +version = "0.2.19" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6ff7b58..08f3378 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.17" +version = "0.2.19" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021"