cc-haha/desktop/src/pages/Settings.tsx
程序员阿江(Relakkes) 28f36da0fd Reduce extension setup friction inside desktop settings
The desktop app already had solid session streaming, permission, and tool-rendering flows, but extension setup still forced users into manual forms or external shell work. This change adds an Install Center in Settings that reuses the session chat pipeline for natural-language installs, adds installer-specific guidance for plugin and skill URLs, and includes an agent-browser E2E script for real UI validation.

Constraint: Must reuse the existing session/chat execution path instead of introducing a second install runtime
Constraint: Plugin installs need real CLI commands while skill installs may come from published install commands on third-party pages
Rejected: Separate terminal-only install surface | duplicates session UX and weakens permission/tool visibility
Rejected: Pure form-based installer expansion | too much friction for plugin, MCP, and skill onboarding
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep installer prompts aligned with the actual CLI install surfaces; do not let the installer fall back to slash-command syntax inside Bash
Tested: cd desktop && bun run lint
Tested: Real UI automation via agent-browser for Telegram plugin install flow through Settings > Install, verified Plugins page shows telegram enabled
Tested: Real UI automation via agent-browser for ui-ux-pro-max skill install flow through Settings > Install, verified ~/.claude/skills/ui-ux-pro-max and Skills page visibility
Not-tested: Full e2e-install-center-agent-browser.sh script as a single uninterrupted green run after the latest stability tweaks
2026-04-22 01:06:57 +08:00

1535 lines
69 KiB
TypeScript

import { useState, useEffect, useMemo, useRef, type ReactNode } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useTranslation } from '../i18n'
import { Modal } from '../components/shared/Modal'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings'
import type { Locale } from '../i18n'
import { PROVIDER_PRESETS } from '../config/providerPresets'
import type { ProviderPreset } from '../config/providerPresets'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
import { AdapterSettings } from './AdapterSettings'
import { useAgentStore } from '../stores/agentStore'
import { useSessionStore } from '../stores/sessionStore'
import type { AgentDefinition, AgentSource } from '../api/agents'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useSkillStore } from '../stores/skillStore'
import { SkillList } from '../components/skills/SkillList'
import { SkillDetail } from '../components/skills/SkillDetail'
import { usePluginStore } from '../stores/pluginStore'
import { PluginList } from '../components/plugins/PluginList'
import { PluginDetail } from '../components/plugins/PluginDetail'
import { ComputerUseSettings } from './ComputerUseSettings'
import { McpSettings } from './McpSettings'
import { useUIStore, type SettingsTab } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore'
import { formatBytes } from '../lib/formatBytes'
import { InstallCenter } from '../components/settings/InstallCenter'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab)
const t = useTranslation()
useEffect(() => {
if (!pendingSettingsTab) return
setActiveTab(pendingSettingsTab)
useUIStore.getState().setPendingSettingsTab(null)
}, [pendingSettingsTab])
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="flex-1 flex overflow-hidden">
{/* Tab navigation */}
<div className="w-[180px] border-r border-[var(--color-border)] py-3 flex-shrink-0 flex flex-col">
<div className="flex-1">
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
<TabButton icon="download" label={t('settings.tab.install')} active={activeTab === 'install'} onClick={() => setActiveTab('install')} />
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
</div>
<div className="border-t border-[var(--color-border)]/40 pt-1">
<TabButton icon="info" label={t('settings.tab.about')} active={activeTab === 'about'} onClick={() => setActiveTab('about')} />
</div>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto px-8 py-6">
{activeTab === 'providers' && <ProviderSettings />}
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'install' && <InstallCenter />}
{activeTab === 'mcp' && <McpSettings />}
{activeTab === 'agents' && <AgentsSettings />}
{activeTab === 'skills' && <SkillSettings />}
{activeTab === 'plugins' && <PluginSettings />}
{activeTab === 'computerUse' && <ComputerUseSettings />}
{activeTab === 'about' && <AboutSettings />}
</div>
</div>
</div>
)
}
function TabButton({ icon, label, active, onClick }: { icon: string; label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-left transition-colors ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[18px]">{icon}</span>
{label}
</button>
)
}
// ─── Provider Settings ──────────────────────────────────────
function ProviderSettings() {
const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const t = useTranslation()
const [editingProvider, setEditingProvider] = useState<SavedProvider | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
useEffect(() => { fetchProviders() }, [fetchProviders])
const handleDelete = async (provider: SavedProvider) => {
if (activeId === provider.id) return
if (!window.confirm(t('settings.providers.confirmDelete', { name: provider.name }))) return
await deleteProvider(provider.id).catch(console.error)
}
const handleTest = async (provider: SavedProvider) => {
setTestResults((r) => ({ ...r, [provider.id]: { loading: true } }))
try {
const result = await testProvider(provider.id)
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
} catch {
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } } }))
}
}
const handleActivate = async (id: string) => {
await activateProvider(id)
await fetchSettings()
}
const handleActivateOfficial = async () => {
await activateOfficial()
await fetchSettings()
}
const isOfficialActive = activeId === null
return (
<div className="max-w-2xl">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.providers.title')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.description')}</p>
</div>
<Button size="sm" onClick={() => setShowCreateModal(true)}>
<span className="material-symbols-outlined text-[16px]">add</span>
{t('settings.providers.addProvider')}
</Button>
</div>
{/* Official provider — always visible at top */}
<div
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
isOfficialActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
}`}
>
<div
className="flex items-center gap-4 px-4 py-3.5"
onClick={() => !isOfficialActive && handleActivateOfficial()}
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
{isOfficialActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('common.active')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.officialDesc')}</div>
</div>
</div>
{isOfficialActive && (
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
<ClaudeOfficialLogin />
</div>
)}
</div>
{/* Saved providers */}
{isLoading && providers.length === 0 ? (
<div className="flex justify-center py-8">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : (
<div className="flex flex-col gap-2">
{providers.map((provider) => {
const isActive = activeId === provider.id
const test = testResults[provider.id]
const preset = PROVIDER_PRESETS.find((p) => p.id === provider.presetId)
return (
<div
key={provider.id}
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
isActive
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
}`}
>
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
{preset && preset.id !== 'custom' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
)}
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-warning)] leading-none">
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
</span>
)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('common.active')}</span>
)}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
{provider.baseUrl} &middot; {provider.models.main}
</div>
{test && !test.loading && test.result && (
<div className="text-xs mt-1 flex flex-col gap-0.5">
<span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
{test.result.connectivity.success
? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
: t('settings.providers.connectivityFailed', { error: test.result.connectivity.error || '' })}
</span>
{test.result.proxy && (
<span className={test.result.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
{test.result.proxy.success
? t('settings.providers.proxyOk', { latency: String(test.result.proxy.latencyMs) })
: t('settings.providers.proxyFailed', { error: test.result.proxy.error || '' })}
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>{t('settings.providers.activate')}</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>{t('settings.providers.test')}</Button>
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>{t('settings.providers.edit')}</Button>
{!isActive && (
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">{t('common.delete')}</Button>
)}
</div>
</div>
)
})}
</div>
)}
{/* Create Modal — conditionally rendered so state resets on close */}
{showCreateModal && (
<ProviderFormModal open={true} onClose={() => setShowCreateModal(false)} mode="create" />
)}
{/* Edit Modal */}
{editingProvider && (
<ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} />
)}
</div>
)
}
// ─── Provider Form Modal ──────────────────────────────────────
type ProviderFormProps = {
open: boolean
onClose: () => void
mode: 'create' | 'edit'
provider?: SavedProvider
}
function requirePreset(preset: ProviderPreset | undefined): ProviderPreset {
if (!preset) {
throw new Error('Provider presets are not configured')
}
return preset
}
function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
const { createProvider, updateProvider, testConfig } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll)
const t = useTranslation()
const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official')
const fallbackPreset = requirePreset(
availablePresets[availablePresets.length - 1] ?? PROVIDER_PRESETS[0],
)
const initialPreset = requirePreset(
provider
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
: availablePresets[0] ?? fallbackPreset,
)
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
const [name, setName] = useState(provider?.name ?? initialPreset.name)
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
const [apiFormat, setApiFormat] = useState<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
const [apiKey, setApiKey] = useState('')
const [notes, setNotes] = useState(provider?.notes ?? '')
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
const [isSubmitting, setIsSubmitting] = useState(false)
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
const [isTesting, setIsTesting] = useState(false)
const [settingsJson, setSettingsJson] = useState('')
const [settingsJsonError, setSettingsJsonError] = useState<string | null>(null)
const jsonPastedRef = useRef(false)
// Load current settings.json and merge provider env vars
useEffect(() => {
// Skip if JSON was just populated by user paste
if (jsonPastedRef.current) {
jsonPastedRef.current = false
return
}
import('../api/settings').then(({ settingsApi }) => {
settingsApi.getUser().then((settings) => {
const needsProxy = apiFormat !== 'anthropic'
const merged = {
...settings,
skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true,
env: {
...((settings.env as Record<string, string>) || {}),
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : (apiKey || '(your API key)'),
ANTHROPIC_MODEL: models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
},
}
setSettingsJson(JSON.stringify(merged, null, 2))
}).catch(() => {
setSettingsJson(JSON.stringify({}, null, 2))
})
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPreset.id])
const handlePresetChange = (preset: ProviderPreset) => {
setSelectedPreset(preset)
setName(preset.name)
setBaseUrl(preset.baseUrl)
setApiFormat(preset.apiFormat ?? 'anthropic')
setModels({ ...preset.defaultModels })
setTestResult(null)
}
const isCustom = selectedPreset.id === 'custom'
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() && !settingsJsonError
const handleSubmit = async () => {
if (!canSubmit) return
setIsSubmitting(true)
try {
// Write the edited settings.json first (for all presets including official)
if (settingsJson.trim()) {
try {
const parsed = JSON.parse(settingsJson)
const { settingsApi } = await import('../api/settings')
await settingsApi.updateUser(parsed)
} catch {
// JSON validation already prevents this
}
}
if (mode === 'create') {
await createProvider({
presetId: selectedPreset.id,
name: name.trim(),
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim(),
apiFormat,
models,
notes: notes.trim() || undefined,
})
} else if (provider) {
const input: UpdateProviderInput = {
name: name.trim(),
baseUrl: baseUrl.trim(),
apiFormat,
models,
notes: notes.trim() || undefined,
}
if (apiKey.trim()) input.apiKey = apiKey.trim()
await updateProvider(provider.id, input)
}
await fetchSettings()
onClose()
} catch (err) {
console.error('Failed to save provider:', err)
} finally {
setIsSubmitting(false)
}
}
const handleTest = async () => {
if (!baseUrl.trim() || !models.main.trim()) return
setIsTesting(true)
setTestResult(null)
try {
let result: ProviderTestResult
if (mode === 'edit' && provider && !apiKey.trim()) {
result = await useProviderStore.getState().testProvider(provider.id, {
baseUrl: baseUrl.trim(),
modelId: models.main.trim(),
apiFormat,
})
} else {
if (!apiKey.trim()) return
result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim(), apiFormat })
}
setTestResult(result)
} catch {
setTestResult({ connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } })
} finally {
setIsTesting(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title={mode === 'create' ? t('settings.providers.addTitle') : t('settings.providers.editTitle')}
width={720}
footer={
<>
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
{mode === 'create' ? t('common.add') : t('common.save')}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
{/* Preset chips */}
{mode === 'create' && (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.preset')}</label>
<div className="flex flex-wrap gap-2">
{availablePresets.map((preset) => (
<button
key={preset.id}
onClick={() => handlePresetChange(preset)}
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
selectedPreset.id === preset.id
? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{preset.name}
</button>
))}
</div>
</div>
)}
<Input label={t('settings.providers.name')} required value={name} onChange={(e) => setName(e.target.value)} placeholder={t('settings.providers.namePlaceholder')} />
<Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
{/* Base URL */}
{isCustom || mode === 'edit' ? (
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
) : (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.baseUrl')}</label>
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
{baseUrl}
</div>
</div>
)}
{/* API Format */}
{(isCustom || mode === 'edit') ? (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<select
value={apiFormat}
onChange={(e) => setApiFormat(e.target.value as ApiFormat)}
className="w-full text-sm px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
>
<option value="anthropic">{t('settings.providers.apiFormatAnthropic')}</option>
<option value="openai_chat">{t('settings.providers.apiFormatOpenaiChat')}</option>
<option value="openai_responses">{t('settings.providers.apiFormatOpenaiResponses')}</option>
</select>
{apiFormat !== 'anthropic' && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
)}
</div>
) : apiFormat !== 'anthropic' ? (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
{apiFormat === 'openai_chat' ? t('settings.providers.apiFormatOpenaiChat') : t('settings.providers.apiFormatOpenaiResponses')}
</div>
</div>
) : null}
<Input
label={mode === 'edit' ? t('settings.providers.apiKeyKeep') : t('settings.providers.apiKey')}
required={mode === 'create'}
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={mode === 'edit' ? '****' : 'sk-...'}
/>
{/* Model Mapping */}
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>
<div className="grid grid-cols-2 gap-2">
<Input label={t('settings.providers.mainModel')} required value={models.main} onChange={(e) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
<Input label={t('settings.providers.haikuModel')} value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.sonnetModel')} value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
<Input label={t('settings.providers.opusModel')} value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder={t('settings.providers.sameAsMain')} />
</div>
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
{t('settings.providers.testConnection')}
</Button>
{testResult && (
<div className="flex flex-col gap-0.5">
<span className={`text-xs ${testResult.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.connectivity.success
? t('settings.providers.connectivityOk', { latency: String(testResult.connectivity.latencyMs) })
: t('settings.providers.connectivityFailed', { error: testResult.connectivity.error || '' })}
</span>
{testResult.proxy && (
<span className={`text-xs ${testResult.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.proxy.success
? t('settings.providers.proxyOk', { latency: String(testResult.proxy.latencyMs) })
: t('settings.providers.proxyFailed', { error: testResult.proxy.error || '' })}
</span>
)}
</div>
)}
</div>
{/* Settings JSON — editable, shown for all presets including official */}
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.settingsJson')}</label>
<textarea
value={settingsJson}
onChange={(e) => {
const raw = e.target.value
setSettingsJson(raw)
try {
const parsed = JSON.parse(raw)
setSettingsJsonError(null)
// Auto-fill form fields from parsed JSON env
const env = parsed.env as Record<string, string> | undefined
if (env) {
if (env.ANTHROPIC_BASE_URL) {
setBaseUrl(env.ANTHROPIC_BASE_URL)
// Auto-switch to matching preset or Custom
if (mode === 'create') {
const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL)
const targetPreset = requirePreset(
matchedPreset ?? availablePresets.find((p) => p.id === 'custom'),
)
if (targetPreset.id !== selectedPreset.id) {
jsonPastedRef.current = true
setSelectedPreset(targetPreset)
}
}
}
if (env.ANTHROPIC_AUTH_TOKEN && env.ANTHROPIC_AUTH_TOKEN !== '(your API key)') setApiKey(env.ANTHROPIC_AUTH_TOKEN)
const newModels: Partial<ModelMapping> = {}
if (env.ANTHROPIC_MODEL) newModels.main = env.ANTHROPIC_MODEL
if (env.ANTHROPIC_DEFAULT_HAIKU_MODEL) newModels.haiku = env.ANTHROPIC_DEFAULT_HAIKU_MODEL
if (env.ANTHROPIC_DEFAULT_SONNET_MODEL) newModels.sonnet = env.ANTHROPIC_DEFAULT_SONNET_MODEL
if (env.ANTHROPIC_DEFAULT_OPUS_MODEL) newModels.opus = env.ANTHROPIC_DEFAULT_OPUS_MODEL
if (Object.keys(newModels).length > 0) {
setModels((prev) => ({ ...prev, ...newModels }))
}
}
} catch (err) {
setSettingsJsonError(err instanceof Error ? err.message : 'Invalid JSON')
}
}}
rows={16}
spellCheck={false}
className={`w-full text-xs px-3 py-3 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border font-mono leading-relaxed resize-y text-[var(--color-text-secondary)] outline-none ${
settingsJsonError
? 'border-[var(--color-error)] focus:border-[var(--color-error)]'
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)]'
}`}
/>
{settingsJsonError && (
<p className="text-[11px] text-[var(--color-error)] mt-1">{t('settings.providers.jsonError', { error: settingsJsonError })}</p>
)}
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.settingsJsonDesc')}</p>
</div>
</div>
</Modal>
)
}
// ─── Permission Settings ──────────────────────────────────────
function PermissionSettings() {
const { permissionMode, setPermissionMode } = useSettingsStore()
const t = useTranslation()
const MODES: Array<{ mode: PermissionMode; icon: string; label: string; desc: string }> = [
{ mode: 'default', icon: 'verified_user', label: t('settings.permissions.default'), desc: t('settings.permissions.defaultDesc') },
{ mode: 'acceptEdits', icon: 'edit_note', label: t('settings.permissions.acceptEdits'), desc: t('settings.permissions.acceptEditsDesc') },
{ mode: 'plan', icon: 'architecture', label: t('settings.permissions.plan'), desc: t('settings.permissions.planDesc') },
{ mode: 'bypassPermissions', icon: 'bolt', label: t('settings.permissions.bypass'), desc: t('settings.permissions.bypassDesc') },
]
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.permissions.title')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">{t('settings.permissions.description')}</p>
<div className="flex flex-col gap-2">
{MODES.map(({ mode, icon, label, desc }) => {
const isSelected = permissionMode === mode
return (
<button
key={mode}
onClick={() => setPermissionMode(mode)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
isSelected
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)]">{icon}</span>
<div className="flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{label}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{desc}</div>
</div>
{isSelected && (
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
)}
</button>
)
})}
</div>
</div>
)
}
// ─── General Settings ──────────────────────────────────────
function GeneralSettings() {
const {
effortLevel,
setEffort,
locale,
setLocale,
theme,
setTheme,
skipWebFetchPreflight,
setSkipWebFetchPreflight,
} = useSettingsStore()
const t = useTranslation()
const EFFORT_LABELS: Record<EffortLevel, string> = {
low: t('settings.general.effort.low'),
medium: t('settings.general.effort.medium'),
high: t('settings.general.effort.high'),
max: t('settings.general.effort.max'),
}
const LANGUAGES: Array<{ value: Locale; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
]
const THEMES: Array<{ value: ThemeMode; label: string }> = [
{ value: 'light', label: t('settings.general.appearance.light') },
{ value: 'dark', label: t('settings.general.appearance.dark') },
]
return (
<div className="max-w-xl">
{/* Appearance selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.appearanceTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.appearanceDescription')}</p>
<div className="flex gap-2 mb-8">
{THEMES.map(({ value, label }) => (
<button
key={value}
onClick={() => void setTheme(value)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
theme === value
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{label}
</button>
))}
</div>
{/* Language selector */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.languageTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.languageDescription')}</p>
<div className="flex gap-2 mb-8">
{LANGUAGES.map(({ value, label }) => (
<button
key={value}
onClick={() => setLocale(value)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
locale === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{label}
</button>
))}
</div>
{/* Effort Level */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.effortTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.effortDescription')}</p>
<div className="flex gap-2">
{(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
<button
key={level}
onClick={() => setEffort(level)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
effortLevel === level
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{EFFORT_LABELS[level]}
</button>
))}
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.webFetchPreflightEnabled')}
checked={skipWebFetchPreflight}
onChange={(e) => void setSkipWebFetchPreflight(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.webFetchPreflightEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{t('settings.general.webFetchPreflightHint')}
</div>
</div>
</label>
</div>
</div>
)
}
// ─── Agents Settings ──────────────────────────────────────
const AGENT_COLORS: Record<string, string> = {
red: '#ef4444',
orange: '#f97316',
yellow: '#eab308',
green: '#22c55e',
blue: '#3b82f6',
purple: '#a855f7',
pink: '#ec4899',
cyan: '#06b6d4',
}
const AGENT_SOURCE_ORDER: AgentSource[] = [
'userSettings',
'projectSettings',
'localSettings',
'policySettings',
'plugin',
'flagSettings',
'built-in',
]
function AgentsSettings() {
const {
activeAgents,
allAgents,
isLoading,
error,
selectedAgent,
fetchAgents,
selectAgent,
} = useAgentStore()
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const t = useTranslation()
const activeSession = sessions.find((s) => s.id === activeSessionId)
const currentWorkDir = activeSession?.workDir || undefined
useEffect(() => {
void fetchAgents(currentWorkDir)
}, [fetchAgents, currentWorkDir])
const groupedAgents = useMemo(() => {
const groups: Partial<Record<AgentSource, AgentDefinition[]>> = {}
for (const agent of allAgents) {
;(groups[agent.source] ??= []).push(agent)
}
return groups
}, [allAgents])
const sourceCount = AGENT_SOURCE_ORDER.filter((source) => (groupedAgents[source] ?? []).length > 0).length
if (selectedAgent) {
return (
<div className="w-full min-w-0">
<AgentDetailView agent={selectedAgent} onBack={() => selectAgent(null)} />
</div>
)
}
return (
<div className="w-full min-w-0">
{isLoading && allAgents.length === 0 ? (
<div className="flex justify-center py-12">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="text-center py-12 px-4">
<span className="material-symbols-outlined text-[40px] text-[var(--color-error)] mb-3 block">error_outline</span>
<p className="text-sm text-[var(--color-error)] mb-2">{error}</p>
<button
onClick={() => void fetchAgents(currentWorkDir)}
className="text-xs text-[var(--color-text-accent)] hover:underline"
>
{t('common.retry')}
</button>
</div>
) : allAgents.length === 0 ? (
<div className="text-center py-12 px-4 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-3 block">smart_toy</span>
<p className="text-sm text-[var(--color-text-secondary)] mb-1">{t('settings.agents.empty')}</p>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.agents.emptyHint')}</p>
</div>
) : (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.agents.browserEyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
smart_toy
</span>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('settings.agents.browserTitle')}
</h3>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
{t('settings.agents.description')}
</p>
</div>
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
<SummaryCard
label={t('settings.agents.summary.totalAgents')}
value={String(allAgents.length)}
icon="smart_toy"
/>
<SummaryCard
label={t('settings.agents.summary.activeAgents')}
value={String(activeAgents.length)}
icon="bolt"
/>
<SummaryCard
label={t('settings.agents.summary.sources')}
value={String(sourceCount)}
icon="layers"
className="col-span-2 sm:col-span-1"
/>
</div>
</div>
</section>
<div className={`grid gap-4 ${sourceCount >= 2 ? 'xl:grid-cols-2' : ''}`}>
{AGENT_SOURCE_ORDER.map((source) => {
const group = groupedAgents[source]
if (!group?.length) return null
const sourceLabel = t(`settings.agents.source.${source}`)
return (
<section
key={source}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden min-w-0"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${getAgentSourceAccentClass(source)}`}>
<span className="material-symbols-outlined text-[16px]">
{getAgentSourceIcon(source)}
</span>
</span>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{sourceLabel}
</h4>
<span className="text-xs text-[var(--color-text-tertiary)]">
{group.length}
</span>
</div>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.agents.groupHint', {
source: sourceLabel,
count: String(group.length),
})}
</p>
</div>
</div>
<div className="flex flex-col p-2">
{group.map((agent) => (
<button
key={`${agent.source}-${agent.agentType}`}
onClick={() => selectAgent(agent)}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
<div className="flex items-start gap-3">
<span
className="mt-0.5 flex-shrink-0 inline-flex items-center justify-center"
style={{ color: getAgentDotColor(agent.color) }}
>
<span className="material-symbols-outlined text-[18px]">smart_toy</span>
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-bold text-[var(--color-text-primary)] break-all">
{agent.agentType}
</span>
{agent.modelDisplay && (
<MetaPill>{agent.modelDisplay}</MetaPill>
)}
<MetaPill>{sourceLabel}</MetaPill>
<MetaPill>
{agent.isActive
? t('settings.agents.status.active')
: t('settings.agents.status.available')}
</MetaPill>
{agent.overriddenBy && (
<MetaPill>
{t('settings.agents.overriddenBy', {
source: t(`settings.agents.source.${agent.overriddenBy}`),
})}
</MetaPill>
)}
</div>
<div className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words [&_.prose]:text-xs [&_.prose]:leading-5 [&_.prose]:text-[var(--color-text-secondary)]">
<MarkdownRenderer
content={agent.description || t('settings.agents.noDescription')}
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>
{agent.tools?.length
? t('settings.agents.toolCount', { count: String(agent.tools.length) })
: t('settings.agents.noTools')}
</span>
{agent.baseDir && (
<span className="break-all">{agent.baseDir}</span>
)}
</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
chevron_right
</span>
</div>
</button>
))}
</div>
</section>
)
})}
</div>
</div>
)}
</div>
)
}
function AgentDetailView({ agent, onBack }: { agent: AgentDefinition; onBack: () => void }) {
const t = useTranslation()
const sourceLabel = t(`settings.agents.source.${agent.source}`)
return (
<div className="flex h-full min-h-0 flex-col gap-4 min-w-0">
<div>
<button
onClick={onBack}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
{t('settings.agents.backToList')}
</button>
</div>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.agents.entryEyebrow')}
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<span
className="h-3 w-3 rounded-full flex-shrink-0"
style={{ backgroundColor: getAgentDotColor(agent.color) }}
/>
<h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
{agent.agentType}
</h3>
<MetaPill>{sourceLabel}</MetaPill>
{agent.modelDisplay && <MetaPill>{agent.modelDisplay}</MetaPill>}
<MetaPill>
{agent.isActive
? t('settings.agents.status.active')
: t('settings.agents.status.available')}
</MetaPill>
{agent.overriddenBy && (
<MetaPill>
{t('settings.agents.overriddenByShort', {
source: t(`settings.agents.source.${agent.overriddenBy}`),
})}
</MetaPill>
)}
</div>
<div className="max-w-4xl text-sm leading-6 text-[var(--color-text-secondary)]">
<MarkdownRenderer
content={agent.description || t('settings.agents.noDescription')}
/>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-[var(--color-text-tertiary)]">
<span>
{agent.tools?.length
? t('settings.agents.toolCount', { count: String(agent.tools.length) })
: t('settings.agents.noTools')}
</span>
{agent.baseDir && <span className="break-all">{agent.baseDir}</span>}
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-2">
<DetailStat
label={t('settings.agents.summary.source')}
value={sourceLabel}
icon="layers"
/>
<DetailStat
label={t('settings.agents.summary.model')}
value={agent.modelDisplay || '—'}
icon="psychology"
/>
<DetailStat
label={t('settings.agents.summary.tools')}
value={String(agent.tools?.length ?? 0)}
icon="build"
/>
<DetailStat
label={t('settings.agents.summary.status')}
value={agent.isActive ? t('settings.agents.status.active') : t('settings.agents.status.available')}
icon="bolt"
/>
</div>
</div>
</section>
{agent.tools && agent.tools.length > 0 && (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
build
</span>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.agents.tools')}
</h4>
</div>
<div className="flex flex-wrap gap-2">
{agent.tools.map((tool) => (
<MetaPill key={tool}>{tool}</MetaPill>
))}
</div>
</section>
)}
<section className="flex flex-1 min-h-0 min-w-0 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-[var(--color-text-secondary)] break-all">
{agent.baseDir || sourceLabel}
</span>
</div>
<div className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('settings.agents.promptHint')}
</div>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
{t('settings.agents.systemPrompt')}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]">
{agent.systemPrompt ? (
<div className="px-6 py-5 lg:px-8">
<MarkdownRenderer
content={agent.systemPrompt}
variant="document"
className="mx-auto max-w-[72ch]"
/>
</div>
) : (
<div className="px-6 py-10 text-center">
<span className="material-symbols-outlined text-[32px] text-[var(--color-text-tertiary)] mb-2 block">
article
</span>
<p className="text-sm text-[var(--color-text-tertiary)]">
{t('settings.agents.noSystemPrompt')}
</p>
</div>
)}
</div>
</div>
</section>
</div>
)
}
function getAgentDotColor(color?: string) {
return color && AGENT_COLORS[color] ? AGENT_COLORS[color] : 'var(--color-text-tertiary)'
}
function getAgentSourceIcon(source: AgentSource) {
switch (source) {
case 'userSettings':
return 'person'
case 'projectSettings':
return 'folder'
case 'localSettings':
return 'folder_lock'
case 'policySettings':
return 'shield'
case 'plugin':
return 'extension'
case 'flagSettings':
return 'terminal'
case 'built-in':
return 'inventory_2'
}
}
function getAgentSourceAccentClass(source: AgentSource) {
switch (source) {
case 'userSettings':
return 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
case 'projectSettings':
return 'bg-[var(--color-success-container)] text-[var(--color-success)]'
case 'localSettings':
return 'bg-[var(--color-info-container)] text-[var(--color-info)]'
case 'policySettings':
return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
case 'plugin':
return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
case 'flagSettings':
return 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
case 'built-in':
return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
}
}
function MetaPill({ children }: { children: ReactNode }) {
return (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{children}
</span>
)
}
function SummaryCard({
label,
value,
icon,
className = '',
}: {
label: string
value: string
icon: string
className?: string
}) {
return (
<div className={`rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0 ${className}`}>
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
{value}
</div>
</div>
)
}
function DetailStat({
label,
value,
icon,
}: {
label: string
value: string
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px]">{icon}</span>
<span>{label}</span>
</div>
<div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
{value}
</div>
</div>
)
}
// ─── Skill Settings ──────────────────────────────────────
function SkillSettings() {
const selectedSkill = useSkillStore((s) => s.selectedSkill)
const t = useTranslation()
if (selectedSkill) {
return (
<div className="w-full min-w-0">
<SkillDetail />
</div>
)
}
return (
<div className="w-full min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
{t('settings.skills.title')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
{t('settings.skills.description')}
</p>
<SkillList />
</div>
)
}
function PluginSettings() {
const selectedPlugin = usePluginStore((s) => s.selectedPlugin)
const t = useTranslation()
if (selectedPlugin) {
return (
<div className="w-full min-w-0">
<PluginDetail />
</div>
)
}
return (
<div className="w-full min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
{t('settings.plugins.title')}
</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
{t('settings.plugins.description')}
</p>
<PluginList />
</div>
)
}
// ─── About Settings ──────────────────────────────────────
const GITHUB_REPO = 'https://github.com/NanmiCoder/cc-haha'
const AUTHOR_GITHUB = 'https://github.com/NanmiCoder'
const SOCIAL_LINKS = [
{ name: 'Bilibili', icon: '/icons/bilibili.svg', url: 'https://space.bilibili.com/434377496', label: '程序员阿江-Relakkes' },
{ name: 'Douyin', icon: '/icons/douyin.svg', url: 'https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE', label: '程序员阿江-Relakkes' },
{ name: 'Xiaohongshu', icon: '/icons/xiaohongshu.svg', url: 'https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753', label: '程序员阿江-Relakkes' },
] as const
function AboutSettings() {
const t = useTranslation()
const [version, setVersion] = useState('')
const updateStatus = useUpdateStore((s) => s.status)
const availableVersion = useUpdateStore((s) => s.availableVersion)
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
const progressPercent = useUpdateStore((s) => s.progressPercent)
const downloadedBytes = useUpdateStore((s) => s.downloadedBytes)
const totalBytes = useUpdateStore((s) => s.totalBytes)
const error = useUpdateStore((s) => s.error)
const checkedAt = useUpdateStore((s) => s.checkedAt)
const checkForUpdates = useUpdateStore((s) => s.checkForUpdates)
const installUpdate = useUpdateStore((s) => s.installUpdate)
const initialize = useUpdateStore((s) => s.initialize)
useEffect(() => {
import('@tauri-apps/api/app').then((mod) => mod.getVersion()).then(setVersion).catch(() => setVersion('0.1.0'))
}, [])
useEffect(() => {
void initialize()
}, [initialize])
const openUrl = (url: string) => {
import('@tauri-apps/plugin-shell').then((mod) => mod.open(url)).catch(() => window.open(url, '_blank'))
}
const checkedAtText =
checkedAt
? new Date(checkedAt).toLocaleString(undefined, {
hour: '2-digit',
minute: '2-digit',
month: 'short',
day: 'numeric',
})
: null
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
const downloadedText = formatBytes(downloadedBytes)
const updateDescription =
updateStatus === 'checking'
? t('update.checking')
: updateStatus === 'downloading'
? hasKnownProgress
? t('update.progress', { progress: String(progressPercent) })
: t('update.progressBytes', { downloaded: downloadedText })
: updateStatus === 'restarting'
? t('update.restarting')
: updateStatus === 'available' && availableVersion
? t('update.newVersion', { version: availableVersion })
: updateStatus === 'up-to-date'
? t('update.upToDate', { version: version || t('update.currentVersionUnknown') })
: error
? t('update.failed', { error })
: t('update.idle')
return (
<div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
{/* Logo + App Name + Version */}
<img src="/app-icon.png" alt="Claude Code Haha" className="w-20 h-20 rounded-2xl shadow-md mb-4" />
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Claude Code Haha</h1>
{version && (
<span className="text-xs text-[var(--color-text-tertiary)] mt-1">{t('settings.about.version')} {version}</span>
)}
{/* GitHub Repo */}
<div className="mt-6 w-full">
<button
onClick={() => openUrl(GITHUB_REPO)}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<img src="/icons/github.svg" alt="GitHub" className="w-5 h-5 opacity-70" />
<div className="flex-1 text-left">
<div className="text-sm font-medium text-[var(--color-text-primary)]">NanmiCoder/cc-haha</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{t('settings.about.starHint')}</div>
</div>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">open_in_new</span>
</button>
</div>
<div className="mt-4 w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.about.updates')}</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1">
{t('settings.about.updatesDesc')}
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void checkForUpdates()}
loading={updateStatus === 'checking'}
>
{t('update.checkNow')}
</Button>
</div>
<div className="mt-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
{t('settings.about.version')}
</div>
<div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
{version || t('update.currentVersionUnknown')}
</div>
</div>
{availableVersion && (
<div className="text-right">
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
{t('update.availableLabel')}
</div>
<div className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
{availableVersion}
</div>
</div>
)}
</div>
<p className={`mt-3 text-sm ${error ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
{updateDescription}
</p>
{checkedAtText && (
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{t('update.checkedAt', { time: checkedAtText })}
</p>
)}
{(updateStatus === 'downloading' || updateStatus === 'restarting') && (
<div className="mt-3">
<div className="h-1.5 bg-[var(--color-surface-container-low)] rounded-full overflow-hidden">
{hasKnownProgress || updateStatus === 'restarting' ? (
<div
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
) : (
<div className="h-full w-1/3 rounded-full bg-[var(--color-text-accent)]/75 animate-pulse" />
)}
</div>
{!hasKnownProgress && updateStatus === 'downloading' && downloadedBytes > 0 && (
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{downloadedText}
</p>
)}
</div>
)}
{releaseNotes && availableVersion && (
<div className="mt-3 rounded-lg bg-[var(--color-surface-container-low)] px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
{t('update.releaseNotes')}
</div>
<MarkdownRenderer
content={releaseNotes}
variant="document"
className="mt-2 text-[13px] leading-6 text-[var(--color-text-secondary)] [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_p]:text-[13px] [&_p]:leading-6"
/>
</div>
)}
{availableVersion && (
<div className="mt-3 flex justify-end">
<Button
size="sm"
onClick={() => void installUpdate()}
loading={updateStatus === 'downloading' || updateStatus === 'restarting'}
disabled={updateStatus === 'checking'}
>
{updateStatus === 'restarting' ? t('update.restarting') : t('update.now')}
</Button>
</div>
)}
</div>
</div>
{/* Divider */}
<div className="w-full border-t border-[var(--color-border)]/40 my-6" />
{/* Author */}
<div className="w-full">
<h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.author')}</h3>
<button
onClick={() => openUrl(AUTHOR_GITHUB)}
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<img src="/icons/github.svg" alt="GitHub" className="w-4 h-4 opacity-60" />
<span className="text-sm text-[var(--color-text-primary)]">-Relakkes</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto">GitHub</span>
</button>
</div>
{/* Social Media */}
<div className="w-full mt-4">
<h3 className="text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider mb-3">{t('settings.about.socialMedia')}</h3>
<div className="flex flex-col gap-0.5">
{SOCIAL_LINKS.map((link) => (
<button
key={link.name}
onClick={() => openUrl(link.url)}
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<img src={link.icon} alt={link.name} className="w-4 h-4 opacity-60" />
<span className="text-sm text-[var(--color-text-primary)]">{link.label}</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto">{link.name}</span>
</button>
))}
<button
onClick={() => openUrl('mailto:relakkes@gmail.com')}
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer"
>
<span className="material-symbols-outlined text-[16px] opacity-60">mail</span>
<span className="text-sm text-[var(--color-text-primary)]">relakkes@gmail.com</span>
<span className="text-xs text-[var(--color-text-tertiary)] ml-auto">Email</span>
</button>
</div>
</div>
</div>
)
}