feat(claude-code): align model mapping UI with Claude Code settings

- Add per-role model rows with inline 1M context controls
- Add bulk action to apply Sonnet settings to all roles
- Persist startup model and context settings in official settings.json fields
- Preserve custom role mappings and Subagent configuration
- Add multilingual labels and UI regression coverage
- Validate behavior with real Claude Code 2.1.292
This commit is contained in:
lzt404 committed 2026-10-08 18:36:58 +08:00
1 parent b564cb384c
commit b90445143c
23 files changed
+717 -191

No files matched your search

+13 -1
View File
@@ -1043,6 +1043,8 @@ pub(crate) fn resolve_claude_desktop_model_mappings(
disable_auto_compact: requested.disable_auto_compact,
manage_default_model: requested.manage_default_model,
manage_subagent_model: requested.manage_subagent_model,
startup_model: requested.startup_model.map(|value| value.trim().to_string()),
subagent_model: requested.subagent_model.map(|value| value.trim().to_string()),
}))
}
@@ -1062,13 +1064,21 @@ pub(crate) fn resolve_claude_code_model_mappings(
if !(1..=100).contains(&requested.auto_compact_pct) {
return Err("Claude Code compaction trigger percentage must be between 1 and 100".to_string());
}
let max_context_tokens = if requested.opus_1m || requested.sonnet_1m || requested.haiku_1m {
let explicit_1m = [&requested.startup_model, &requested.subagent_model]
.iter().any(|value| value.as_deref().is_some_and(|model|
model.trim().to_ascii_lowercase().ends_with("[1m]")));
let max_context_tokens = if requested.opus_1m || requested.sonnet_1m || requested.haiku_1m || explicit_1m {
CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW
} else {
requested.max_context_tokens
};
let resolve =
|model: &str| resolve_available_agent_model(models, &validate_agent_model(model)?);
let normalize_override = |value: Option<String>| -> Result<Option<String>, String> {
value.map(|value| {
if value.trim().is_empty() { Ok(String::new()) } else { validate_agent_model(&value) }
}).transpose()
};
Ok(Some(ClaudeDesktopModelMappings {
desktop_models: None,
opus: resolve(&requested.opus)?,
@@ -1082,6 +1092,8 @@ pub(crate) fn resolve_claude_code_model_mappings(
disable_auto_compact: requested.disable_auto_compact,
manage_default_model: requested.manage_default_model,
manage_subagent_model: requested.manage_subagent_model,
startup_model: normalize_override(requested.startup_model)?,
subagent_model: normalize_override(requested.subagent_model)?,
}))
}
+62 -24
View File
@@ -584,14 +584,17 @@ pub(crate) fn build_claude_agent_config(
let max_context_tokens = mappings.max_context_tokens;
let model_settings = claude_code_model_settings(&mappings);
let subagent_model = model_settings.haiku.clone();
let effort_level = claude_code_model_effort_level(models, &mappings.sonnet)?;
// Explicit/default session selections may switch roles, while Subagents may use
// another model. A global Sonnet-derived effort override is wrong for both.
let effort_level = if mappings.startup_model.is_none() && mappings.subagent_model.is_none() {
claude_code_model_effort_level(models, &mappings.sonnet)?
} else {
None
};
let env = ensure_json_object_entry(root, "env");
env.remove("ANTHROPIC_API_KEY");
env.remove("CLAUDE_CODE_EFFORT_LEVEL");
env.remove(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV);
env.remove(CLAUDE_CODE_AUTO_MODE_SERVER_ENV);
env.remove(CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV);
env.remove(DISABLE_AUTO_COMPACT_ENV);
for (key, value) in [
("ANTHROPIC_BASE_URL", base_url),
("ANTHROPIC_AUTH_TOKEN", api_key),
@@ -616,13 +619,19 @@ pub(crate) fn build_claude_agent_config(
}
env.insert(
"EASYCLIPROXY_MANAGE_CLAUDE_CODE_DEFAULT_MODEL".to_string(),
serde_json::Value::String(if mappings.manage_default_model { "1" } else { "0" }.to_string()),
serde_json::Value::String(if mappings.startup_model.as_ref().map_or(mappings.manage_default_model, |value| !value.trim().is_empty()) { "1" } else { "0" }.to_string()),
);
env.insert(
"EASYCLIPROXY_MANAGE_CLAUDE_CODE_SUBAGENT_MODEL".to_string(),
serde_json::Value::String(if mappings.manage_subagent_model { "1" } else { "0" }.to_string()),
serde_json::Value::String(if mappings.subagent_model.as_ref().map_or(mappings.manage_subagent_model, |value| !value.trim().is_empty()) { "1" } else { "0" }.to_string()),
);
if mappings.manage_default_model {
let explicit_startup_model = mappings.startup_model.as_ref().map(|model| model.trim().to_string());
if mappings.startup_model.is_some() {
// Claude Code's official persistent startup-model setting is the root
// `model` key. Keep ANTHROPIC_MODEL out of the generated file so the
// saved choice can still be changed from Claude Code's /model picker.
env.remove("ANTHROPIC_MODEL");
} else if mappings.manage_default_model {
env.insert(
"ANTHROPIC_MODEL".to_string(),
serde_json::Value::String(model_settings.sonnet.clone()),
@@ -634,7 +643,13 @@ pub(crate) fn build_claude_agent_config(
{
env.remove("ANTHROPIC_MODEL");
}
if mappings.manage_subagent_model {
if let Some(model) = &mappings.subagent_model {
if model.trim().is_empty() {
env.remove("CLAUDE_CODE_SUBAGENT_MODEL");
} else {
env.insert("CLAUDE_CODE_SUBAGENT_MODEL".into(), serde_json::Value::String(model.trim().into()));
}
} else if mappings.manage_subagent_model {
env.entry("CLAUDE_CODE_SUBAGENT_MODEL".to_string())
.or_insert_with(|| serde_json::Value::String(subagent_model));
} else if env
@@ -644,10 +659,6 @@ pub(crate) fn build_claude_agent_config(
{
env.remove("CLAUDE_CODE_SUBAGENT_MODEL");
}
env.insert(
CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV.to_string(),
serde_json::Value::String(max_context_tokens.to_string()),
);
env.insert(
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV.to_string(),
serde_json::Value::String("1".to_string()),
@@ -656,16 +667,6 @@ pub(crate) fn build_claude_agent_config(
CLAUDE_CODE_AUTO_MODE_SERVER_ENV.to_string(),
serde_json::Value::String("0".to_string()),
);
env.insert(
CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV.to_string(),
serde_json::Value::String(mappings.auto_compact_pct.to_string()),
);
if mappings.disable_auto_compact {
env.insert(
DISABLE_AUTO_COMPACT_ENV.to_string(),
serde_json::Value::String("1".to_string()),
);
}
for (key, value) in claude_code_model_presentation_environment(&mappings, models)? {
env.insert(key, serde_json::Value::String(value));
}
@@ -675,12 +676,49 @@ pub(crate) fn build_claude_agent_config(
serde_json::Value::String(effort_level),
);
}
if mappings.manage_default_model {
// Claude Code 2.1.288+ supports a per-model auto-compaction window in
// settings.json. Keep the existing environment override for compatibility
// with older Claude Code versions and custom gateway IDs, but also emit the
// official settings so newer versions can apply the value per model.
root.insert("autoCompactWindow".into(), serde_json::Value::Number(max_context_tokens.into()));
root.insert("autoCompactEnabled".into(), serde_json::Value::Bool(!mappings.disable_auto_compact));
let model_settings_json = ensure_json_object_entry(root, "modelSettings");
for (model_id, window) in [
(&mappings.opus, if mappings.opus_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }),
(&mappings.sonnet, if mappings.sonnet_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }),
(&mappings.haiku, if mappings.haiku_1m { CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW } else { mappings.max_context_tokens }),
] {
let model_id = strip_claude_code_context_suffix(model_id).trim();
if model_id.is_empty() { continue; }
let entry = ensure_json_object_entry(model_settings_json, model_id);
let current = entry.get("autoCompactWindow").and_then(serde_json::Value::as_u64).unwrap_or(0);
entry.insert("autoCompactWindow".into(), serde_json::Value::Number(current.max(window).into()));
}
for model in [&mappings.startup_model, &mappings.subagent_model] {
let Some(model) = model.as_deref().map(str::trim).filter(|value| !value.is_empty()) else { continue; };
let model_id = strip_claude_code_context_suffix(model).trim();
let window = if model.to_ascii_lowercase().ends_with("[1m]") {
CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW
} else {
mappings.max_context_tokens
};
let entry = ensure_json_object_entry(model_settings_json, model_id);
let current = entry.get("autoCompactWindow").and_then(serde_json::Value::as_u64).unwrap_or(0);
entry.insert("autoCompactWindow".into(), serde_json::Value::Number(current.max(window).into()));
}
if let Some(model) = explicit_startup_model {
if model.is_empty() {
root.remove("model");
} else {
root.insert("model".into(), serde_json::Value::String(model));
}
}
if mappings.startup_model.is_none() && mappings.manage_default_model {
root.insert(
"model".to_string(),
serde_json::Value::String(model_settings.sonnet),
);
} else if root
} else if mappings.startup_model.is_none() && root
.get("model")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value == model_settings.opus || value == model_settings.sonnet || value == model_settings.haiku)
+30 -9
View File
@@ -2925,7 +2925,8 @@ pub(crate) fn inspect_claude_code_model_mappings(
.get("ANTHROPIC_MODEL")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
.filter(|value| !value.is_empty())
.or_else(|| root.get("model").and_then(serde_json::Value::as_str).map(str::trim).filter(|value| !value.is_empty()));
let read_model = |key: &str| {
let value = env
.get(key)
@@ -2946,10 +2947,13 @@ pub(crate) fn inspect_claude_code_model_mappings(
return Ok(None);
};
let legacy_1m = opus_had_1m || sonnet_had_1m || haiku_had_1m;
let max_context_tokens = env
.get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV)
.and_then(serde_json::Value::as_str)
.and_then(|value| value.trim().parse::<u64>().ok())
let max_context_tokens = root
.get("autoCompactWindow")
.and_then(serde_json::Value::as_u64)
.or_else(|| env
.get(CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV)
.and_then(serde_json::Value::as_str)
.and_then(|value| value.trim().parse::<u64>().ok()))
.unwrap_or_else(|| {
if legacy_1m {
CLAUDE_DESKTOP_EXTENDED_CONTEXT_WINDOW
@@ -2962,10 +2966,14 @@ pub(crate) fn inspect_claude_code_model_mappings(
.and_then(serde_json::Value::as_str)
.and_then(|value| value.trim().parse::<u8>().ok())
.unwrap_or_else(default_claude_auto_compact_pct);
let disable_auto_compact = env
.get(DISABLE_AUTO_COMPACT_ENV)
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true"));
let disable_auto_compact = root
.get("autoCompactEnabled")
.and_then(serde_json::Value::as_bool)
.map(|enabled| !enabled)
.unwrap_or_else(|| env
.get(DISABLE_AUTO_COMPACT_ENV)
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")));
Ok(Some(ClaudeDesktopModelMappings {
desktop_models: None,
opus,
@@ -2987,6 +2995,17 @@ pub(crate) fn inspect_claude_code_model_mappings(
.and_then(serde_json::Value::as_str)
.map(|value| value != "0" && !value.eq_ignore_ascii_case("false"))
.unwrap_or(true),
startup_model: Some(env
.get("ANTHROPIC_MODEL")
.and_then(serde_json::Value::as_str)
.or_else(|| root.get("model").and_then(serde_json::Value::as_str))
.unwrap_or_default()
.to_string()),
subagent_model: Some(env
.get("CLAUDE_CODE_SUBAGENT_MODEL")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()),
}))
}
@@ -3115,6 +3134,8 @@ pub(crate) fn claude_code_model_settings(
disable_auto_compact: mappings.disable_auto_compact,
manage_default_model: mappings.manage_default_model,
manage_subagent_model: mappings.manage_subagent_model,
startup_model: mappings.startup_model.clone(),
subagent_model: mappings.subagent_model.clone(),
}
}
+9
View File
@@ -840,6 +840,15 @@ fn validate_unmanaged_preserved(
}
}
if client == "claude-code" {
if let Some(root) = value.as_object_mut() {
// These are CPA-managed Claude Code settings. Exclude them
// when comparing user-owned configuration so a model update
// does not get rejected as an unrelated custom edit.
root.remove("model");
root.remove("modelSettings");
root.remove("autoCompactWindow");
root.remove("autoCompactEnabled");
}
if let Some(env) = value.get_mut("env").and_then(Value::as_object_mut) {
env.retain(|key, _| {
!matches!(
+8
View File
@@ -1163,6 +1163,12 @@ struct ClaudeDesktopModelMappings {
/// Defaults to true for backwards compatibility with existing profiles.
#[serde(default = "default_true")]
manage_subagent_model: bool,
/// Optional explicit model written to ANTHROPIC_MODEL.
#[serde(default)]
startup_model: Option<String>,
/// Optional explicit model written to CLAUDE_CODE_SUBAGENT_MODEL.
#[serde(default)]
subagent_model: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
@@ -1227,6 +1233,8 @@ impl ClaudeDesktopModelMappings {
disable_auto_compact: false,
manage_default_model: true,
manage_subagent_model: true,
startup_model: None,
subagent_model: None,
}
}
}
+76 -7
View File
@@ -1,6 +1,73 @@
use super::support::*;
use super::*;
#[test]
fn claude_code_explicit_model_inputs_replace_clear_and_round_trip() {
let directory = agent_test_home("claude-code-explicit-models");
let path = directory.join("settings.json");
let models = test_agent_models(&["route-model"]);
let mut mappings = ClaudeDesktopModelMappings::all("route-model");
mappings.startup_model = Some(" custom-startup[1m] ".into());
mappings.subagent_model = Some("custom-worker".into());
let resolved = resolve_claude_code_model_mappings(
AgentClient::ClaudeCode, &models, "route-model", Some(mappings),
).unwrap().unwrap();
let rendered = build_claude_agent_config(
Some(r#"{"model":"user-choice","env":{"KEEP":"yes","ANTHROPIC_MODEL":"old-startup","CLAUDE_CODE_SUBAGENT_MODEL":"old-worker"}}"#),
"http://127.0.0.1:8317", "test-key", "route-model", &models, Some(&resolved),
).unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["model"], "custom-startup[1m]");
assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "custom-worker");
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "route-model");
assert_eq!(value["modelSettings"]["route-model"]["autoCompactWindow"], 1_000_000);
assert_eq!(value["model"], "custom-startup[1m]");
fs::write(&path, &rendered).unwrap();
let mut inspected = inspect_claude_code_model_mappings(&path).unwrap().unwrap();
assert_eq!(inspected.startup_model.as_deref(), Some("custom-startup[1m]"));
assert_eq!(inspected.subagent_model.as_deref(), Some("custom-worker"));
inspected.startup_model = Some(String::new());
inspected.subagent_model = Some(String::new());
let cleared = build_claude_agent_config(
Some(&rendered), "http://127.0.0.1:8317", "test-key", "route-model", &models, Some(&inspected),
).unwrap();
let value: serde_json::Value = serde_json::from_str(&cleared).unwrap();
assert!(value["env"].get("ANTHROPIC_MODEL").is_none());
assert!(value["env"].get("CLAUDE_CODE_SUBAGENT_MODEL").is_none());
assert!(value.get("model").is_none());
assert_eq!(value["env"]["KEEP"], "yes");
}
#[test]
fn claude_code_role_selection_survives_mapping_changes_without_global_effort_override() {
let models = test_agent_models(&["route-a", "route-b"]);
let mut mappings = ClaudeDesktopModelMappings::all("route-a");
mappings.startup_model = Some("sonnet".into());
mappings.subagent_model = Some("haiku".into());
for route in ["route-a", "route-b"] {
mappings.sonnet = route.into();
mappings.sonnet_1m = true;
let rendered = build_claude_agent_config(
Some(r#"{"model":"user-choice","env":{"CLAUDE_CODE_EFFORT_LEVEL":"max"}}"#),
"http://127.0.0.1:8317", "test-key", route, &models, Some(&mappings),
).unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["model"], "sonnet");
assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "haiku");
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], format!("{route}[1m]"));
assert_eq!(value["modelSettings"][route]["autoCompactWindow"], 1_000_000);
assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none());
assert_eq!(value["model"], "sonnet");
}
mappings.sonnet_1m = false;
mappings.startup_model = Some("route-a[1m]".into());
let resolved = resolve_claude_code_model_mappings(
AgentClient::ClaudeCode, &models, "route-a", Some(mappings),
).unwrap().unwrap();
assert_eq!(resolved.max_context_tokens, 1_000_000);
assert_eq!(resolved.startup_model.as_deref(), Some("route-a[1m]"));
}
#[test]
fn claude_agent_config_preserves_existing_fields() {
let rendered = build_claude_agent_config(
@@ -27,9 +94,8 @@ fn claude_agent_config_preserves_existing_fields() {
"1"
);
assert_eq!(value["env"][CLAUDE_CODE_AUTO_MODE_SERVER_ENV], "0");
assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "200000");
assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "90");
assert!(value["env"].get(DISABLE_AUTO_COMPACT_ENV).is_none());
assert_eq!(value["autoCompactWindow"], 200_000);
assert_eq!(value["autoCompactEnabled"], true);
assert!(value["env"].get("CLAUDE_CODE_EFFORT_LEVEL").is_none());
assert_eq!(value["env"]["CLAUDE_CODE_SUBAGENT_MODEL"], "claude-test");
assert_eq!(
@@ -156,6 +222,8 @@ fn claude_code_role_mappings_drive_settings() {
disable_auto_compact: false,
manage_default_model: true,
manage_subagent_model: true,
startup_model: None,
subagent_model: None,
};
let models = vec![
AgentModelOption {
@@ -205,7 +273,7 @@ fn claude_code_role_mappings_drive_settings() {
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"], "gpt-opus");
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"], "gpt-sonnet");
assert_eq!(value["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"], "gpt-haiku");
assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "272000");
assert_eq!(value["autoCompactWindow"], 272_000);
assert_eq!(
value["env"]["ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"],
"gpt-sonnet (272K context)"
@@ -274,6 +342,8 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() {
disable_auto_compact: true,
manage_default_model: true,
manage_subagent_model: true,
startup_model: None,
subagent_model: None,
};
let models = vec![
AgentModelOption {
@@ -304,9 +374,8 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() {
.unwrap();
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
assert_eq!(value["env"][CLAUDE_CODE_MAX_CONTEXT_TOKENS_ENV], "1000000");
assert_eq!(value["env"][CLAUDE_AUTOCOMPACT_PCT_OVERRIDE_ENV], "75");
assert_eq!(value["env"][DISABLE_AUTO_COMPACT_ENV], "1");
assert_eq!(value["autoCompactWindow"], 1_000_000);
assert_eq!(value["autoCompactEnabled"], false);
assert_eq!(value["env"]["ANTHROPIC_MODEL"], "custom-pro[1m]");
assert_eq!(
value["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"],
+12 -5
View File
@@ -28,6 +28,8 @@ export type AgentModelPickerProps = {
allowCustomValue?: boolean;
editable?: { label: string; placeholder: string; maxLength: number };
menuClassName?: string;
emptyOption?: string;
preserveValue?: boolean;
};
type AgentModelDropdownLayout = {
@@ -48,6 +50,8 @@ export function AgentModelPicker({
allowCustomValue = false,
editable,
menuClassName = '',
emptyOption,
preserveValue = false,
}: AgentModelPickerProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
@@ -66,13 +70,16 @@ export function AgentModelPicker({
);
const choices = useMemo(() => {
const options = visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' }));
if (emptyOption && !search.trim()) options.unshift({ name: '', alias: emptyOption });
if (allowCustomValue && search.trim() && !findAgentModel(models, search)) {
options.push({ name: search.trim(), alias: t('agents.model.useCustom') });
}
return options;
}, [visibleModels, allowCustomValue, search, models, t]);
}, [visibleModels, allowCustomValue, search, models, t, emptyOption]);
const selectedModel = findAgentModel(models, value);
const selectedName = selectedModel?.name ?? (allowCustomValue || editable ? value.trim() : '');
const selectedName = (selectedModel?.name
?? (allowCustomValue || editable || preserveValue ? value.trim() : ''))
|| (emptyOption && !value.trim() ? emptyOption : '');
const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : '';
const updateDropdownLayout = useCallback(() => {
@@ -158,7 +165,7 @@ export function AgentModelPicker({
useEffect(() => {
if (!open) return;
if (!editable) setSearch('');
const selectedIndex = (editable ? visibleModels : filterAgentModels(models, '')).findIndex(
const selectedIndex = choices.findIndex(
(model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(),
);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
@@ -322,8 +329,8 @@ export function AgentModelPicker({
onClick={() => choose(choice.name)}
>
<span>
<strong title={choice.name}>{choice.name}</strong>
<small>{choice.alias || t('agents.model.available')}</small>
<strong title={choice.name}>{choice.name || emptyOption}</strong>
{choice.name ? <small>{choice.alias || t('agents.model.available')}</small> : null}
</span>
{selected ? <Check size={16} aria-hidden /> : null}
</button>
+11 -11
View File
@@ -98,14 +98,13 @@
.credential-quota-dense .credential-quota-label span {
min-width: 0;
padding: 2px 5px;
overflow: hidden;
padding: 2px 7px;
border: 0;
border-radius: 0;
background: transparent;
font-size: 10px;
font-weight: 550;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -137,21 +136,22 @@
.credential-quota-dense .credential-quota-empty {
margin: 0;
color: var(--text-tertiary);
font-size: 11px;
font-size: 12px;
}
.credential-quota-dense .credential-quota-label strong {
font-size: 12px;
font-size: 13px;
line-height: 1;
font-weight: 750;
}
.credential-quota-dense .credential-quota-track {
height: 5px;
height: 7px;
}
.credential-quota-dense .credential-quota-row small {
color: var(--text-tertiary);
font-size: 10px;
font-size: 12px;
line-height: 1.4;
}
@@ -167,11 +167,11 @@
}
.credential-quota-dense .credential-quota-heading .credential-quota-reset {
min-height: 21px;
padding: 2px 6px;
min-height: 26px;
padding: 3px 8px;
margin: 0;
border-radius: 4px;
font-size: 10px;
border-radius: 6px;
font-size: 12px;
line-height: 1.3;
}
@@ -0,0 +1,39 @@
import { useI18n } from '../../i18n';
import { claudeCodeRoles, claudeCodeRole, claudeCodeModelBase, type ClaudeCodeRoutes } from '../../services/claudeCodeModels';
type Props = {
routes: ClaudeCodeRoutes;
startupModel: string;
subagentModel: string;
disabled: boolean;
onRoleChange: (field: 'opus1m' | 'sonnet1m' | 'haiku1m', enabled: boolean) => void;
onModelChange: (field: 'startupModel' | 'subagentModel', value: string) => void;
};
export function ClaudeCodeContextOptions({ routes, startupModel, subagentModel, disabled, onRoleChange, onModelChange }: Props) {
const { t } = useI18n();
return (
<fieldset className="claude-code-context-options">
<legend>{t('agents.claudeCodeRuntime.extendedContext')}</legend>
<p>{t('agents.claudeCodeRuntime.selectionHint')}</p>
<div>
{claudeCodeRoles.map(role => (
<label key={role}>
<input type="checkbox" checked={routes[`${role}1m`]} disabled={disabled}
onChange={event => onRoleChange(`${role}1m`, event.currentTarget.checked)} />
<span>{role[0].toUpperCase() + role.slice(1)}</span>
</label>
))}
{(['startupModel', 'subagentModel'] as const).map(field => {
const value = field === 'startupModel' ? startupModel : subagentModel;
if (!value.trim() || claudeCodeRole(value)) return null;
return <label key={field}>
<input type="checkbox" checked={/\[1m\]$/i.test(value.trim())} disabled={disabled}
onChange={event => onModelChange(field, claudeCodeModelBase(value) + (event.currentTarget.checked ? '[1m]' : ''))} />
<span>{t(`agents.claudeCodeRuntime.${field}`)}</span>
</label>;
})}
</div>
</fieldset>
);
}
@@ -0,0 +1,53 @@
.claude-code-model-inputs {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.claude-code-model-field {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
overflow-wrap: anywhere;
}
@container agent-panel (max-width: 560px) {
.claude-code-model-inputs {
grid-template-columns: 1fr;
}
}
.claude-code-model-field small { color: var(--theme-817b72); line-height: 1.5; }
.claude-code-model-field .agent-model-trigger { width: 100%; border: 1px solid var(--theme-d3d0c9); border-radius: 7px; background: var(--theme-fff); }
.claude-code-model-field .agent-model-trigger input { min-width: 0; width: 100%; }
.claude-code-model-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--theme-d3d0c9); border-radius: 8px; }
.claude-code-model-error p { margin: 4px 0 0; }
.claude-code-direct-context { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-caption); }
.claude-code-selection-hint { grid-column: 1 / -1; color: var(--theme-817b72); line-height: 1.5; }
.claude-code-model-inputs .claude-code-model-field .agent-model-trigger {
border: 1px solid var(--theme-d3d0c9, #d3d0c9);
padding: 6px 10px;
border-radius: 7px;
}
.claude-code-context-settings { border-top: 1px solid var(--theme-d3d0c9, #d3d0c9); padding-top: 14px; }
.claude-code-context-settings summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
.claude-code-context-settings > p, .claude-code-context-options p { font-size: var(--font-size-caption); line-height: 1.5; color: var(--theme-817b72); }
.claude-code-context-options { margin: 14px 0 0; padding: 12px; border: 1px solid var(--theme-d3d0c9, #d3d0c9); border-radius: 8px; }
.claude-code-context-options > div { display: flex; flex-wrap: wrap; gap: 14px 24px; }
.claude-code-context-options label { display: flex; gap: 8px; align-items: center; }
.claude-code-group-heading { margin-top: 4px; font-size: var(--font-size-label); }
.claude-role-table { display: grid; gap: 10px; min-width: 0; }
.claude-role-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.claude-role-table > p { margin: 0; font-size: var(--font-size-caption); color: var(--theme-817b72); line-height: 1.5; }
.claude-role-row { display: grid; grid-template-columns: 80px minmax(0, 1fr) 65px; align-items: center; gap: 12px; padding: 10px; border: 1px solid var(--theme-d3d0c9); border-radius: 8px; }
.claude-role-row .agent-model-trigger { width: 100%; }
.claude-model-with-context { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.claude-inline-context { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
@container agent-panel (max-width: 420px) {
.claude-role-row { grid-template-columns: minmax(0, 1fr) auto; }
.claude-role-row > strong { grid-column: 1 / -1; }
}
@@ -0,0 +1,66 @@
import { claudeCodeModelBase, claudeCodeRoles, claudeCodeModelPreview, isKnownClaudeCodeModel, type ClaudeCodeRoutes } from '../../services/claudeCodeModels';
import { useI18n } from '../../i18n';
import { AgentModelPicker } from '../AgentModelPicker';
import type { ModelOption } from '../../services/modelService';
import './ClaudeCodeModelInputs.css';
type Props = {
startupModel: string;
subagentModel: string;
models: ModelOption[];
routes: ClaudeCodeRoutes;
loading: boolean;
disabled: boolean;
onChange: (field: 'startupModel' | 'subagentModel', value: string) => void;
};
export function ClaudeCodeModelInputs({ startupModel, subagentModel, models, routes, loading, disabled, onChange }: Props) {
const { t } = useI18n();
const options = [
...claudeCodeRoles.map(role => ({ name: role, alias: t('agents.claudeCodeRuntime.followRole', {
role, model: claudeCodeModelPreview(role, routes).model || '—',
}) })),
...models.filter(model => !claudeCodeRoles.some(role => role === model.name.toLowerCase()))
.map(model => ({ ...model, alias: t('agents.claudeCodeRuntime.directOption') })),
];
return (
<div className="claude-code-model-inputs">
{(['startupModel', 'subagentModel'] as const).map((field) => {
const value = field === 'startupModel' ? startupModel : subagentModel;
const preview = claudeCodeModelPreview(value, routes);
return (
<div className="claude-code-model-field" key={field}>
<strong>{t(`agents.claudeCodeRuntime.${field}`)}</strong>
<div className="claude-model-with-context">
<AgentModelPicker
models={options}
value={value}
onChange={(value) => onChange(field, value)}
disabled={disabled}
loading={loading}
allowCustomValue
emptyOption={t(`agents.claudeCodeRuntime.${field}Placeholder`)}
editable={{ label: t(`agents.claudeCodeRuntime.${field}`),
placeholder: t(`agents.claudeCodeRuntime.${field}Placeholder`), maxLength: 240 }}
/>
<label className="claude-inline-context">
<input type="checkbox" aria-label={`${t(`agents.claudeCodeRuntime.${field}`)} 1M`}
checked={preview.context1m} disabled={disabled || !value.trim() || Boolean(preview.role)}
onChange={event => onChange(field, claudeCodeModelBase(value) + (event.currentTarget.checked ? '[1m]' : ''))} />
1M
</label>
</div>
<small className="claude-code-model-summary" aria-live="polite">
{value.trim() ? preview.role
? t('agents.claudeCodeRuntime.followRole', { role: preview.role, model: preview.model || '—' })
: t('agents.claudeCodeRuntime.directModel', { model: preview.model })
: t(`agents.claudeCodeRuntime.${field}Hint`)}
</small>
{!loading && !isKnownClaudeCodeModel(value, models) ? (
<small role="status">{t('agents.claudeCodeRuntime.unverifiedModel')}</small>
) : null}
</div>
); })}
</div>
);
}
@@ -0,0 +1,40 @@
import { useI18n } from '../../i18n';
import { AgentModelPicker } from '../AgentModelPicker';
import type { ModelOption } from '../../services/modelService';
import { claudeCodeRoles, type ClaudeCodeRoutes } from '../../services/claudeCodeModels';
import './ClaudeCodeModelInputs.css';
type Role = typeof claudeCodeRoles[number];
type Props = {
routes: ClaudeCodeRoutes;
models: ModelOption[];
loading: boolean;
error: string;
disabled: boolean;
onModelChange: (role: Role, value: string) => void;
onContextChange: (field: `${Role}1m`, value: boolean) => void;
onRefresh: () => void;
onApplyAll: () => void;
};
export function ClaudeCodeRoleRows({ routes, models, loading, error, disabled, onModelChange, onContextChange, onRefresh, onApplyAll }: Props) {
const { t } = useI18n();
return <section className="claude-role-table" aria-label={t('agents.claudeCodeRuntime.roleMappings')}>
<div className="claude-role-toolbar">
<strong>{t('agents.claudeCodeRuntime.roleMappings')}</strong>
<button type="button" className="secondary-button" disabled={disabled || !routes.sonnet}
onClick={onApplyAll}>{t('agents.claudeCodeRuntime.applySonnetToRoles')}</button>
</div>
<p>{t('agents.claudeCodeRuntime.roleRowsHint')}</p>
{(['sonnet', 'opus', 'haiku'] as const).map(role => <div className="claude-role-row" key={role} data-role={role}>
<strong>{role[0].toUpperCase() + role.slice(1)}</strong>
<AgentModelPicker preserveValue models={models} value={routes[role]} loading={loading} error={error}
disabled={disabled} onChange={value => onModelChange(role, value)} onRefresh={onRefresh} />
<label className="claude-inline-context">
<input type="checkbox" checked={routes[`${role}1m`]} disabled={disabled}
aria-label={`${role} 1M`} onChange={event => onContextChange(`${role}1m`, event.currentTarget.checked)} />
1M
</label>
</div>)}
</section>;
}
+24 -1
View File
@@ -1455,7 +1455,7 @@ export const jaOverrides = {
'agents.claudeDesktopMapping.error.duplicate': 'エイリアスまたは元モデル ID が重複しています。各行に異なる ID を指定してください。',
'agents.claudeCodeMapping.title': 'Claude Code モデル → 上流モデル',
'agents.claudeCodeMapping.description': 'タスク役割ごとに上流モデルを選び、共通のコンテキストと自動圧縮ポリシーを設定します。',
'agents.claudeCodeRuntime.maxContextTokens': '最大コンテキスト',
'agents.claudeCodeRuntime.maxContextTokens': '全体のコンテキスト上書き',
'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code の既定値は 200000 です。上流モデルの上限を超えないでください。',
'agents.claudeCodeRuntime.context1mHint': 'このマッピングで [1m] モデルを使用し、最大コンテキストを 1000000 にします。3 つすべてを無効にすると 200000 に戻ります。',
'agents.claudeCodeRuntime.autoCompactPct': '自動圧縮の開始率',
@@ -1466,6 +1466,29 @@ export const jaOverrides = {
'agents.claudeCodeRuntime.manageDefaultModelHint': '無効にすると ANTHROPIC_MODEL と model を上書きせず、/model で既定モデルを保存できます。',
'agents.claudeCodeRuntime.manageSubagentModel': 'Subagent モデルを CPA が固定',
'agents.claudeCodeRuntime.manageSubagentModelHint': '無効にすると CLAUDE_CODE_SUBAGENT_MODEL を設定せず、未指定の Subagent はメインモデルを継承します。',
'agents.claudeCodeRuntime.connectionHint': 'CPA のモデル一覧を更新できません。カーネルと接続を確認して再試行してください。保存済みモデルと編集内容は保持されます。',
'agents.claudeCodeRuntime.followRole': '{role} に追従 → {model}',
'agents.claudeCodeRuntime.directOption': 'CPA モデルを直接指定',
'agents.claudeCodeRuntime.directModel': '{model} に固定し、ロールの変更には追従しません。',
'agents.claudeCodeRuntime.direct1m': 'このモデルで 1M コンテキストを使用',
'agents.claudeCodeRuntime.unverifiedModel': '現在の CPA 一覧にないモデルです。ID またはエイリアスが利用可能か確認してください。',
'agents.claudeCodeRuntime.selectionHint': 'ロール選択はロールの 1M 設定に従います。直接指定では個別に 1M を設定します。上流モデルの対応が必要です。',
'agents.claudeCodeRuntime.advancedContext': 'コンテキストと自動圧縮(詳細)',
'agents.claudeCodeRuntime.applySonnetToRoles': 'Sonnet を全ロールに適用',
'agents.claudeCodeRuntime.roleRowsHint': '各行でリクエストモデルと 1M を設定します。起動・サブエージェントでロールを選ぶと、その行の設定に従います。',
'agents.claudeCodeRuntime.roleMappings': 'ロールのマッピング',
'agents.claudeCodeRuntime.sharedRoleModel': '共通ロールモデル',
'agents.claudeCodeRuntime.sharedRoleModelHint': '通常は Opus、Sonnet、Haiku に同じ CPA モデルを使用します。個別ルートが必要な場合だけ展開してください。',
'agents.claudeCodeRuntime.showRoleMappings': 'ロールを個別設定',
'agents.claudeCodeRuntime.hideRoleMappings': '個別設定を閉じる',
'agents.claudeCodeRuntime.extendedContext': '拡張コンテキスト指定(1M)',
'agents.claudeCodeRuntime.sharedContextHint': 'ウィンドウ値は全体の上書きで、モデル別の制限ではありません。1M は拡張コンテキストの指定であり、上流モデルの容量を増やしません。',
'agents.claudeCodeRuntime.startupModel': '起動モデル',
'agents.claudeCodeRuntime.startupModelPlaceholder': 'Claude Code の既定モデルを使用',
'agents.claudeCodeRuntime.startupModelHint': 'CPA のモデルを選択するか ID を入力します。空欄では Claude Code の既定の選択を維持します。',
'agents.claudeCodeRuntime.subagentModel': 'Subagent モデル',
'agents.claudeCodeRuntime.subagentModelPlaceholder': '既定の継承動作を使用',
'agents.claudeCodeRuntime.subagentModelHint': 'CPA のモデルを選択するか ID を入力します。空欄ではサブエージェントの既定動作を維持します。',
'agents.claudeDesktopMapping.customMapping': 'モデルエイリアスを使用',
'agents.claudeDesktopMapping.customMappingHint': '有効にするとエイリアスモデルのみ、無効にするとエイリアス未設定の元モデルのみ表示します。',
'agents.claudeDesktopMapping.opus': 'Opus',
+24 -1
View File
@@ -1421,7 +1421,7 @@ export const en: Record<MessageKey, string> = {
'agents.claudeDesktopMapping.error.duplicate': 'Duplicate alias or original model ID. Use a different ID for each row.',
'agents.claudeCodeMapping.title': 'Claude Code Model → Upstream Model',
'agents.claudeCodeMapping.description': 'Choose the upstream model for each task role and configure one shared context and auto-compact policy.',
'agents.claudeCodeRuntime.maxContextTokens': 'Maximum context',
'agents.claudeCodeRuntime.maxContextTokens': 'Global context override',
'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',
@@ -1432,6 +1432,29 @@ export const en: Record<MessageKey, string> = {
'agents.claudeCodeRuntime.manageDefaultModelHint': 'When off, CPA leaves ANTHROPIC_MODEL and the root model untouched so /model can save the default.',
'agents.claudeCodeRuntime.manageSubagentModel': 'Let CPA pin the Subagent model',
'agents.claudeCodeRuntime.manageSubagentModelHint': 'When off, CPA leaves CLAUDE_CODE_SUBAGENT_MODEL unset so unspecified Subagents inherit the main model.',
'agents.claudeCodeRuntime.connectionHint': 'Could not refresh CPA models. Check the kernel and connection, then retry. Saved models and edits are preserved.',
'agents.claudeCodeRuntime.followRole': 'Follow {role} → {model}',
'agents.claudeCodeRuntime.directOption': 'Direct CPA model',
'agents.claudeCodeRuntime.directModel': 'Pinned to {model}; does not follow role mapping changes.',
'agents.claudeCodeRuntime.direct1m': 'Use 1M context for this model',
'agents.claudeCodeRuntime.unverifiedModel': 'This model is not in the current CPA list. Check that the ID or alias is available.',
'agents.claudeCodeRuntime.selectionHint': 'Role selections follow their role’s 1M setting. Direct models have a separate 1M setting. The upstream must support the context size.',
'agents.claudeCodeRuntime.advancedContext': 'Context and auto-compaction (advanced)',
'agents.claudeCodeRuntime.applySonnetToRoles': 'Apply Sonnet to all roles',
'agents.claudeCodeRuntime.roleRowsHint': 'Set each role’s request model and 1M flag together. Session and subagent role selections follow the corresponding row.',
'agents.claudeCodeRuntime.roleMappings': 'Role mappings',
'agents.claudeCodeRuntime.sharedRoleModel': 'Shared role model',
'agents.claudeCodeRuntime.sharedRoleModelHint': 'Use one CPA model for Opus, Sonnet, and Haiku by default. Expand this only when you need separate routes.',
'agents.claudeCodeRuntime.showRoleMappings': 'Configure roles separately',
'agents.claudeCodeRuntime.hideRoleMappings': 'Hide separate role settings',
'agents.claudeCodeRuntime.extendedContext': 'Extended context flag (1M)',
'agents.claudeCodeRuntime.sharedContextHint': 'The window value is a global override, not a per-model limit. The 1M options send an extended-context flag; they do not increase upstream capacity.',
'agents.claudeCodeRuntime.startupModel': 'Startup model',
'agents.claudeCodeRuntime.startupModelPlaceholder': 'Use Claude Code default',
'agents.claudeCodeRuntime.startupModelHint': 'Select a CPA model or enter a model ID. Leave empty to keep the Claude Code default.',
'agents.claudeCodeRuntime.subagentModel': 'Subagent model',
'agents.claudeCodeRuntime.subagentModelPlaceholder': 'Use default inheritance',
'agents.claudeCodeRuntime.subagentModelHint': 'Select a CPA model or enter a model ID. Leave empty to keep the default subagent behavior.',
'agents.claudeDesktopMapping.customMapping': 'Use Model Aliases',
'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.',
'agents.claudeDesktopMapping.opus': 'Opus',
+24 -1
View File
@@ -1420,7 +1420,7 @@ export const zhCN = {
'agents.claudeDesktopMapping.error.duplicate': '别名或原模型 ID 重复,请为每一行使用不同的 ID。',
'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型',
'agents.claudeCodeMapping.description': '分别为不同任务角色选择上游模型,并统一配置上下文窗口与自动压缩。',
'agents.claudeCodeRuntime.maxContextTokens': '最大窗口',
'agents.claudeCodeRuntime.maxContextTokens': '全局窗口覆盖',
'agents.claudeCodeRuntime.maxContextTokensHint': 'Claude Code 默认 200000;请勿超过上游模型实际支持的窗口。',
'agents.claudeCodeRuntime.context1mHint': '为此映射使用 [1m] 模型并将最大窗口设为 1000000;三个映射均关闭后恢复为 200000。',
'agents.claudeCodeRuntime.autoCompactPct': '触发压缩百分比',
@@ -1431,6 +1431,29 @@ export const zhCN = {
'agents.claudeCodeRuntime.manageDefaultModelHint': '关闭后不再写入 ANTHROPIC_MODEL 或根级 model,Claude Code 可通过 /model 保存默认模型。',
'agents.claudeCodeRuntime.manageSubagentModel': '由 CPA 固定 Subagent 模型',
'agents.claudeCodeRuntime.manageSubagentModelHint': '关闭后不再写入 CLAUDE_CODE_SUBAGENT_MODEL,未指定模型的 Subagent 继承主会话模型。',
'agents.claudeCodeRuntime.connectionHint': '无法刷新 CPA 模型列表。请检查内核运行状态与连接后重试;已保存的模型和编辑内容会保留。',
'agents.claudeCodeRuntime.followRole': '跟随 {role} → {model}',
'agents.claudeCodeRuntime.directOption': '直接指定 CPA 模型',
'agents.claudeCodeRuntime.directModel': '固定使用 {model},不随角色映射变化。',
'agents.claudeCodeRuntime.direct1m': '此模型使用 1M 上下文',
'agents.claudeCodeRuntime.unverifiedModel': '当前 CPA 列表中未找到此模型,请确认 ID 或别名可用。',
'agents.claudeCodeRuntime.selectionHint': '跟随角色时使用该角色的 1M 设置;直接指定模型时单独设置 1M。上游须支持对应窗口。',
'agents.claudeCodeRuntime.advancedContext': '上下文与自动压缩(高级)',
'agents.claudeCodeRuntime.applySonnetToRoles': '将 Sonnet 应用到全部角色',
'agents.claudeCodeRuntime.roleRowsHint': '每行设置一个角色的请求模型和 1M 标记;启动模型或子代理选择角色时,跟随对应行的设置。',
'agents.claudeCodeRuntime.roleMappings': '角色映射',
'agents.claudeCodeRuntime.sharedRoleModel': '统一角色模型',
'agents.claudeCodeRuntime.sharedRoleModelHint': '默认让 Opus、Sonnet、Haiku 共用一个 CPA 模型;需要分流时再展开分别设置。',
'agents.claudeCodeRuntime.showRoleMappings': '分别设置角色',
'agents.claudeCodeRuntime.hideRoleMappings': '收起分别设置',
'agents.claudeCodeRuntime.extendedContext': '扩展上下文标记(1M)',
'agents.claudeCodeRuntime.sharedContextHint': '这里的窗口数值是全局覆盖,不是每个模型的独立窗口。1M 选项发送扩展上下文标记;它不会增加上游模型的实际容量。',
'agents.claudeCodeRuntime.startupModel': '启动模型',
'agents.claudeCodeRuntime.startupModelPlaceholder': '使用 Claude Code 默认模型',
'agents.claudeCodeRuntime.startupModelHint': '选择 CPA 模型或输入模型 ID;留空保留 Claude Code 的默认选择。',
'agents.claudeCodeRuntime.subagentModel': 'Subagent 模型',
'agents.claudeCodeRuntime.subagentModelPlaceholder': '使用默认继承行为',
'agents.claudeCodeRuntime.subagentModelHint': '选择 CPA 模型或输入模型 ID;留空不覆盖子代理的默认模型。',
'agents.claudeDesktopMapping.customMapping': '使用模型别名',
'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。',
'agents.claudeDesktopMapping.opus': 'Opus',
+59 -91
View File
@@ -1,3 +1,5 @@
import { ClaudeCodeRoleRows } from '../components/claude-code/ClaudeCodeRoleRows';
import { ClaudeCodeModelInputs } from '../components/claude-code/ClaudeCodeModelInputs';
import { MessageNotice } from '../appNotice';
import {
useCallback,
@@ -159,6 +161,8 @@ type ClaudeModelMappings = {
disableAutoCompact: boolean;
manageDefaultModel: boolean;
manageSubagentModel: boolean;
startupModel: string;
subagentModel: string;
};
type AgentFormValues = {
@@ -191,6 +195,8 @@ const createClaudeModelMappings = (model: string): ClaudeModelMappings => ({
disableAutoCompact: false,
manageDefaultModel: true,
manageSubagentModel: true,
startupModel: model,
subagentModel: model,
});
const createClaudeModelMappingsByClient = (): Record<
@@ -862,12 +868,12 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
: activeStatus?.pluginVersion ?? undefined;
useEffect(() => {
if (!isClaudeModelMappingClient || (selected !== 'claude-desktop' && !selectedModel)) return;
if (!isClaudeModelMappingClient) return;
const appliedMappings = selected === 'claude-code'
? activeStatus?.claudeCodeModelMappings
: activeStatus?.claudeDesktopModelMappings;
const dirty = claudeModelMappingsDirtyRef.current[selected];
if (!dirty && selected === 'claude-code') {
if (!dirty && selected === 'claude-code' && models.length > 0) {
const appliedModels = appliedMappings
? claudeMappingRoles
.map((role) => findAgentModel(models, appliedMappings[role.key]))
@@ -891,9 +897,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const next: ClaudeModelMappings = {
...(selected === 'claude-desktop' ? { desktopModels: dirty && source.desktopModels
? source.desktopModels : appliedMappings ? desktopModelEntries(source) : createDefaultDesktopModels() } : {}),
opus: findAgentModel(models, source.opus)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel),
sonnet: findAgentModel(models, source.sonnet)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel),
haiku: findAgentModel(models, source.haiku)?.name ?? (selected === 'claude-desktop' ? '' : selectedModel),
opus: findAgentModel(models, source.opus)?.name ?? (selected === 'claude-code' ? source.opus || selectedModel : ''),
sonnet: findAgentModel(models, source.sonnet)?.name ?? (selected === 'claude-code' ? source.sonnet || selectedModel : ''),
haiku: findAgentModel(models, source.haiku)?.name ?? (selected === 'claude-code' ? source.haiku || selectedModel : ''),
opus1m: Boolean(source.opus1m),
sonnet1m: Boolean(source.sonnet1m),
haiku1m: Boolean(source.haiku1m),
@@ -902,6 +908,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
disableAutoCompact: Boolean(source.disableAutoCompact),
manageDefaultModel: source.manageDefaultModel !== false,
manageSubagentModel: source.manageSubagentModel !== false,
startupModel: source.startupModel ?? (source.manageDefaultModel === false ? '' : source.sonnet || ''),
subagentModel: source.subagentModel ?? (source.manageSubagentModel === false ? '' : source.haiku || ''),
};
return sameAgentModelMappings(currentClientDraft, next)
? current
@@ -1088,7 +1096,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
editClaudeModelMappings((current) => {
const next = { ...current, [preference]: enabled };
if (selected === 'claude-code') {
const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m;
const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m
|| [next.startupModel, next.subagentModel].some(value => /\[1m\]$/i.test(value.trim()));
next.maxContextTokens = any1mEnabled
? 1_000_000
: DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -1170,8 +1179,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
resolved.maxContextTokens = claudeModelMappingsDraft.maxContextTokens;
resolved.autoCompactPct = claudeModelMappingsDraft.autoCompactPct;
resolved.disableAutoCompact = claudeModelMappingsDraft.disableAutoCompact;
resolved.manageDefaultModel = claudeModelMappingsDraft.manageDefaultModel;
resolved.manageSubagentModel = claudeModelMappingsDraft.manageSubagentModel;
resolved.startupModel = claudeModelMappingsDraft.startupModel.trim();
resolved.subagentModel = claudeModelMappingsDraft.subagentModel.trim();
resolved.manageDefaultModel = resolved.startupModel.length > 0;
resolved.manageSubagentModel = resolved.subagentModel.length > 0;
return resolved;
};
@@ -1673,7 +1684,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
status={modelLoading || !activeStatus ? t('agents.modify.checking')
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState') : ''}
description={modificationDescription} />;
const configurationErrorMessage = configurationError || (!nativeOauth ? modelSelectionError || modelError : '');
const configurationErrorMessage = configurationError || (!nativeOauth ? modelSelectionError || (selected === 'claude-code' ? '' : modelError) : '');
const editDesktopEntries = (entries: ClaudeDesktopModelMapping[]) => {
setModelSelectionError('');
@@ -2047,7 +2058,44 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</label>
</div>
</div>
{selected === 'claude-code' ? (
<ClaudeCodeModelInputs
routes={claudeModelMappingsDraft}
models={models}
loading={modelLoading}
startupModel={claudeModelMappingsDraft.startupModel}
subagentModel={claudeModelMappingsDraft.subagentModel}
disabled={busy || loading}
onChange={(field, value) => editClaudeModelMappings((current) => ({
...current,
[field]: value,
maxContextTokens: /\[1m\]$/i.test(value.trim()) ? 1_000_000
: /\[1m\]$/i.test(current[field].trim()) && current.maxContextTokens === 1_000_000
&& !current.opus1m && !current.sonnet1m && !current.haiku1m
&& !/\[1m\]$/i.test(current[field === 'startupModel' ? 'subagentModel' : 'startupModel'].trim())
? DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS : current.maxContextTokens,
[field === 'startupModel' ? 'manageDefaultModel' : 'manageSubagentModel']: value.trim().length > 0,
}))}
/>
{modelError ? (
<div className="claude-code-model-error" role="status">
<div><strong>{t('agents.model.loadFailed')}</strong><p>{t('agents.claudeCodeRuntime.connectionHint')}</p></div>
<button type="button" className="secondary-button" onClick={refreshModels} disabled={modelLoading}>
{t('agents.model.refresh')}
</button>
</div>
) : null}
<ClaudeCodeRoleRows routes={claudeModelMappingsDraft} models={claudeMappingModels}
loading={modelLoading} error={modelError}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
onModelChange={selectClaudeModelMapping} onContextChange={changeClaude1mPreference}
onRefresh={refreshModels}
onApplyAll={() => editClaudeModelMappings(current => ({ ...current,
opus: current.sonnet, haiku: current.sonnet,
opus1m: current.sonnet1m, haiku1m: current.sonnet1m,
}))} />
<details className="claude-code-context-settings">
<summary>{t('agents.claudeCodeRuntime.advancedContext')}</summary>
<p>{t('agents.claudeCodeRuntime.sharedContextHint')}</p>
<div className="agent-claude-code-runtime-settings">
<div className="agent-claude-code-number-field">
<span>{t('agents.claudeCodeRuntime.maxContextTokens')}</span>
@@ -2111,88 +2159,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<span className="switch-track" />
</span>
</label>
<label
className="agent-claude-code-disable-compact"
title={t('agents.claudeCodeRuntime.manageDefaultModelHint')}
>
<span>
<strong>{t('agents.claudeCodeRuntime.manageDefaultModel')}</strong>
<small>{t('agents.claudeCodeRuntime.manageDefaultModelHint')}</small>
</span>
<span className="switch-control">
<input
type="checkbox"
checked={claudeModelMappingsDraft.manageDefaultModel}
onChange={(event) => editClaudeModelMappings((current) => ({
...current,
manageDefaultModel: event.currentTarget.checked,
}))}
disabled={busy || loading || modelLoading}
/>
<span className="switch-track" />
</span>
</label>
<label
className="agent-claude-code-disable-compact"
title={t('agents.claudeCodeRuntime.manageSubagentModelHint')}
>
<span>
<strong>{t('agents.claudeCodeRuntime.manageSubagentModel')}</strong>
<small>{t('agents.claudeCodeRuntime.manageSubagentModelHint')}</small>
</span>
<span className="switch-control">
<input
type="checkbox"
checked={claudeModelMappingsDraft.manageSubagentModel}
onChange={(event) => editClaudeModelMappings((current) => ({
...current,
manageSubagentModel: event.currentTarget.checked,
}))}
disabled={busy || loading || modelLoading}
/>
<span className="switch-track" />
</span>
</label>
</div>
) : null}
<div className="agent-claude-desktop-mapping-grid">
{claudeMappingRoles.map((role) => (
<div className="agent-claude-desktop-mapping-row" key={role.key}>
<div className="agent-claude-mapping-card-heading">
<strong>{t(role.labelKey)}</strong>
<label
className="agent-claude-context-toggle"
title={t(selected === 'claude-code'
? 'agents.claudeCodeRuntime.context1mHint'
: 'agents.claudeMapping.context1mHint')}
>
<span>{t('agents.claudeMapping.context1m')}</span>
<span className="switch-control">
<input
type="checkbox"
checked={claudeModelMappingsDraft[role.contextKey]}
onChange={(event) => changeClaude1mPreference(
role.contextKey,
event.currentTarget.checked,
)}
disabled={busy || loading || modelLoading}
/>
<span className="switch-track" />
</span>
</label>
</div>
<AgentModelPicker
models={claudeMappingModels}
value={claudeModelMappingsDraft[role.key]}
loading={modelLoading}
error={modelError}
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
onChange={(value) => selectClaudeModelMapping(role.key, value)}
onRefresh={refreshModels}
/>
</div>
))}
</div>
</details>
</section>
) : null}
+34 -35
View File
@@ -22,7 +22,7 @@
.auth-files-heading-actions > .auth-toolbar-quiet { border-color: transparent; background: transparent; color: var(--auth-muted); }
.auth-files-heading-actions > .auth-toolbar-quiet:hover:not(:disabled) { background: var(--bg-hover); color: var(--auth-text); }
.auth-files-page .auth-files-panel {
--auth-list-columns: minmax(160px, 1.15fr) 66px 88px 110px 152px minmax(220px, 3fr) 166px;
--auth-list-columns: minmax(168px, 1.1fr) 72px 92px 116px 156px minmax(240px, 2.4fr) 248px;
padding: 0;
min-height: 0;
border: 0;
@@ -41,36 +41,36 @@
.auth-files-page .auth-files-toolbar select:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 1px; }
.auth-file-table-scroll { min-width: 0; overflow-x: auto; padding: 0 0 2px; }
.auth-file-list-head, .auth-credential-row { display: grid; grid-template-columns: var(--auth-list-columns); align-items: center; column-gap: 10px; }
.auth-file-list-head { min-width: 1148px; min-height: 36px; padding: 2px 13px 0; color: var(--auth-muted); font-size: 12px; font-weight: 700; }
.auth-file-list-head { min-width: 1280px; min-height: 38px; padding: 4px 14px 0; color: var(--auth-muted); font-size: 13px; font-weight: 700; }
.auth-file-list-head > span { min-width: 0; line-height: 1.25; text-align: center; }
.auth-file-list-head > :first-child, .auth-file-list-head > :nth-child(6) { text-align: left; }
.auth-file-card-grid { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 1148px; gap: 8px; }
.auth-file-card { min-width: 0; padding: 0 12px; border: 1px solid var(--auth-border); border-radius: 8px; background: var(--bg-card); }
.auth-file-card-grid { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 1280px; gap: 10px; }
.auth-file-card { min-width: 0; padding: 0 14px; border: 1px solid var(--auth-border); border-radius: 10px; background: var(--bg-card); }
.auth-file-card:hover { border-color: color-mix(in srgb, var(--auth-accent) 24%, var(--auth-border)); }
.auth-credential-row { min-height: 102px; padding: 10px 0; }
.auth-credential-row { min-height: 108px; padding: 12px 0; }
.auth-list-cell { min-width: 0; }
.auth-card-header { display: flex; gap: 10px; align-items: center; min-width: 0; }
.auth-card-header .provider-logo { width: 35px; height: 35px; object-fit: contain; flex-shrink: 0; }
[data-theme="dark"] .auth-card-header .provider-logo[data-provider="xai"] { filter: invert(1); }
.auth-card-identity { min-width: 0; flex: 1; display: grid; gap: 5px; }
.auth-card-identity strong { color: var(--auth-text); font-size: 13px; font-weight: 750; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-card-filename, .auth-card-provider { color: var(--auth-muted); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-card-identity strong { color: var(--auth-text); font-size: 14px; font-weight: 750; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-card-filename, .auth-card-provider { color: var(--auth-muted); font-size: 12px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-list-plan, .auth-list-status { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; text-align: center; }
.auth-list-plan > span { max-width: 100%; color: var(--auth-text); font-size: 12px; font-weight: 600; text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-list-plan small, .auth-list-status small { color: var(--auth-muted); font-size: 11px; line-height: 1.25; }
.auth-list-plan > span { max-width: 100%; color: var(--auth-text); font-size: 13px; font-weight: 600; text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-list-plan small, .auth-list-status small { color: var(--auth-muted); font-size: 12px; line-height: 1.3; }
.auth-list-status small { font-weight: 600; }
.auth-status-badge { display: block; max-width: 100%; padding: 4px 8px; border-radius: 7px; background: var(--bg-secondary); color: var(--auth-muted); font-size: 12px; line-height: 1.4; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-status-badge { display: block; max-width: 100%; padding: 4px 9px; border-radius: 7px; background: var(--bg-secondary); color: var(--auth-muted); font-size: 13px; line-height: 1.4; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-status-badge.success { color: var(--success-color); background: color-mix(in srgb, var(--success-color) 8%, var(--bg-card)); }
.auth-status-badge.warning { color: var(--amber-text); background: color-mix(in srgb, var(--amber-text) 8%, var(--bg-card)); }
.auth-status-badge.error { color: var(--failure-badge-text); background: color-mix(in srgb, var(--failure-badge-text) 7%, var(--bg-card)); }
.auth-status-badge.info { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 8%, var(--bg-card)); }
.auth-list-muted { color: var(--auth-muted); font-size: 11px; }
.auth-list-muted { color: var(--auth-muted); font-size: 12px; }
.auth-list-recent .auth-file-requests { gap: 9px; }
.auth-list-recent .auth-file-requests-latest { color: var(--auth-muted); font-size: 11px; font-weight: 650; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.auth-list-recent .auth-file-requests-latest { color: var(--auth-muted); font-size: 12px; font-weight: 650; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.auth-list-recent .auth-file-requests-blocks { gap: 2px; padding: 0; }
.auth-list-recent .auth-file-request-block { height: 16px; border-radius: 2px; }
.auth-list-recent .auth-file-requests-timeline { justify-content: center; gap: 5px; font-size: 10px; }
.auth-list-recent .auth-file-requests-unavailable { font-size: 10px; line-height: 1.4; text-align: center; color: var(--auth-muted); }
.auth-list-recent .auth-file-requests-timeline { justify-content: center; gap: 5px; font-size: 11px; }
.auth-list-recent .auth-file-requests-unavailable { font-size: 12px; line-height: 1.4; text-align: center; color: var(--auth-muted); }
.auth-list-usage .auth-file-usage { color: var(--auth-text); }
.auth-list-usage .auth-file-usage-metrics { grid-template-columns: max-content max-content; grid-template-rows: repeat(2, 20px); gap: 0 18px; justify-content: center; align-content: center; }
.auth-list-usage .auth-file-usage-metric { gap: 5px; }
@@ -78,22 +78,22 @@
.auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; }
.auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; border-radius: 4px; }
.auth-list-usage .auth-file-usage-metric.total svg { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 9%, var(--bg-card)); }
.auth-list-usage .auth-file-usage-metric strong { font-size: 12.75px; line-height: 1.2; font-weight: 750; }
.auth-list-usage .auth-file-usage-metric strong { font-size: 13px; line-height: 1.2; font-weight: 750; }
.auth-list-usage .auth-file-usage-scope { margin-top: 6px; color: var(--auth-muted); text-align: right; font-size: 10px; line-height: 1.2; }
.auth-card-actions { display: grid; grid-template-columns: 98px 54px; align-items: center; justify-content: center; gap: 12px; }
.auth-list-icon-actions { display: grid; grid-template-columns: repeat(3, 30px); gap: 4px; }
.auth-card-actions .auth-list-action { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 30px; min-width: 0; height: 30px; padding: 0; border: 1px solid transparent; border-radius: 8px; color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 10%, var(--bg-card)); cursor: pointer; }
.auth-card-actions .auth-list-action.muted { color: var(--auth-muted); background: var(--bg-secondary); }
.auth-card-actions .auth-list-action.danger { color: #ef4444; background: color-mix(in srgb, #ef4444 9%, var(--bg-card)); }
.auth-card-actions .auth-list-action:disabled { opacity: .4; cursor: default; }
.auth-card-actions .auth-list-action:not(:disabled):hover { border-color: currentColor; }
.auth-list-main-actions { display: grid; justify-items: center; align-items: center; grid-template-rows: repeat(2, 30px); gap: 4px; border-left: 1px solid var(--auth-border); }
.auth-list-switch { display: flex; align-items: center; flex: none; width: 38px; height: 22px; padding: 2px; border: 1px solid color-mix(in srgb, var(--auth-muted) 18%, var(--bg-card)); border-radius: 20px; background: color-mix(in srgb, var(--auth-muted) 24%, var(--bg-card)); cursor: pointer; }
.auth-list-switch > span { display: block; width: 16px; height: 16px; flex-shrink: 0; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px #0002; transition: transform .15s; }
.auth-card-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
.auth-list-icon-actions { display: flex; align-items: center; gap: 6px; }
.auth-card-actions .auth-list-action { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 32px; min-width: 32px; height: 32px; padding: 0; border: 1px solid color-mix(in srgb, var(--auth-accent) 16%, var(--auth-border)); border-radius: 8px; color: var(--auth-accent); background: var(--bg-card); cursor: pointer; }
.auth-card-actions .auth-list-action.muted { color: var(--auth-text); border-color: var(--auth-border); background: var(--bg-card); }
.auth-card-actions .auth-list-action.danger { color: #e11d48; border-color: color-mix(in srgb, #e11d48 22%, var(--auth-border)); background: var(--bg-card); }
.auth-card-actions .auth-list-action:disabled { opacity: .38; cursor: default; }
.auth-card-actions .auth-list-action:not(:disabled):hover { background: color-mix(in srgb, currentColor 8%, var(--bg-card)); border-color: currentColor; }
.auth-list-main-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-left: 10px; border-left: 1px solid var(--auth-border); }
.auth-list-switch { display: flex; align-items: center; flex: none; width: 40px; height: 24px; padding: 2px; border: 1px solid color-mix(in srgb, var(--auth-muted) 22%, var(--bg-card)); border-radius: 20px; background: color-mix(in srgb, var(--auth-muted) 28%, var(--bg-card)); cursor: pointer; }
.auth-list-switch > span { display: block; width: 18px; height: 18px; flex-shrink: 0; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px #0002; transition: transform .15s; }
.auth-list-switch[aria-checked="true"] { background: var(--auth-accent); border-color: var(--auth-accent); }
.auth-list-switch[aria-checked="true"] > span { transform: translateX(16px); }
.auth-list-switch:disabled { opacity: .5; cursor: default; }
.auth-list-details-button { width: 50px; min-height: 30px; padding: 4px; border: 0; border-radius: 8px; background: color-mix(in srgb, var(--auth-accent) 7%, var(--bg-card)); color: var(--auth-accent); font: inherit; font-size: 11px; font-weight: 650; cursor: pointer; }
.auth-list-details-button { width: auto; min-width: 52px; min-height: 32px; padding: 4px 10px; border: 1px solid color-mix(in srgb, var(--auth-accent) 18%, var(--auth-border)); border-radius: 8px; background: var(--bg-card); color: var(--auth-accent); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; white-space: nowrap; }
.auth-list-details-button:hover { background: color-mix(in srgb, var(--auth-accent) 14%, var(--bg-card)); }
.auth-list-action:focus-visible, .auth-list-switch:focus-visible, .auth-list-details-button:focus-visible { outline: 2px solid var(--auth-accent); outline-offset: 3px; }
.auth-list-details { display: grid; gap: 12px; border-top: 1px solid var(--auth-border); padding: 12px 0; }
@@ -105,7 +105,7 @@
.auth-card-note > span { flex-shrink: 0; color: var(--auth-muted); }
.auth-card-note p { min-width: 0; margin: 0; color: var(--auth-text); white-space: pre-wrap; overflow-wrap: anywhere; }
.auth-file-card .auth-health-body, .auth-file-card .auth-health-summary { overflow-wrap: anywhere; }
.auth-list-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 0 0; color: var(--auth-muted); font-size: 11px; }
.auth-list-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; width: 100%; max-width: 100%; margin-left: auto; padding: 12px 0 0; color: var(--auth-muted); font-size: 12px; position: sticky; left: 0; }
.auth-list-pagination > span { margin-right: auto; }
.auth-list-pagination label { display: flex; align-items: center; gap: 6px; }
.auth-list-pagination select { min-height: 30px; border: 1px solid var(--auth-border); border-radius: 6px; background: var(--bg-card); color: var(--auth-muted); padding: 3px 6px; font: inherit; }
@@ -153,14 +153,13 @@
@media (prefers-reduced-motion: reduce) { .credential-quota-track.indeterminate i { animation: none; width: 100%; opacity: .3; } }
@container credential-files (min-width: 701px) and (max-width: 1147px) {
.auth-file-list-head, .auth-credential-row { grid-template-columns: minmax(140px, 1fr) 60px 78px 96px 136px minmax(180px, 2fr) 142px; column-gap: 8px; }
.auth-file-card-grid, .auth-file-list-head { min-width: 906px; }
.auth-card-actions { grid-template-columns: 86px 46px; gap: 8px; }
.auth-list-icon-actions { grid-template-columns: repeat(3, 26px); }
.auth-card-actions .auth-list-action { width: 26px; height: 28px; border-radius: 6px; }
.auth-list-main-actions { grid-template-rows: repeat(2, 28px); }
.auth-list-details-button { width: 42px; min-height: 28px; }
@container credential-files (min-width: 701px) and (max-width: 1360px) {
.auth-file-list-head, .auth-credential-row { grid-template-columns: minmax(132px, 1fr) 58px 74px 92px 128px minmax(168px, 1.6fr) 214px;; column-gap: 8px; }
.auth-file-card-grid, .auth-file-list-head { min-width: 980px; }
.auth-card-actions { gap: 8px; }
.auth-list-icon-actions { gap: 4px; }
.auth-card-actions .auth-list-action { width: 30px; min-width: 30px; height: 30px; }
.auth-list-details-button { min-width: 48px; min-height: 30px; padding-inline: 8px; font-size: 12px; }
.auth-list-usage .auth-file-usage-metric strong { font-size: 12px; }
}
@container credential-files (max-width: 700px) {
+5 -1
View File
@@ -31,6 +31,8 @@ export type AgentModelMappings = {
disableAutoCompact?: boolean;
manageDefaultModel?: boolean;
manageSubagentModel?: boolean;
startupModel?: string;
subagentModel?: string;
};
type ResolveAgentConfigurationActionOptions = {
@@ -72,7 +74,9 @@ export const sameAgentModelMappings = (
&& (left.autoCompactPct ?? 90) === (right.autoCompactPct ?? 90)
&& Boolean(left.disableAutoCompact) === Boolean(right.disableAutoCompact)
&& (left.manageDefaultModel ?? true) === (right.manageDefaultModel ?? true)
&& (left.manageSubagentModel ?? true) === (right.manageSubagentModel ?? true);
&& (left.manageSubagentModel ?? true) === (right.manageSubagentModel ?? true)
&& (left.startupModel ?? '') === (right.startupModel ?? '')
&& (left.subagentModel ?? '') === (right.subagentModel ?? '');
export const resolveAgentModelMappingsDraftSource = <T>(
current: T,
+28
View File
@@ -0,0 +1,28 @@
import type { ModelOption } from './modelService';
export const claudeCodeRoles = ['opus', 'sonnet', 'haiku'] as const;
export type ClaudeCodeRole = typeof claudeCodeRoles[number];
export type ClaudeCodeRoutes = Record<ClaudeCodeRole, string> & {
opus1m: boolean; sonnet1m: boolean; haiku1m: boolean;
};
export function claudeCodeModelBase(value: string): string {
return value.trim().replace(/(?:\[1m\])+$/i, '');
}
export function claudeCodeRole(value: string): ClaudeCodeRole | null {
const base = claudeCodeModelBase(value).toLowerCase();
return claudeCodeRoles.find(role => role === base) ?? null;
}
export function claudeCodeModelPreview(value: string, routes: ClaudeCodeRoutes) {
const role = claudeCodeRole(value);
const base = role ? claudeCodeModelBase(routes[role]) : claudeCodeModelBase(value);
const context1m = /\[1m\]$/i.test(value.trim()) || Boolean(role && routes[`${role}1m`]);
return { role, model: base ? base + (context1m ? '[1m]' : '') : '', context1m };
}
export function isKnownClaudeCodeModel(value: string, models: ModelOption[]): boolean {
const base = claudeCodeModelBase(value).toLowerCase();
return !base || Boolean(claudeCodeRole(value)) || models.some(model => model.name.toLowerCase() === base);
}
+1
View File
@@ -7418,6 +7418,7 @@ textarea:focus-visible {
white-space: nowrap;
}
.agent-claude-context-toggle {
display: flex;
flex: none;
+4 -4
View File
@@ -44,7 +44,7 @@ const fs = require('node:fs/promises');
className: node.className, width: node.clientWidth, scroll: node.scrollWidth, overflowX: getComputedStyle(node).overflowX,
})));
assert.ok(dimensions.every(size => size.scroll <= size.width + 1 || (
size.className === 'auth-file-table-scroll' && size.width > 700 && size.width < 906 && size.overflowX === 'auto'
size.className === 'auth-file-table-scroll' && size.width > 700 && size.width < 980 && size.overflowX === 'auto'
)), `${label}: only intermediate widths may scroll inside the table; cards must fit ${JSON.stringify(dimensions)}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `${label}: page overflow`);
};
@@ -93,7 +93,7 @@ const fs = require('node:fs/promises');
const resetTitle = await resetTimes.first().getAttribute('title');
assert.ok(resetTitle.startsWith(await resetTimes.first().innerText()) && resetTitle.includes('小时后'), 'Compact timestamps retain their exact time and relative countdown on hover');
await next().click();
await card(11).waitFor();
await card(7).waitFor();
assert.equal(await cards().count(), 2);
assert.equal(await next().isDisabled(), true);
assert.ok((await pagination().innerText()).includes('2 / 2'));
@@ -235,10 +235,10 @@ const fs = require('node:fs/promises');
await page.setViewportSize({ width: 1800, height: 1200 });
await open('theme=light&locale=zh-CN');
const tableBounds = await page.locator('.auth-file-table-scroll').boundingBox();
const seventhBounds = await card(7).boundingBox();
const lastBounds = await cards().last().boundingBox();
await page.screenshot({ path: path.join(screenshotDir, 'reference-dense-preview.png'), clip: {
x: tableBounds.x, y: tableBounds.y, width: tableBounds.width,
height: seventhBounds.y + seventhBounds.height - tableBounds.y,
height: lastBounds.y + lastBounds.height - tableBounds.y,
} });
await open('theme=light&locale=zh-CN&small=1');
await page.locator('.auth-file-table-scroll').screenshot({ path: path.join(screenshotDir, 'reference-small-idle-preview.png') });
+77
View File
@@ -0,0 +1,77 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
await page.goto('http://127.0.0.1:1421/tests/fixtures/agent-backups.html?client=claude-code&reset-selections');
const startup = page.getByRole('combobox', { name: '启动模型', exact: true });
const subagent = page.getByRole('combobox', { name: 'Subagent 模型', exact: true });
await startup.click();
await page.getByRole('option', { name: /^sonnet / }).click();
assert.equal(await startup.inputValue(), 'sonnet');
await page.locator('.claude-role-row .agent-model-trigger').first().click();
await page.getByRole('option', { name: /gpt-two/ }).click();
await page.getByText('跟随 sonnet → gpt-two', { exact: true }).waitFor();
assert.equal(await startup.inputValue(), 'sonnet');
await startup.click();
await page.getByRole('option', { name: /^gpt-two / }).click();
await page.locator('.claude-code-context-settings summary').click();
const context = page.getByRole('checkbox', { name: '启动模型 1M', exact: true });
await context.check();
assert.equal(await startup.inputValue(), 'gpt-two[1m]');
assert.equal(await page.getByRole('spinbutton', { name: '全局窗口覆盖', exact: true }).inputValue(), '1000000');
await context.uncheck();
assert.equal(await page.getByRole('spinbutton', { name: '全局窗口覆盖', exact: true }).inputValue(), '200000');
assert.equal(await startup.inputValue(), 'gpt-two');
await page.locator('.claude-code-context-settings summary').click();
assert.equal(await page.locator('.claude-role-row input[type=checkbox]').count(), 3);
await subagent.fill('custom-worker');
await subagent.press('Enter');
assert.equal(await subagent.inputValue(), 'custom-worker');
await page.getByRole('button', { name: '更新配置', exact: true }).click();
await page.waitForFunction(() => window.fixtureCalls.some(c => c.args?.claudeCodeModelMappings?.startupModel === 'gpt-two' && c.args?.claudeCodeModelMappings?.subagentModel === 'custom-worker'));
await subagent.fill('unsaved-worker');
await subagent.press('Escape');
await page.evaluate(() => { window.fixtureFailedCommands = ['get_agent_models']; });
await page.locator('.claude-role-row .agent-model-trigger').first().click();
await page.locator('.agent-model-dropdown:visible button').filter({ has: page.locator('svg.lucide-refresh-cw') }).click();
await page.locator('.claude-code-model-error').waitFor();
assert.equal(await startup.inputValue(), 'gpt-two');
assert.equal(await subagent.inputValue(), 'unsaved-worker');
assert.match(await page.locator('.claude-role-row .agent-model-trigger').first().innerText(), /gpt-two/);
await subagent.press('Escape');
await page.evaluate(() => { window.fixtureFailedCommands = []; });
await page.locator('.claude-code-model-error button').click();
await page.locator('.claude-code-model-error').waitFor({ state: 'hidden' });
assert.equal(await subagent.inputValue(), 'unsaved-worker');
await startup.click();
await page.getByRole('option', { name: '使用 Claude Code 默认模型', exact: true }).click();
await subagent.click();
await page.getByRole('option', { name: '使用默认继承行为', exact: true }).click();
await page.getByRole('button', { name: '更新配置', exact: true }).click();
await page.waitForFunction(() => window.fixtureCalls.some(c => c.args?.claudeCodeModelMappings?.startupModel === '' && c.args?.claudeCodeModelMappings?.subagentModel === ''));
await page.getByRole('checkbox', { name: 'sonnet 1M', exact: true }).check();
await page.getByRole('button', { name: '将 Sonnet 应用到全部角色', exact: true }).click();
for (const role of ['sonnet', 'opus', 'haiku']) {
assert.equal(await page.getByRole('checkbox', { name: `${role} 1M`, exact: true }).isChecked(), true);
assert.match(await page.locator(`.claude-role-row[data-role="${role}"] .agent-model-trigger`).innerText(), /gpt-two/);
}
await page.getByRole('button', { name: '更新配置', exact: true }).click();
await page.waitForFunction(() => window.fixtureCalls.some(c => {
const m = c.args?.claudeCodeModelMappings;
return m && m.opus === 'gpt-two' && m.haiku === 'gpt-two' && m.opus1m && m.haiku1m && m.sonnet1m;
}));
for (const width of [1280, 760]) {
await page.setViewportSize({ width, height: 1000 });
await page.screenshot({ path: path.join(os.tmpdir(), `claude-code-models-${width}.png`), fullPage: true });
for (const field of await page.locator('.claude-code-model-field').all()) {
assert(await field.evaluate(el => el.scrollWidth <= el.clientWidth + 1));
}
}
console.log('PASS: select, custom input, defaults, connection failure/retry, draft retention and responsive layout');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from 'bun:test';
import { claudeCodeModelPreview, isKnownClaudeCodeModel } from '../src/services/claudeCodeModels';
const routes = { opus: 'model-a', sonnet: 'model-b', haiku: 'model-c', opus1m: true, sonnet1m: false, haiku1m: false };
test('role selections follow changed mappings and role context flags', () => {
expect(claudeCodeModelPreview('opus', routes).model).toBe('model-a[1m]');
expect(claudeCodeModelPreview('sonnet', { ...routes, sonnet: 'model-d', sonnet1m: true }).model).toBe('model-d[1m]');
});
test('direct model stays pinned and has its own context flag', () => {
expect(claudeCodeModelPreview('model-a', routes)).toEqual({ role: null, model: 'model-a', context1m: false });
expect(claudeCodeModelPreview('model-a[1m]', { ...routes, opus: 'other' }).model).toBe('model-a[1m]');
});
test('role aliases do not need to appear in CPA model list', () => {
expect(isKnownClaudeCodeModel('sonnet[1m]', [])).toBeTrue();
expect(isKnownClaudeCodeModel('', [])).toBeTrue();
expect(isKnownClaudeCodeModel('model-a[1m]', [{ name: 'model-a' }])).toBeTrue();
expect(isKnownClaudeCodeModel('unknown', [{ name: 'model-a' }])).toBeFalse();
});