From 6e34850780c5e6739ce63757ed7793ee64cf2ef8 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 5 Oct 2026 21:18:10 +0800 Subject: [PATCH] style(usage): show reasoning effort under the model name Drop the separate reasoning-effort column from request details and render the raw effort, or auto, directly beneath the model name. Include it in the model tooltip, reset saved layouts that still use the old default column, and keep the field in CSV export. --- src-tauri/capabilities/default.json | 3 +- src-tauri/src/main.rs | 1 + src-tauri/src/usage.rs | 109 ++++++- src/components/AgentModelPicker.tsx | 341 ++++++++++++++++++++ src/components/AuthFileSettingsDialog.css | 68 +++- src/components/AuthFileSettingsDialog.tsx | 36 +-- src/components/CredentialAdvancedFields.tsx | 235 ++++++++++++++ src/i18n/ja.ts | 43 ++- src/i18n/locales/en.ts | 43 ++- src/i18n/locales/zh-CN.ts | 43 ++- src/mocks/browserMockRuntime.ts | 1 + src/pages/AgentsPage.tsx | 297 +---------------- src/pages/AuthFileManagementPage.tsx | 2 +- src/pages/QuotaPage.css | 65 +++- src/pages/QuotaPage.tsx | 52 ++- src/pages/UsageEventsView.css | 13 + src/pages/UsageEventsView.tsx | 59 ++-- tests/credentialAdvancedFields.test.tsx | 36 +++ tests/credentialAliasPicker.test.tsx | 20 ++ tests/fixtures/credential-settings.html | 5 + tests/fixtures/credential-settings.tsx | 24 ++ tests/uiLocalization.test.ts | 18 +- 22 files changed, 1130 insertions(+), 384 deletions(-) create mode 100644 src/components/AgentModelPicker.tsx create mode 100644 src/components/CredentialAdvancedFields.tsx create mode 100644 tests/credentialAdvancedFields.test.tsx create mode 100644 tests/credentialAliasPicker.test.tsx create mode 100644 tests/fixtures/credential-settings.html create mode 100644 tests/fixtures/credential-settings.tsx diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3ddf213..da27db7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-theme", "core:window:allow-set-background-color", "core:webview:allow-set-webview-background-color", - "dialog:allow-open" + "dialog:allow-open", + "dialog:allow-save" ] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 63eeaa2..5114994 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2725,6 +2725,7 @@ fn main() { usage::get_usage_overview, usage::get_usage_analysis, usage::get_usage_events, + usage::save_usage_events_export, usage::get_usage_pricing, usage::get_usage_storage_settings, usage::repair_usage_cache_records, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index a0caf57..9f08070 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -21,7 +21,7 @@ use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, HashMap, HashSet}, fs, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::{LazyLock, Mutex}, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -4232,6 +4232,113 @@ fn load_api_key_categories( Ok(categories) } + +#[tauri::command] +pub(crate) async fn save_usage_events_export(path: String, contents: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || write_usage_events_export(&path, &contents)) + .await + .map_err(|error| format!("Failed to save usage export: {error}"))? +} + +fn write_usage_events_export(path: &str, contents: &str) -> Result<(), String> { + let path = validated_usage_export_path(path)?; + fs::write(&path, contents.as_bytes()) + .map_err(|error| format!("Failed to write usage export {}: {error}", path.display())) +} + +fn validated_usage_export_path(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() || trimmed.contains('\0') { + return Err("Usage export path is invalid".to_string()); + } + let path = PathBuf::from(trimmed); + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err("Usage export path must be an absolute file path".to_string()); + } + if path.file_name().is_none_or(|name| name.is_empty()) { + return Err("Usage export path must include a file name".to_string()); + } + if path.is_dir() { + return Err("Usage export path is a directory".to_string()); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| "Usage export path has no directory".to_string())?; + if !parent.is_dir() { + return Err(format!( + "Usage export directory does not exist: {}", + parent.display() + )); + } + Ok(path) +} + +#[cfg(test)] +mod save_usage_events_export_tests { + use super::{validated_usage_export_path, write_usage_events_export}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempDir(PathBuf); + + impl TempDir { + fn new(label: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "cpa-usage-export-{}-{}-{}", + std::process::id(), + label, + stamp + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn writes_the_selected_csv_and_replaces_an_existing_file() { + let directory = TempDir::new("write"); + let path = directory.path().join("usage.csv"); + fs::write(&path, "old").unwrap(); + let selected = path.to_string_lossy().into_owned(); + write_usage_events_export(&selected, "\u{feff}id\r\n1").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "\u{feff}id\r\n1"); + } + + #[test] + fn rejects_relative_paths_missing_directories_and_directories() { + let directory = TempDir::new("reject"); + assert!(validated_usage_export_path("usage.csv").is_err()); + assert!(validated_usage_export_path(&directory.path().to_string_lossy()).is_err()); + let missing = directory + .path() + .join("missing") + .join("usage.csv") + .to_string_lossy() + .into_owned(); + assert!(write_usage_events_export(&missing, "id").is_err()); + } +} + #[tauri::command] pub(crate) async fn get_usage_events( query: UsageQuery, diff --git a/src/components/AgentModelPicker.tsx b/src/components/AgentModelPicker.tsx new file mode 100644 index 0000000..3ff8af3 --- /dev/null +++ b/src/components/AgentModelPicker.tsx @@ -0,0 +1,341 @@ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import { Check, ChevronDown, LoaderCircle, RefreshCw, Search, X } from 'lucide-react'; +import { + agentModelAlias, + filterAgentModels, + findAgentModel, +} from '../services/agentModelPicker'; +import type { ModelOption } from '../services/modelService'; +import { useI18n } from '../i18n'; + +export type AgentModelPickerProps = { + models: ModelOption[]; + value: string; + loading?: boolean; + error?: string; + disabled?: boolean; + onChange: (value: string) => void; + onRefresh?: () => void; + allowCustomValue?: boolean; + editable?: { label: string; placeholder: string; maxLength: number }; + menuClassName?: string; +}; + +type AgentModelDropdownLayout = { + top: number; + left: number; + width: number; + height: number; +}; + +export function AgentModelPicker({ + models, + value, + loading = false, + error = '', + disabled = false, + onChange, + onRefresh, + allowCustomValue = false, + editable, + menuClassName = '', +}: AgentModelPickerProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [dropdownLayout, setDropdownLayout] = useState(null); + const [dialogOverlay, setDialogOverlay] = useState(false); + const rootRef = useRef(null); + const menuRef = useRef(null); + const searchRef = useRef(null); + const valueRef = useRef(null); + const listboxId = useId(); + const visibleModels = useMemo( + () => editable && !search.trim() ? models : filterAgentModels(models, search), + [models, search, editable], + ); + const choices = useMemo(() => { + const options = visibleModels.map((model) => ({ name: model.name, alias: model.alias ?? '' })); + if (allowCustomValue && search.trim() && !findAgentModel(models, search)) { + options.push({ name: search.trim(), alias: t('agents.model.useCustom') }); + } + return options; + }, [visibleModels, allowCustomValue, search, models, t]); + const selectedModel = findAgentModel(models, value); + const selectedName = selectedModel?.name ?? (allowCustomValue || editable ? value.trim() : ''); + const selectedAlias = selectedName ? agentModelAlias(models, selectedName) : ''; + + const updateDropdownLayout = useCallback(() => { + const root = rootRef.current; + if (!root) return; + + const rect = root.getBoundingClientRect(); + const dialog = root.closest('dialog'); + const footer = dialog?.querySelector('.credential-settings-footer'); + const heading = dialog?.querySelector('.credential-settings-heading'); + const frame = dialog?.getBoundingClientRect(); + const footerTop = footer?.getBoundingClientRect().top; + const headingBottom = heading?.getBoundingClientRect().bottom; + const edgeGap = 12; + const triggerGap = 6; + const preferredHeight = 282; + const minimumHeight = 150; + const bounds = { + top: (headingBottom ?? frame?.top ?? 0) + edgeGap, + bottom: (footerTop ?? frame?.bottom ?? window.innerHeight) - edgeGap, + left: (frame?.left ?? 0) + edgeGap, + right: (frame?.right ?? window.innerWidth) - edgeGap, + }; + const scroller = dialog?.querySelector('.credential-settings-body'); + if (scroller) { + const view = scroller.getBoundingClientRect(); + if (rect.bottom < view.top + 4 || rect.top > view.bottom - 4) { + setOpen(false); + return; + } + } + const spaceBelow = Math.max(0, bounds.bottom - rect.bottom - triggerGap); + const spaceAbove = Math.max(0, rect.top - triggerGap - bounds.top); + const placeAbove = spaceBelow < minimumHeight && spaceAbove > spaceBelow; + const availableHeight = placeAbove ? spaceAbove : spaceBelow; + const limit = Math.max(0, bounds.bottom - bounds.top); + const height = Math.min(preferredHeight, limit, Math.max(Math.min(minimumHeight, limit), availableHeight)); + const width = Math.min(rect.width, Math.max(0, bounds.right - bounds.left)); + const left = Math.min(Math.max(bounds.left, rect.left), Math.max(bounds.left, bounds.right - width)); + const desiredTop = placeAbove ? rect.top - triggerGap - height : rect.bottom + triggerGap; + const top = Math.min(Math.max(bounds.top, desiredTop), Math.max(bounds.top, bounds.bottom - height)); + + setDropdownLayout({ top, left, width, height }); + }, []); + + useLayoutEffect(() => { + if (!open) { + setDropdownLayout(null); + setDialogOverlay(false); + return undefined; + } + const inDialog = Boolean(rootRef.current?.closest('dialog')); + if (inDialog !== dialogOverlay) { + setDialogOverlay(inDialog); + return undefined; + } + + updateDropdownLayout(); + const menu = menuRef.current; + if (dialogOverlay && menu && !menu.matches(':popover-open')) menu.showPopover(); + const onScroll = (event: Event) => { + if (menuRef.current?.contains(event.target as Node)) return; + updateDropdownLayout(); + }; + window.addEventListener('resize', updateDropdownLayout); + window.addEventListener('scroll', onScroll, true); + return () => { + window.removeEventListener('resize', updateDropdownLayout); + window.removeEventListener('scroll', onScroll, true); + }; + }, [open, dialogOverlay, updateDropdownLayout]); + + useEffect(() => { + if (!open) return undefined; + const close = (event: MouseEvent) => { + const target = event.target as Node; + if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + useEffect(() => { + if (!open) return; + if (!editable) setSearch(''); + const selectedIndex = (editable ? visibleModels : filterAgentModels(models, '')).findIndex( + (model) => model.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(), + ); + setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); + if (!editable) requestAnimationFrame(() => searchRef.current?.focus()); + }, [open]); + + useEffect(() => { + setActiveIndex((current) => Math.min(current, Math.max(choices.length - 1, 0))); + }, [choices.length]); + + const choose = (name: string) => { + onChange(name); + setOpen(false); + if (editable) valueRef.current?.focus(); + }; + + const moveActive = (offset: number) => { + if (choices.length === 0) return; + setActiveIndex((current) => (current + offset + choices.length) % choices.length); + }; + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!open) setOpen(true); + else moveActive(1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!open) setOpen(true); + else moveActive(-1); + } else if (event.key === 'Enter' && open && choices[activeIndex]) { + event.preventDefault(); + choose(choices[activeIndex].name); + } else if (event.key === 'Escape') { + event.preventDefault(); + setOpen(false); + } + }; + + return ( +
{ + if (event.button === 0 && event.currentTarget.contains(document.activeElement) + && event.target instanceof Element && event.target.closest('button')) { + event.preventDefault(); + } + }} + onBlur={(event) => { + const next = event.relatedTarget as Node | null; + if (!event.currentTarget.contains(next) && !menuRef.current?.contains(next)) setOpen(false); + }}> + {editable ? ( +
+ { setSearch(''); setOpen(true); }} onKeyDown={handleSearchKeyDown} + onChange={(event) => { + onChange(event.currentTarget.value); + setSearch(event.currentTarget.value); + setActiveIndex(0); + setOpen(true); + }} /> + +
+ ) : } + + {open ? ( +
+ {!editable ?
+ + { + setSearch(event.currentTarget.value); + setActiveIndex(0); + }} + onKeyDown={handleSearchKeyDown} + placeholder={t('agents.model.search')} + role="combobox" + aria-controls={listboxId} + aria-expanded="true" + /> + {search ? ( + + ) : null} + {onRefresh ? : null} +
: null} + +
+ {loading && models.length === 0 && choices.length === 0 ? ( +
{t('agents.model.fetching')}
+ ) : error && models.length === 0 && choices.length === 0 ? ( +
{t('agents.model.loadFailed')}{error}
+ ) : choices.length === 0 ? ( +
+ {editable ? {t('agents.claudeDesktopMapping.customAliasHint')} : <> + {search.trim() ? t('agents.model.noMatch') : t('agents.model.unavailable')} + {search.trim() ? t('agents.model.tryKeywords') : t('agents.model.connectFirst')} + } +
+ ) : choices.map((choice, index) => { + const selected = choice.name.toLocaleLowerCase() === value.trim().toLocaleLowerCase(); + return ( + + ); + })} +
+
+ {t(editable ? 'agents.claudeDesktopMapping.suggestionCount' : 'agents.model.count', { count: models.length })} + {error && models.length > 0 ? {t('agents.model.stale')} : null} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/AuthFileSettingsDialog.css b/src/components/AuthFileSettingsDialog.css index f93da8f..83859db 100644 --- a/src/components/AuthFileSettingsDialog.css +++ b/src/components/AuthFileSettingsDialog.css @@ -1,6 +1,8 @@ .credential-settings-dialog { - width: min(760px, calc(100vw - 32px)); + width: min(820px, calc(100vw - 32px)); max-height: min(900px, calc(100dvh - 40px)); + display: flex; + flex-direction: column; padding: 0; overflow: hidden; border: 1px solid var(--border-color); @@ -10,17 +12,17 @@ box-shadow: 0 24px 80px #0004; } .credential-settings-dialog::backdrop { background: #0b122080; backdrop-filter: blur(3px); } -.credential-settings-dialog form { display: flex; flex-direction: column; max-height: min(898px, calc(100dvh - 42px)); min-height: 0; } +.credential-settings-dialog form { display: flex; flex: 1 1 auto; flex-direction: column; width: 100%; min-height: 0; } .credential-settings-heading, .credential-settings-footer { flex-shrink: 0; } .credential-settings-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--border-color); } .credential-settings-heading > div { min-width: 0; } .credential-settings-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: var(--font-size-dialog-title); line-height: var(--line-height-tight); } .credential-settings-heading p { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; } -.credential-settings-body { overflow-y: auto; min-height: 0; padding: 22px 24px; overscroll-behavior: contain; } +.credential-settings-body { overflow: auto; flex: 1 1 auto; min-height: 0; padding: 22px 24px 28px; overscroll-behavior: contain; } .credential-settings-body fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } -.credential-settings-section + .credential-settings-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border-color); } -.credential-settings-section h3 { margin: 0 0 15px; font-size: var(--font-size-section); line-height: var(--line-height-tight); } -.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px; } +.credential-settings-section + .credential-settings-section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border-color); } +.credential-settings-section h3 { margin: 0 0 8px; font-size: var(--font-size-section); line-height: var(--line-height-tight); } +.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 16px; } .credential-settings-field { display: grid; align-content: start; gap: 7px; min-width: 0; font-size: var(--font-size-label); } .credential-settings-field > span { font-weight: var(--font-weight-medium); } .credential-settings-field + .credential-settings-field { margin-top: 16px; } @@ -40,7 +42,7 @@ .credential-settings-models label { display: flex; align-items: flex-start; gap: 8px; overflow-wrap: anywhere; } .credential-settings-models input { accent-color: var(--primary-color); margin-top: 3px; } .credential-settings-models span { min-width: 0; } -.credential-settings-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); } +.credential-settings-footer { position: relative; z-index: 1; flex: 0 0 auto; padding: 16px 24px; border-top: 1px solid var(--border-color); background: var(--bg-card); } .credential-settings-actions { display: flex; gap: 10px; justify-content: flex-end; } .credential-settings-error { color: var(--failure-badge-text); margin: 0 0 12px; font-size: var(--font-size-caption); overflow-wrap: anywhere; max-height: 90px; overflow-y: auto; } .credential-settings-warning { color: var(--amber-text); } @@ -51,3 +53,55 @@ .credential-settings-grid { grid-template-columns: minmax(0, 1fr); } .credential-settings-heading, .credential-settings-body, .credential-settings-footer { padding: 16px; } } + +.credential-settings-lead { margin: 0 0 10px; color: var(--text-tertiary); font-size: var(--font-size-meta); line-height: var(--line-height-body); } +.credential-settings-span { grid-column: 1 / -1; } +.credential-settings-inline-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.credential-alias-list, .credential-header-list { display: grid; gap: 12px; } +.credential-alias-card { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-primary); } +.credential-alias-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; } +.credential-alias-fields .credential-settings-field { margin: 0; } +.credential-alias-fields .credential-settings-field:last-child { grid-column: 1 / -1; } +.credential-settings-dialog .agent-model-trigger { box-sizing: border-box; min-height: 41px; padding: 0 4px 0 11px; border-color: var(--border-color); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); } +.credential-settings-dialog .agent-model-trigger strong { color: var(--text-primary); } +.credential-settings-dialog .agent-model-editable-trigger input { box-sizing: border-box; width: 100%; height: 39px; margin: 0; padding: 0; border: 0; border-radius: 0; outline: 0; background: transparent; box-shadow: none; color: inherit; font: inherit; font-size: inherit; line-height: 1.5; } +.credential-settings-dialog .credential-model-menu { + position: fixed; + z-index: 2; + margin: 0; + max-height: none; + overflow: hidden; + border: 1px solid var(--border-color); + background: var(--bg-card); + color: var(--text-primary); + box-shadow: 0 16px 40px #0003; +} +.credential-settings-dialog .credential-model-menu .agent-model-option { + background: var(--bg-card); + color: var(--text-primary); +} +.credential-settings-dialog .credential-model-menu .agent-model-option:hover, +.credential-settings-dialog .credential-model-menu .agent-model-option.active { + background: var(--bg-secondary); +} +.credential-settings-dialog .credential-model-menu .agent-model-option.selected { + background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-card)); +} +.credential-settings-dialog .credential-model-menu .agent-model-option small, +.credential-settings-dialog .credential-model-menu .agent-model-dropdown-footer { + color: var(--text-tertiary); +} +.credential-alias-card-heading, .credential-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.credential-alias-card-heading strong { font-size: var(--font-size-meta); } +.credential-alias-checks { display: flex; flex-wrap: wrap; gap: 8px 16px; } +.credential-alias-checks label { display: flex; align-items: center; gap: 8px; font-size: var(--font-size-meta); } +.credential-header-row { display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr) auto; } +.credential-header-row input { min-width: 0; } +@media (max-width: 560px) { + .credential-header-row { grid-template-columns: minmax(0, 1fr); } +} + +@media (max-width: 560px) { + .credential-alias-fields { grid-template-columns: minmax(0, 1fr); } +} + diff --git a/src/components/AuthFileSettingsDialog.tsx b/src/components/AuthFileSettingsDialog.tsx index 717063a..a77b62a 100644 --- a/src/components/AuthFileSettingsDialog.tsx +++ b/src/components/AuthFileSettingsDialog.tsx @@ -6,22 +6,23 @@ import { managementApi } from '../services/managementApi'; import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft, type BooleanOverride } from '../services/authFileSettings'; import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels'; import './AuthFileSettingsDialog.css'; -import { StructuredConfigEditor, useConfigText } from './StructuredConfigEditor'; -import { credentialAdvancedShape } from '../services/credentialAdvancedSettings'; +import { CredentialAdvancedFields, CredentialHeadersEditor, credentialProviderKey } from './CredentialAdvancedFields'; +import { modelSearchText, type ModelOption } from '../services/modelService'; -export function AuthFileSettingsDialog({ name, onClose, onSaved }: { +export function AuthFileSettingsDialog({ name, provider = '', onClose, onSaved }: { name: string; + provider?: string; onClose: () => void; onSaved: () => void; }) { const { t } = useI18n(); - const tx = useConfigText(); const id = useId(); const dialogRef = useRef(null); const savingRef = useRef(false); const [original, setOriginal] = useState(null); const [draft, setDraft] = useState(null); const [models, setModels] = useState([]); + const [pickerModels, setPickerModels] = useState([]); const [catalogError, setCatalogError] = useState(''); const [catalogLoading, setCatalogLoading] = useState(true); const [search, setSearch] = useState(''); @@ -57,7 +58,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: { if (active) setError(reason instanceof Error ? reason.message : String(reason)); }).finally(() => { if (active) setLoading(false); }); void managementApi.get('/credentials/models', { name }).then((payload) => { - if (active) setModels(oauthModelsFromPayload(payload)); + const catalog = oauthModelsFromPayload(payload); + if (!active) return; + setModels(catalog); + setPickerModels(catalog.map((model) => ({ name: model.id, displayName: model.displayName }))); }).catch((reason: unknown) => { if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason)); }).finally(() => { if (active) setCatalogLoading(false); }); @@ -92,8 +96,10 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: { } }; const rules = normalizeOAuthExcludedRules((draft?.excluded_models ?? '').split(/\r?\n/)); - const candidates = oauthModelCandidates(models, rules).filter((model) => - `${model.id} ${model.displayName ?? ''}`.toLowerCase().includes(search.trim().toLowerCase())); + const query = search.trim().toLowerCase(); + const candidates = oauthModelCandidates(models, rules).filter((model) => modelSearchText({ name: model.id, displayName: model.displayName }).includes(query)); + const resolvedProvider = credentialProviderKey(name, provider); + const showWebsockets = resolvedProvider === 'codex' || resolvedProvider === 'xai' || draft?.websockets === 'true' || draft?.websockets === 'false'; const textField = (key: 'prefix' | 'proxy_url' | 'priority' | 'weight') => (