From 104a40414437a4f32492e4bcc33fdfbbec386e2f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 21 Dec 2025 03:26:40 -0500 Subject: [PATCH] refactor(ui): modularize settings page into directory structure - split settings.tsx (1,781 lines) into modular components - extract hooks into separate files (context-hooks, use-*-config) - create sections: websearch, proxy, globalenv with subcomponents - add tab-navigation and section-skeleton components - add settings-context.ts for state management --- ui/src/pages/settings.tsx | 1781 ----------------- .../settings/components/section-skeleton.tsx | 31 + .../settings/components/tab-navigation.tsx | 34 + ui/src/pages/settings/context.tsx | 19 + ui/src/pages/settings/hooks.ts | 13 + ui/src/pages/settings/hooks/context-hooks.ts | 157 ++ ui/src/pages/settings/hooks/index.ts | 10 + .../settings/hooks/use-globalenv-config.ts | 100 + .../pages/settings/hooks/use-proxy-config.ts | 117 ++ ui/src/pages/settings/hooks/use-raw-config.ts | 48 + .../pages/settings/hooks/use-settings-tab.ts | 23 + .../settings/hooks/use-websearch-config.ts | 137 ++ ui/src/pages/settings/index.tsx | 148 ++ .../settings/sections/globalenv-section.tsx | 222 ++ .../pages/settings/sections/proxy/index.tsx | 289 +++ .../sections/proxy/local-proxy-card.tsx | 66 + .../sections/proxy/remote-proxy-card.tsx | 184 ++ .../settings/sections/websearch/index.tsx | 226 +++ .../sections/websearch/provider-card.tsx | 172 ++ ui/src/pages/settings/settings-context.ts | 150 ++ ui/src/pages/settings/types.ts | 56 + 21 files changed, 2202 insertions(+), 1781 deletions(-) delete mode 100644 ui/src/pages/settings.tsx create mode 100644 ui/src/pages/settings/components/section-skeleton.tsx create mode 100644 ui/src/pages/settings/components/tab-navigation.tsx create mode 100644 ui/src/pages/settings/context.tsx create mode 100644 ui/src/pages/settings/hooks.ts create mode 100644 ui/src/pages/settings/hooks/context-hooks.ts create mode 100644 ui/src/pages/settings/hooks/index.ts create mode 100644 ui/src/pages/settings/hooks/use-globalenv-config.ts create mode 100644 ui/src/pages/settings/hooks/use-proxy-config.ts create mode 100644 ui/src/pages/settings/hooks/use-raw-config.ts create mode 100644 ui/src/pages/settings/hooks/use-settings-tab.ts create mode 100644 ui/src/pages/settings/hooks/use-websearch-config.ts create mode 100644 ui/src/pages/settings/index.tsx create mode 100644 ui/src/pages/settings/sections/globalenv-section.tsx create mode 100644 ui/src/pages/settings/sections/proxy/index.tsx create mode 100644 ui/src/pages/settings/sections/proxy/local-proxy-card.tsx create mode 100644 ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx create mode 100644 ui/src/pages/settings/sections/websearch/index.tsx create mode 100644 ui/src/pages/settings/sections/websearch/provider-card.tsx create mode 100644 ui/src/pages/settings/settings-context.ts create mode 100644 ui/src/pages/settings/types.ts diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx deleted file mode 100644 index 3d19327f..00000000 --- a/ui/src/pages/settings.tsx +++ /dev/null @@ -1,1781 +0,0 @@ -/** - * Settings Page - WebSearch, Global Env & Proxy Configuration - * Supports Gemini CLI and Grok CLI providers + Global Environment Variables + Proxy Settings - */ - -import { useState, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -import { Button } from '@/components/ui/button'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Switch } from '@/components/ui/switch'; -import { Input } from '@/components/ui/input'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Globe, - RefreshCw, - CheckCircle2, - AlertCircle, - FileCode, - Copy, - Check, - GripVertical, - Terminal, - ExternalLink, - ChevronDown, - ChevronUp, - Settings2, - Plus, - Trash2, - Server, - Laptop, - Cloud, - Wifi, - WifiOff, -} from 'lucide-react'; -import { CodeEditor } from '@/components/shared/code-editor'; -import { api } from '@/lib/api-client'; -import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; - -interface ProviderConfig { - enabled?: boolean; - model?: string; - timeout?: number; -} - -interface WebSearchProvidersConfig { - gemini?: ProviderConfig; - grok?: ProviderConfig; - opencode?: ProviderConfig; -} - -interface WebSearchConfig { - enabled: boolean; - providers?: WebSearchProvidersConfig; -} - -interface CliStatus { - installed: boolean; - path: string | null; - version: string | null; -} - -interface WebSearchStatus { - geminiCli: CliStatus; - grokCli: CliStatus; - opencodeCli: CliStatus; - readiness: { - status: 'ready' | 'unavailable'; - message: string; - }; -} - -interface GlobalEnvConfig { - enabled: boolean; - env: Record; -} - -export function SettingsPage() { - const [searchParams] = useSearchParams(); - const tabParam = searchParams.get('tab'); - const initialTab = - tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch'; - const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv' | 'proxy'>(initialTab); - const [config, setConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - const [status, setStatus] = useState(null); - const [statusLoading, setStatusLoading] = useState(true); - // Config viewer state - const [rawConfig, setRawConfig] = useState(null); - const [rawConfigLoading, setRawConfigLoading] = useState(false); - const [copied, setCopied] = useState(false); - // Local model input state (to avoid saving on every keystroke) - const [geminiModelInput, setGeminiModelInput] = useState(''); - const [opencodeModelInput, setOpencodeModelInput] = useState(''); - // Collapsible install hints state - const [showGeminiHint, setShowGeminiHint] = useState(false); - const [showOpencodeHint, setShowOpencodeHint] = useState(false); - const [showGrokHint, setShowGrokHint] = useState(false); - // Model save indicators (brief visual feedback) - const [geminiModelSaved, setGeminiModelSaved] = useState(false); - const [opencodeModelSaved, setOpencodeModelSaved] = useState(false); - // Global Env state - const [globalEnvConfig, setGlobalEnvConfig] = useState(null); - const [globalEnvLoading, setGlobalEnvLoading] = useState(true); - const [globalEnvSaving, setGlobalEnvSaving] = useState(false); - const [globalEnvError, setGlobalEnvError] = useState(null); - const [globalEnvSuccess, setGlobalEnvSuccess] = useState(false); - // New env var inputs - const [newEnvKey, setNewEnvKey] = useState(''); - const [newEnvValue, setNewEnvValue] = useState(''); - // Proxy state - const [proxyConfig, setCliproxyServerConfig] = useState(null); - const [proxyLoading, setProxyLoading] = useState(true); - const [proxySaving, setProxySaving] = useState(false); - const [proxyError, setProxyError] = useState(null); - const [proxySuccess, setProxySuccess] = useState(false); - const [testResult, setTestResult] = useState(null); - const [testing, setTesting] = useState(false); - // Proxy input state (lifted from ProxyContent to persist across tab switches) - const [proxyEditedHost, setProxyEditedHost] = useState(null); - const [proxyEditedPort, setProxyEditedPort] = useState(null); - const [proxyEditedAuthToken, setProxyEditedAuthToken] = useState(null); - const [proxyEditedLocalPort, setProxyEditedLocalPort] = useState(null); - - // Load config and status on mount - useEffect(() => { - fetchConfig(); - fetchStatus(); - fetchRawConfig(); - fetchGlobalEnvConfig(); - fetchCliproxyServerConfig(); - }, []); - - // Sync local model inputs when config changes - useEffect(() => { - if (config) { - setGeminiModelInput(config.providers?.gemini?.model ?? 'gemini-2.5-flash'); - setOpencodeModelInput(config.providers?.opencode?.model ?? 'opencode/grok-code'); - } - }, [config]); - - const fetchConfig = async () => { - try { - setLoading(true); - setError(null); - const res = await fetch('/api/websearch'); - if (!res.ok) throw new Error('Failed to load WebSearch config'); - const data = await res.json(); - setConfig(data); - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } - }; - - const fetchStatus = async () => { - try { - setStatusLoading(true); - const res = await fetch('/api/websearch/status'); - if (!res.ok) throw new Error('Failed to load status'); - const data = await res.json(); - setStatus(data); - } catch (err) { - console.error('Failed to fetch WebSearch status:', err); - } finally { - setStatusLoading(false); - } - }; - - const fetchRawConfig = async () => { - try { - setRawConfigLoading(true); - const res = await fetch('/api/config/raw'); - if (!res.ok) { - setRawConfig(null); - return; - } - const text = await res.text(); - setRawConfig(text); - } catch (err) { - console.error('Failed to fetch raw config:', err); - setRawConfig(null); - } finally { - setRawConfigLoading(false); - } - }; - - const fetchGlobalEnvConfig = async () => { - try { - setGlobalEnvLoading(true); - setGlobalEnvError(null); - const res = await fetch('/api/global-env'); - if (!res.ok) throw new Error('Failed to load Global Env config'); - const data = await res.json(); - setGlobalEnvConfig(data); - } catch (err) { - setGlobalEnvError((err as Error).message); - } finally { - setGlobalEnvLoading(false); - } - }; - - const fetchCliproxyServerConfig = async () => { - try { - setProxyLoading(true); - setProxyError(null); - const data = await api.cliproxyServer.get(); - setCliproxyServerConfig(data); - } catch (err) { - setProxyError((err as Error).message); - } finally { - setProxyLoading(false); - } - }; - - const copyToClipboard = async () => { - if (!rawConfig) return; - try { - await navigator.clipboard.writeText(rawConfig); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - - // Toggle Gemini provider - const toggleGemini = () => { - const providers = config?.providers || {}; - const currentState = providers.gemini?.enabled ?? false; - const grokState = providers.grok?.enabled ?? false; - const opencodeState = providers.opencode?.enabled ?? false; - - saveConfig({ - enabled: !currentState || grokState || opencodeState, // Enable WebSearch if any provider is enabled - providers: { - ...providers, - gemini: { - ...providers.gemini, - enabled: !currentState, - }, - }, - }); - }; - - const saveConfig = async (updates: Partial) => { - if (!config) return; - - // Optimistic update - apply changes immediately to local state - const optimisticConfig = { ...config, ...updates }; - setConfig(optimisticConfig); - - try { - setSaving(true); - setError(null); - - const res = await fetch('/api/websearch', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(optimisticConfig), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to save'); - } - - const data = await res.json(); - setConfig(data.websearch); - // Quick flash of success (shorter duration, less intrusive) - setSuccess(true); - setTimeout(() => setSuccess(false), 1500); - // Silently refresh raw config without loading state - fetch('/api/config/raw') - .then((r) => (r.ok ? r.text() : null)) - .then((text) => text && setRawConfig(text)) - .catch(() => {}); - } catch (err) { - // Revert optimistic update on error - setConfig(config); - setError((err as Error).message); - } finally { - setSaving(false); - } - }; - - const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false; - const isGrokEnabled = config?.providers?.grok?.enabled ?? false; - const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false; - - // Toggle Grok provider - const toggleGrok = () => { - const providers = config?.providers || {}; - const currentState = providers.grok?.enabled ?? false; - - saveConfig({ - enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, // Enable WebSearch if any provider is enabled - providers: { - ...providers, - grok: { - ...providers.grok, - enabled: !currentState, - }, - }, - }); - }; - - // Toggle OpenCode provider - const toggleOpenCode = () => { - const providers = config?.providers || {}; - const currentState = providers.opencode?.enabled ?? false; - - saveConfig({ - enabled: isGeminiEnabled || isGrokEnabled || !currentState, // Enable WebSearch if any provider is enabled - providers: { - ...providers, - opencode: { - ...providers.opencode, - enabled: !currentState, - }, - }, - }); - }; - - // Save Gemini model on blur (only if changed) - const saveGeminiModel = async () => { - const currentModel = config?.providers?.gemini?.model ?? 'gemini-2.5-flash'; - if (geminiModelInput !== currentModel) { - const providers = config?.providers || {}; - await saveConfig({ - providers: { - ...providers, - gemini: { - ...providers.gemini, - model: geminiModelInput, - }, - }, - }); - // Show saved indicator briefly - setGeminiModelSaved(true); - setTimeout(() => setGeminiModelSaved(false), 2000); - } - }; - - // Save OpenCode model on blur (only if changed) - const saveOpencodeModel = async () => { - const currentModel = config?.providers?.opencode?.model ?? 'opencode/grok-code'; - if (opencodeModelInput !== currentModel) { - const providers = config?.providers || {}; - await saveConfig({ - providers: { - ...providers, - opencode: { - ...providers.opencode, - model: opencodeModelInput, - }, - }, - }); - // Show saved indicator briefly - setOpencodeModelSaved(true); - setTimeout(() => setOpencodeModelSaved(false), 2000); - } - }; - - // Global Env functions - const saveGlobalEnvConfig = async (updates: Partial) => { - if (!globalEnvConfig) return; - - // Optimistic update - const optimisticConfig = { ...globalEnvConfig, ...updates }; - setGlobalEnvConfig(optimisticConfig); - - try { - setGlobalEnvSaving(true); - setGlobalEnvError(null); - - const res = await fetch('/api/global-env', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(optimisticConfig), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || 'Failed to save'); - } - - const data = await res.json(); - setGlobalEnvConfig(data.config); - setGlobalEnvSuccess(true); - setTimeout(() => setGlobalEnvSuccess(false), 1500); - // Silently refresh raw config - fetch('/api/config/raw') - .then((r) => (r.ok ? r.text() : null)) - .then((text) => text && setRawConfig(text)) - .catch(() => {}); - } catch (err) { - setGlobalEnvConfig(globalEnvConfig); - setGlobalEnvError((err as Error).message); - } finally { - setGlobalEnvSaving(false); - } - }; - - const toggleGlobalEnv = () => { - saveGlobalEnvConfig({ enabled: !globalEnvConfig?.enabled }); - }; - - const addEnvVar = () => { - if (!newEnvKey.trim() || !globalEnvConfig) return; - const newEnv = { ...globalEnvConfig.env, [newEnvKey.trim()]: newEnvValue }; - saveGlobalEnvConfig({ env: newEnv }); - setNewEnvKey(''); - setNewEnvValue(''); - }; - - const removeEnvVar = (key: string) => { - if (!globalEnvConfig) return; - const newEnv = { ...globalEnvConfig.env }; - delete newEnv[key]; - saveGlobalEnvConfig({ env: newEnv }); - }; - - const updateEnvValue = (key: string, value: string) => { - if (!globalEnvConfig) return; - const newEnv = { ...globalEnvConfig.env, [key]: value }; - saveGlobalEnvConfig({ env: newEnv }); - }; - - // Proxy functions - const saveCliproxyServerConfig = async (updates: Partial) => { - if (!proxyConfig) return; - - // Optimistic update - const optimisticConfig = { - remote: { ...proxyConfig.remote, ...updates.remote }, - fallback: { ...proxyConfig.fallback, ...updates.fallback }, - local: { ...proxyConfig.local, ...updates.local }, - }; - setCliproxyServerConfig(optimisticConfig); - setTestResult(null); // Clear previous test result on config change - - try { - setProxySaving(true); - setProxyError(null); - - const data = await api.cliproxyServer.update(updates); - setCliproxyServerConfig(data); - setProxySuccess(true); - setTimeout(() => setProxySuccess(false), 1500); - // Silently refresh raw config - fetch('/api/config/raw') - .then((r) => (r.ok ? r.text() : null)) - .then((text) => text && setRawConfig(text)) - .catch(() => {}); - } catch (err) { - setCliproxyServerConfig(proxyConfig); - setProxyError((err as Error).message); - } finally { - setProxySaving(false); - } - }; - - const handleTestConnection = async (params: { - host: string; - port: string; - protocol: 'http' | 'https'; - authToken: string; - }) => { - const { host, port, protocol, authToken } = params; - if (!host) { - setProxyError('Host is required'); - return; - } - - try { - setTesting(true); - setProxyError(null); - setTestResult(null); - - // Parse port - empty string means use protocol default - const portNum = port ? parseInt(port, 10) : undefined; - const result = await api.cliproxyServer.test({ - host, - port: portNum || undefined, // Empty/0 means use protocol default - protocol, - authToken: authToken || undefined, - }); - setTestResult(result); - } catch (err) { - setProxyError((err as Error).message); - } finally { - setTesting(false); - } - }; - - if (loading) { - return ( -
-
- - Loading configuration... -
-
- ); - } - - return ( -
- - {/* Left Panel - Settings Controls */} - -
- {/* Header with Tabs */} -
- setActiveTab(v as 'websearch' | 'globalenv' | 'proxy')} - > - - - - WebSearch - - - - Global Env - - - - Proxy - - - -
- - {/* Tab Content */} - {activeTab === 'websearch' ? ( - - ) : activeTab === 'globalenv' ? ( - - ) : ( - - )} -
-
- - {/* Resize Handle */} - - - - - {/* Right Panel - Config Viewer */} - -
- {/* Header */} -
-
- -
-

config.yaml

-

~/.ccs/config.yaml

-
-
-
- - -
-
- - {/* Config Content - scrollable */} -
- {rawConfigLoading ? ( -
- - Loading... -
- ) : rawConfig ? ( - {}} - language="yaml" - readonly - minHeight="auto" - className="min-h-full" - /> - ) : ( -
-
- -

Config file not found

- - ccs migrate - -
-
- )} -
-
-
-
-
- ); -} - -// WebSearch Tab Content Component -interface WebSearchContentProps { - config: WebSearchConfig | null; - status: WebSearchStatus | null; - statusLoading: boolean; - saving: boolean; - error: string | null; - success: boolean; - isGeminiEnabled: boolean; - isGrokEnabled: boolean; - isOpenCodeEnabled: boolean; - geminiModelInput: string; - opencodeModelInput: string; - geminiModelSaved: boolean; - opencodeModelSaved: boolean; - showGeminiHint: boolean; - showOpencodeHint: boolean; - showGrokHint: boolean; - setGeminiModelInput: (v: string) => void; - setOpencodeModelInput: (v: string) => void; - setShowGeminiHint: (v: boolean) => void; - setShowOpencodeHint: (v: boolean) => void; - setShowGrokHint: (v: boolean) => void; - toggleGemini: () => void; - toggleGrok: () => void; - toggleOpenCode: () => void; - saveGeminiModel: () => void; - saveOpencodeModel: () => void; - fetchStatus: () => void; - fetchConfig: () => void; - fetchRawConfig: () => void; - loading: boolean; -} - -function WebSearchContent({ - status, - statusLoading, - saving, - error, - success, - isGeminiEnabled, - isGrokEnabled, - isOpenCodeEnabled, - geminiModelInput, - opencodeModelInput, - geminiModelSaved, - opencodeModelSaved, - showGeminiHint, - showOpencodeHint, - showGrokHint, - setGeminiModelInput, - setOpencodeModelInput, - setShowGeminiHint, - setShowOpencodeHint, - setShowGrokHint, - toggleGemini, - toggleGrok, - toggleOpenCode, - saveGeminiModel, - saveOpencodeModel, - fetchStatus, - fetchConfig, - fetchRawConfig, - loading, -}: WebSearchContentProps) { - return ( - <> - {/* Toast-style alerts - absolute positioned, no layout shift */} -
- {error && ( - - - {error} - - )} - {success && ( -
- - Saved -
- )} -
- - {/* Scrollable Content */} - -
- {/* Description */} -

- CLI-based web search for third-party profiles (gemini, codex, agy, etc.) -

- - {/* Status Summary */} -
-
-

- {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} -

- {statusLoading ? ( -

Checking status...

- ) : status?.readiness ? ( -

{status.readiness.message}

- ) : null} -
- -
- - {/* CLI Providers */} -
-

Providers

- - {/* Gemini CLI Provider */} -
-
-
- -
-
-

gemini

- - FREE - - {status?.geminiCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

- Google Gemini CLI (1000 req/day free) -

-
-
- -
- {/* Model input when enabled */} - {isGeminiEnabled && ( -
-
- - setGeminiModelInput(e.target.value)} - onBlur={saveGeminiModel} - placeholder="gemini-2.5-flash" - className="h-8 text-sm font-mono" - disabled={saving} - /> - {geminiModelSaved && ( - - - Saved - - )} -
-
- )} - {/* Installation hint when not installed - inside card */} - {!status?.geminiCli?.installed && !statusLoading && ( -
- - {showGeminiHint && ( -
-

- Install globally (FREE tier available): -

- - npm install -g @google/gemini-cli - - - - View documentation - -
- )} -
- )} -
- - {/* OpenCode CLI Provider */} -
-
-
- -
-
-

opencode

- - FREE - - {status?.opencodeCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

OpenCode (web search via Zen)

-
-
- -
- {/* Model input when enabled */} - {isOpenCodeEnabled && ( -
-
- - setOpencodeModelInput(e.target.value)} - onBlur={saveOpencodeModel} - placeholder="opencode/grok-code" - className="h-8 text-sm font-mono" - disabled={saving} - /> - {opencodeModelSaved && ( - - - Saved - - )} -
-
- )} - {/* Installation hint when not installed - inside card */} - {!status?.opencodeCli?.installed && !statusLoading && ( -
- - {showOpencodeHint && ( -
-

- Install globally (FREE tier available): -

- - curl -fsSL https://opencode.ai/install | bash - - - - View documentation - -
- )} -
- )} -
- - {/* Grok CLI Provider */} -
-
-
- -
-
-

grok

- - GROK_API_KEY - - {status?.grokCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

xAI Grok CLI (web + X search)

-
-
- -
- {/* Installation hint when not installed - inside card */} - {!status?.grokCli?.installed && !statusLoading && ( -
- - {showGrokHint && ( -
-

- Install globally (requires xAI API key): -

- - npm install -g @vibe-kit/grok-cli - - - - View documentation - -
- )} -
- )} -
-
-
-
- - {/* Footer */} -
- -
- - ); -} - -// Global Env Tab Content Component -interface GlobalEnvContentProps { - config: GlobalEnvConfig | null; - loading: boolean; - saving: boolean; - error: string | null; - success: boolean; - newEnvKey: string; - newEnvValue: string; - setNewEnvKey: (v: string) => void; - setNewEnvValue: (v: string) => void; - toggleGlobalEnv: () => void; - addEnvVar: () => void; - removeEnvVar: (key: string) => void; - updateEnvValue: (key: string, value: string) => void; - fetchGlobalEnvConfig: () => void; - fetchRawConfig: () => void; -} - -function GlobalEnvContent({ - config, - loading, - saving, - error, - success, - newEnvKey, - newEnvValue, - setNewEnvKey, - setNewEnvValue, - toggleGlobalEnv, - addEnvVar, - removeEnvVar, - fetchGlobalEnvConfig, - fetchRawConfig, -}: GlobalEnvContentProps) { - if (loading) { - return ( -
-
- - Loading... -
-
- ); - } - - return ( - <> - {/* Toast-style alerts */} -
- {error && ( - - - {error} - - )} - {success && ( -
- - Saved -
- )} -
- - {/* Scrollable Content */} - -
- {/* Description */} -

- Environment variables injected into all non-Claude subscription profiles (gemini, codex, - agy, copilot, etc.) -

- - {/* Enable/Disable Toggle */} -
-
-

- {config?.enabled ? 'Global Env enabled' : 'Global Env disabled'} -

-

- {config?.enabled - ? 'Env vars will be injected into third-party profiles' - : 'Env vars will not be injected'} -

-
- -
- - {/* Current Environment Variables */} -
-

Environment Variables

- - {config?.env && Object.keys(config.env).length > 0 ? ( -
- {Object.entries(config.env).map(([key, value]) => ( -
- {key} - = - {value} - -
- ))} -
- ) : ( -
-

No environment variables configured

-
- )} - - {/* Add New Variable */} -
-

Add New Variable

-
- setNewEnvKey(e.target.value.toUpperCase())} - placeholder="KEY_NAME" - className="flex-1 font-mono text-sm h-9" - disabled={saving} - /> - = - setNewEnvValue(e.target.value)} - placeholder="value" - className="flex-1 font-mono text-sm h-9" - disabled={saving} - /> - -
-
- - {/* Common Variables Quick Add */} -
-

Quick Add Common Variables

-
- {[ - { key: 'DISABLE_BUG_COMMAND', value: '1' }, - { key: 'DISABLE_ERROR_REPORTING', value: '1' }, - { key: 'DISABLE_TELEMETRY', value: '1' }, - ].map( - ({ key, value }) => - !config?.env?.[key] && ( - - ) - )} - {config?.env && - ['DISABLE_BUG_COMMAND', 'DISABLE_ERROR_REPORTING', 'DISABLE_TELEMETRY'].every( - (k) => config.env[k] - ) && ( - - All common variables are configured - - )} -
-
-
-
-
- - {/* Footer */} -
- -
- - ); -} - -// Proxy Tab Content Component -interface ProxyContentProps { - config: CliproxyServerConfig | null; - loading: boolean; - saving: boolean; - error: string | null; - success: boolean; - testResult: RemoteProxyStatus | null; - testing: boolean; - saveCliproxyServerConfig: (updates: Partial) => void; - handleTestConnection: (params: { - host: string; - port: string; - protocol: 'http' | 'https'; - authToken: string; - }) => void; - fetchCliproxyServerConfig: () => void; - fetchRawConfig: () => void; - // Lifted input state for persistence across tab switches - editedHost: string | null; - setEditedHost: (value: string | null) => void; - editedPort: string | null; - setEditedPort: (value: string | null) => void; - editedAuthToken: string | null; - setEditedAuthToken: (value: string | null) => void; - editedLocalPort: string | null; - setEditedLocalPort: (value: string | null) => void; -} - -function ProxyContent({ - config, - loading, - saving, - error, - success, - testResult, - testing, - saveCliproxyServerConfig, - handleTestConnection, - fetchCliproxyServerConfig, - fetchRawConfig, - editedHost, - setEditedHost, - editedPort, - setEditedPort, - editedAuthToken, - setEditedAuthToken, - editedLocalPort, - setEditedLocalPort, -}: ProxyContentProps) { - // Default configs for fallback - const defaultRemote = { - enabled: false, - host: '', - port: undefined as number | undefined, - protocol: 'http' as const, - auth_token: '', - }; - const defaultFallback = { enabled: true, auto_start: true }; - const defaultLocal = { port: 8317, auto_start: true }; - - // Helper to get default port based on protocol - // HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL) - const getDefaultPort = (protocol: 'http' | 'https') => (protocol === 'https' ? 443 : 8317); - - // Sync local state with config (using refs to avoid lint warnings) - const hostInput = config?.remote.host ?? ''; - // Show empty string for port if undefined (will use protocol default) - const portInput = config?.remote.port !== undefined ? config.remote.port.toString() : ''; - const authTokenInput = config?.remote.auth_token ?? ''; - const localPortInput = (config?.local.port ?? 8317).toString(); - - // Get display values (edited or from config) - const displayHost = editedHost ?? hostInput; - const displayPort = editedPort ?? portInput; - const displayAuthToken = editedAuthToken ?? authTokenInput; - const displayLocalPort = editedLocalPort ?? localPortInput; - - if (loading) { - return ( -
-
- - Loading... -
-
- ); - } - - const isRemoteMode = config?.remote.enabled ?? false; - const remoteConfig = config?.remote ?? defaultRemote; - const fallbackConfig = config?.fallback ?? defaultFallback; - const localConfig = config?.local ?? defaultLocal; - - // Save functions for blur events - const saveHost = () => { - const value = editedHost ?? displayHost; - if (value !== config?.remote.host) { - saveCliproxyServerConfig({ remote: { ...remoteConfig, host: value } }); - } - setEditedHost(null); - }; - - const savePort = () => { - const portStr = editedPort ?? displayPort; - // Empty string means use protocol default (undefined) - const port = portStr === '' ? undefined : parseInt(portStr, 10); - const effectivePort = port && !isNaN(port) && port > 0 ? port : undefined; - - if (effectivePort !== config?.remote.port) { - saveCliproxyServerConfig({ remote: { ...remoteConfig, port: effectivePort } }); - } - setEditedPort(null); - }; - - const saveAuthToken = () => { - const value = editedAuthToken ?? displayAuthToken; - if (value !== config?.remote.auth_token) { - saveCliproxyServerConfig({ remote: { ...remoteConfig, auth_token: value } }); - } - setEditedAuthToken(null); - }; - - const saveLocalPort = () => { - const port = parseInt(editedLocalPort ?? displayLocalPort, 10); - if (!isNaN(port) && port !== config?.local.port) { - saveCliproxyServerConfig({ local: { ...localConfig, port } }); - } - setEditedLocalPort(null); - }; - - return ( - <> - {/* Toast-style alerts */} -
- {error && ( - - - {error} - - )} - {success && ( -
- - Saved -
- )} -
- - {/* Scrollable Content */} - -
- {/* Description */} -

- Configure local or remote CLIProxyAPI connection for proxy-based profiles -

- - {/* Mode Toggle - Card based selection */} -
-

Connection Mode

-
- {/* Local Mode Card */} - - - {/* Remote Mode Card */} - -
-
- - {/* Remote Settings - Show when remote mode is enabled */} - {isRemoteMode && ( -
-

- - Remote Server Configuration -

- - {/* Host */} -
- - setEditedHost(e.target.value)} - onBlur={saveHost} - placeholder="192.168.1.100 or proxy.example.com" - className="font-mono" - disabled={saving} - /> -
- - {/* Port and Protocol */} -
-
- - setEditedPort(e.target.value.replace(/\D/g, ''))} - onBlur={savePort} - placeholder={`Leave empty for ${getDefaultPort(config?.remote.protocol || 'http')}`} - className="font-mono" - disabled={saving} - /> -
-
- - -
-
- - {/* Auth Token */} -
- - setEditedAuthToken(e.target.value)} - onBlur={saveAuthToken} - placeholder="Bearer token for authentication" - className="font-mono" - disabled={saving} - /> -
- - {/* Test Connection */} -
- - - {/* Test Result */} - {testResult && ( -
-
- {testResult.reachable ? ( - <> - - - Connected ({testResult.latencyMs}ms) - - - ) : ( - <> - - - {testResult.error || 'Connection failed'} - - - )} -
-
- )} -
-
- )} - - {/* Fallback Settings */} -
-

Fallback Settings

-
- {/* Enable Fallback */} -
-
-

Enable fallback to local

-

- Use local proxy if remote is unreachable -

-
- - saveCliproxyServerConfig({ fallback: { ...fallbackConfig, enabled: checked } }) - } - disabled={saving || !isRemoteMode} - /> -
- - {/* Auto-start on fallback */} -
-
-

Auto-start local proxy

-

- Automatically start local proxy on fallback -

-
- - saveCliproxyServerConfig({ - fallback: { ...fallbackConfig, auto_start: checked }, - }) - } - disabled={saving || !isRemoteMode || !config?.fallback.enabled} - /> -
-
-
- - {/* Local Proxy Settings - Only show in Local mode */} - {!isRemoteMode && ( -
-

Local Proxy

-
- {/* Port */} -
- - setEditedLocalPort(e.target.value)} - onBlur={saveLocalPort} - placeholder="8317" - className="font-mono max-w-32" - disabled={saving} - /> -
- - {/* Auto-start */} -
-
-

Auto-start

-

- Start local proxy automatically when needed -

-
- - saveCliproxyServerConfig({ local: { ...localConfig, auto_start: checked } }) - } - disabled={saving} - /> -
-
-
- )} -
-
- - {/* Footer */} -
- -
- - ); -} diff --git a/ui/src/pages/settings/components/section-skeleton.tsx b/ui/src/pages/settings/components/section-skeleton.tsx new file mode 100644 index 00000000..33391159 --- /dev/null +++ b/ui/src/pages/settings/components/section-skeleton.tsx @@ -0,0 +1,31 @@ +/** + * Section Skeleton Component + * Loading placeholder for lazy-loaded settings sections + */ + +import { Skeleton } from '@/components/ui/skeleton'; + +export function SectionSkeleton() { + return ( +
+ +
+
+
+ + +
+ +
+
+
+ +
+ + + +
+
+
+ ); +} diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx new file mode 100644 index 00000000..0755e437 --- /dev/null +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -0,0 +1,34 @@ +/** + * Tab Navigation Component + * Settings page tab switcher with icons + */ + +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Globe, Settings2, Server } from 'lucide-react'; +import type { SettingsTab } from '../types'; + +interface TabNavigationProps { + activeTab: SettingsTab; + onTabChange: (tab: SettingsTab) => void; +} + +export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { + return ( + onTabChange(v as SettingsTab)}> + + + + WebSearch + + + + Global Env + + + + Proxy + + + + ); +} diff --git a/ui/src/pages/settings/context.tsx b/ui/src/pages/settings/context.tsx new file mode 100644 index 00000000..2cc9a8b5 --- /dev/null +++ b/ui/src/pages/settings/context.tsx @@ -0,0 +1,19 @@ +/** + * Settings Provider Component + * React component that provides settings context + */ + +import { useReducer, type ReactNode } from 'react'; +import { SettingsContext, settingsReducer, initialSettingsState } from './settings-context'; + +interface SettingsProviderProps { + children: ReactNode; +} + +export function SettingsProvider({ children }: SettingsProviderProps) { + const [state, dispatch] = useReducer(settingsReducer, initialSettingsState); + + return ( + {children} + ); +} diff --git a/ui/src/pages/settings/hooks.ts b/ui/src/pages/settings/hooks.ts new file mode 100644 index 00000000..3b9c20d8 --- /dev/null +++ b/ui/src/pages/settings/hooks.ts @@ -0,0 +1,13 @@ +/** + * Settings Hooks - Re-export from hooks directory + */ + +export { + useSettingsContext, + useSettingsActions, + useSettingsTab, + useWebSearchConfig, + useGlobalEnvConfig, + useProxyConfig, + useRawConfig, +} from './hooks/index'; diff --git a/ui/src/pages/settings/hooks/context-hooks.ts b/ui/src/pages/settings/hooks/context-hooks.ts new file mode 100644 index 00000000..eb7aea2f --- /dev/null +++ b/ui/src/pages/settings/hooks/context-hooks.ts @@ -0,0 +1,157 @@ +/** + * Context Hooks + * Hooks for accessing and updating settings context state + */ + +import { useCallback, useContext } from 'react'; +import { SettingsContext } from '../settings-context'; +import type { + WebSearchConfig, + GlobalEnvConfig, + CliproxyServerConfig, + WebSearchStatus, + RemoteProxyStatus, +} from '../types'; + +export function useSettingsContext() { + const context = useContext(SettingsContext); + if (!context) { + throw new Error('useSettingsContext must be used within a SettingsProvider'); + } + return context; +} + +export function useSettingsActions() { + const { dispatch } = useSettingsContext(); + + const setWebSearchConfig = useCallback( + (config: WebSearchConfig | null) => dispatch({ type: 'SET_WEBSEARCH_CONFIG', payload: config }), + [dispatch] + ); + + const setWebSearchStatus = useCallback( + (status: WebSearchStatus | null) => dispatch({ type: 'SET_WEBSEARCH_STATUS', payload: status }), + [dispatch] + ); + + const setWebSearchLoading = useCallback( + (loading: boolean) => dispatch({ type: 'SET_WEBSEARCH_LOADING', payload: loading }), + [dispatch] + ); + + const setWebSearchStatusLoading = useCallback( + (loading: boolean) => dispatch({ type: 'SET_WEBSEARCH_STATUS_LOADING', payload: loading }), + [dispatch] + ); + + const setWebSearchSaving = useCallback( + (saving: boolean) => dispatch({ type: 'SET_WEBSEARCH_SAVING', payload: saving }), + [dispatch] + ); + + const setWebSearchError = useCallback( + (error: string | null) => dispatch({ type: 'SET_WEBSEARCH_ERROR', payload: error }), + [dispatch] + ); + + const setWebSearchSuccess = useCallback( + (success: boolean) => dispatch({ type: 'SET_WEBSEARCH_SUCCESS', payload: success }), + [dispatch] + ); + + const setGlobalEnvConfig = useCallback( + (config: GlobalEnvConfig | null) => dispatch({ type: 'SET_GLOBALENV_CONFIG', payload: config }), + [dispatch] + ); + + const setGlobalEnvLoading = useCallback( + (loading: boolean) => dispatch({ type: 'SET_GLOBALENV_LOADING', payload: loading }), + [dispatch] + ); + + const setGlobalEnvSaving = useCallback( + (saving: boolean) => dispatch({ type: 'SET_GLOBALENV_SAVING', payload: saving }), + [dispatch] + ); + + const setGlobalEnvError = useCallback( + (error: string | null) => dispatch({ type: 'SET_GLOBALENV_ERROR', payload: error }), + [dispatch] + ); + + const setGlobalEnvSuccess = useCallback( + (success: boolean) => dispatch({ type: 'SET_GLOBALENV_SUCCESS', payload: success }), + [dispatch] + ); + + const setProxyConfig = useCallback( + (config: CliproxyServerConfig | null) => + dispatch({ type: 'SET_PROXY_CONFIG', payload: config }), + [dispatch] + ); + + const setProxyLoading = useCallback( + (loading: boolean) => dispatch({ type: 'SET_PROXY_LOADING', payload: loading }), + [dispatch] + ); + + const setProxySaving = useCallback( + (saving: boolean) => dispatch({ type: 'SET_PROXY_SAVING', payload: saving }), + [dispatch] + ); + + const setProxyError = useCallback( + (error: string | null) => dispatch({ type: 'SET_PROXY_ERROR', payload: error }), + [dispatch] + ); + + const setProxySuccess = useCallback( + (success: boolean) => dispatch({ type: 'SET_PROXY_SUCCESS', payload: success }), + [dispatch] + ); + + const setProxyTestResult = useCallback( + (result: RemoteProxyStatus | null) => + dispatch({ type: 'SET_PROXY_TEST_RESULT', payload: result }), + [dispatch] + ); + + const setProxyTesting = useCallback( + (testing: boolean) => dispatch({ type: 'SET_PROXY_TESTING', payload: testing }), + [dispatch] + ); + + const setRawConfig = useCallback( + (config: string | null) => dispatch({ type: 'SET_RAW_CONFIG', payload: config }), + [dispatch] + ); + + const setRawConfigLoading = useCallback( + (loading: boolean) => dispatch({ type: 'SET_RAW_CONFIG_LOADING', payload: loading }), + [dispatch] + ); + + return { + setWebSearchConfig, + setWebSearchStatus, + setWebSearchLoading, + setWebSearchStatusLoading, + setWebSearchSaving, + setWebSearchError, + setWebSearchSuccess, + setGlobalEnvConfig, + setGlobalEnvLoading, + setGlobalEnvSaving, + setGlobalEnvError, + setGlobalEnvSuccess, + setProxyConfig, + setProxyLoading, + setProxySaving, + setProxyError, + setProxySuccess, + setProxyTestResult, + setProxyTesting, + setRawConfig, + setRawConfigLoading, + }; +} diff --git a/ui/src/pages/settings/hooks/index.ts b/ui/src/pages/settings/hooks/index.ts new file mode 100644 index 00000000..2797059d --- /dev/null +++ b/ui/src/pages/settings/hooks/index.ts @@ -0,0 +1,10 @@ +/** + * Settings Hooks Barrel Export + */ + +export { useSettingsContext, useSettingsActions } from './context-hooks'; +export { useSettingsTab } from './use-settings-tab'; +export { useWebSearchConfig } from './use-websearch-config'; +export { useGlobalEnvConfig } from './use-globalenv-config'; +export { useProxyConfig } from './use-proxy-config'; +export { useRawConfig } from './use-raw-config'; diff --git a/ui/src/pages/settings/hooks/use-globalenv-config.ts b/ui/src/pages/settings/hooks/use-globalenv-config.ts new file mode 100644 index 00000000..a865a9af --- /dev/null +++ b/ui/src/pages/settings/hooks/use-globalenv-config.ts @@ -0,0 +1,100 @@ +/** + * GlobalEnv Config Hook + */ + +import { useCallback, useState } from 'react'; +import { useSettingsContext, useSettingsActions } from './context-hooks'; +import type { GlobalEnvConfig } from '../types'; + +export function useGlobalEnvConfig() { + const { state } = useSettingsContext(); + const actions = useSettingsActions(); + const [newEnvKey, setNewEnvKey] = useState(''); + const [newEnvValue, setNewEnvValue] = useState(''); + + const fetchConfig = useCallback(async () => { + try { + actions.setGlobalEnvLoading(true); + actions.setGlobalEnvError(null); + const res = await fetch('/api/global-env'); + if (!res.ok) throw new Error('Failed to load Global Env config'); + const data = await res.json(); + actions.setGlobalEnvConfig(data); + } catch (err) { + actions.setGlobalEnvError((err as Error).message); + } finally { + actions.setGlobalEnvLoading(false); + } + }, [actions]); + + const saveConfig = useCallback( + async (updates: Partial) => { + const config = state.globalEnvConfig; + if (!config) return; + + const optimisticConfig = { ...config, ...updates }; + actions.setGlobalEnvConfig(optimisticConfig); + + try { + actions.setGlobalEnvSaving(true); + actions.setGlobalEnvError(null); + + const res = await fetch('/api/global-env', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(optimisticConfig), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to save'); + } + + const data = await res.json(); + actions.setGlobalEnvConfig(data.config); + actions.setGlobalEnvSuccess(true); + setTimeout(() => actions.setGlobalEnvSuccess(false), 1500); + } catch (err) { + actions.setGlobalEnvConfig(config); + actions.setGlobalEnvError((err as Error).message); + } finally { + actions.setGlobalEnvSaving(false); + } + }, + [state.globalEnvConfig, actions] + ); + + const addEnvVar = useCallback(() => { + if (!newEnvKey.trim() || !state.globalEnvConfig) return; + const newEnv = { ...state.globalEnvConfig.env, [newEnvKey.trim()]: newEnvValue }; + saveConfig({ env: newEnv }); + setNewEnvKey(''); + setNewEnvValue(''); + }, [newEnvKey, newEnvValue, state.globalEnvConfig, saveConfig]); + + const removeEnvVar = useCallback( + (key: string) => { + if (!state.globalEnvConfig) return; + const newEnv = { ...state.globalEnvConfig.env }; + delete newEnv[key]; + saveConfig({ env: newEnv }); + }, + [state.globalEnvConfig, saveConfig] + ); + + return { + config: state.globalEnvConfig, + loading: state.globalEnvLoading, + saving: state.globalEnvSaving, + error: state.globalEnvError, + success: state.globalEnvSuccess, + newEnvKey, + setNewEnvKey, + newEnvValue, + setNewEnvValue, + fetchConfig, + saveConfig, + addEnvVar, + removeEnvVar, + }; +} diff --git a/ui/src/pages/settings/hooks/use-proxy-config.ts b/ui/src/pages/settings/hooks/use-proxy-config.ts new file mode 100644 index 00000000..b9e9cb50 --- /dev/null +++ b/ui/src/pages/settings/hooks/use-proxy-config.ts @@ -0,0 +1,117 @@ +/** + * Proxy Config Hook + */ + +import { useCallback, useState } from 'react'; +import { api } from '@/lib/api-client'; +import { useSettingsContext, useSettingsActions } from './context-hooks'; +import type { CliproxyServerConfig } from '../types'; + +export function useProxyConfig() { + const { state } = useSettingsContext(); + const actions = useSettingsActions(); + const [editedHost, setEditedHost] = useState(null); + const [editedPort, setEditedPort] = useState(null); + const [editedAuthToken, setEditedAuthToken] = useState(null); + const [editedLocalPort, setEditedLocalPort] = useState(null); + + const fetchConfig = useCallback(async () => { + try { + actions.setProxyLoading(true); + actions.setProxyError(null); + const data = await api.cliproxyServer.get(); + actions.setProxyConfig(data); + } catch (err) { + actions.setProxyError((err as Error).message); + } finally { + actions.setProxyLoading(false); + } + }, [actions]); + + const saveConfig = useCallback( + async (updates: Partial) => { + const config = state.proxyConfig; + if (!config) return; + + const optimisticConfig = { + remote: { ...config.remote, ...updates.remote }, + fallback: { ...config.fallback, ...updates.fallback }, + local: { ...config.local, ...updates.local }, + }; + actions.setProxyConfig(optimisticConfig); + actions.setProxyTestResult(null); + + try { + actions.setProxySaving(true); + actions.setProxyError(null); + + const data = await api.cliproxyServer.update(updates); + actions.setProxyConfig(data); + actions.setProxySuccess(true); + setTimeout(() => actions.setProxySuccess(false), 1500); + } catch (err) { + actions.setProxyConfig(config); + actions.setProxyError((err as Error).message); + } finally { + actions.setProxySaving(false); + } + }, + [state.proxyConfig, actions] + ); + + const testConnection = useCallback( + async (params: { + host: string; + port: string; + protocol: 'http' | 'https'; + authToken: string; + }) => { + const { host, port, protocol, authToken } = params; + if (!host) { + actions.setProxyError('Host is required'); + return; + } + + try { + actions.setProxyTesting(true); + actions.setProxyError(null); + actions.setProxyTestResult(null); + + const portNum = port ? parseInt(port, 10) : undefined; + const result = await api.cliproxyServer.test({ + host, + port: portNum || undefined, + protocol, + authToken: authToken || undefined, + }); + actions.setProxyTestResult(result); + } catch (err) { + actions.setProxyError((err as Error).message); + } finally { + actions.setProxyTesting(false); + } + }, + [actions] + ); + + return { + config: state.proxyConfig, + loading: state.proxyLoading, + saving: state.proxySaving, + error: state.proxyError, + success: state.proxySuccess, + testResult: state.proxyTestResult, + testing: state.proxyTesting, + editedHost, + setEditedHost, + editedPort, + setEditedPort, + editedAuthToken, + setEditedAuthToken, + editedLocalPort, + setEditedLocalPort, + fetchConfig, + saveConfig, + testConnection, + }; +} diff --git a/ui/src/pages/settings/hooks/use-raw-config.ts b/ui/src/pages/settings/hooks/use-raw-config.ts new file mode 100644 index 00000000..3e6f9f0b --- /dev/null +++ b/ui/src/pages/settings/hooks/use-raw-config.ts @@ -0,0 +1,48 @@ +/** + * Raw Config Hook + */ + +import { useCallback, useState } from 'react'; +import { useSettingsContext, useSettingsActions } from './context-hooks'; + +export function useRawConfig() { + const { state } = useSettingsContext(); + const actions = useSettingsActions(); + const [copied, setCopied] = useState(false); + + const fetchRawConfig = useCallback(async () => { + try { + actions.setRawConfigLoading(true); + const res = await fetch('/api/config/raw'); + if (!res.ok) { + actions.setRawConfig(null); + return; + } + const text = await res.text(); + actions.setRawConfig(text); + } catch { + actions.setRawConfig(null); + } finally { + actions.setRawConfigLoading(false); + } + }, [actions]); + + const copyToClipboard = useCallback(async () => { + if (!state.rawConfig) return; + try { + await navigator.clipboard.writeText(state.rawConfig); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Silent fail + } + }, [state.rawConfig]); + + return { + rawConfig: state.rawConfig, + loading: state.rawConfigLoading, + copied, + fetchRawConfig, + copyToClipboard, + }; +} diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts new file mode 100644 index 00000000..444b13e3 --- /dev/null +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -0,0 +1,23 @@ +/** + * Settings Tab URL Sync Hook + */ + +import { useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import type { SettingsTab } from '../types'; + +export function useSettingsTab() { + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get('tab'); + const activeTab: SettingsTab = + tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch'; + + const setActiveTab = useCallback( + (tab: SettingsTab) => { + setSearchParams({ tab }, { replace: true }); + }, + [setSearchParams] + ); + + return { activeTab, setActiveTab }; +} diff --git a/ui/src/pages/settings/hooks/use-websearch-config.ts b/ui/src/pages/settings/hooks/use-websearch-config.ts new file mode 100644 index 00000000..6fc5e05b --- /dev/null +++ b/ui/src/pages/settings/hooks/use-websearch-config.ts @@ -0,0 +1,137 @@ +/** + * WebSearch Config Hook + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useSettingsContext, useSettingsActions } from './context-hooks'; +import type { WebSearchConfig } from '../types'; + +export function useWebSearchConfig() { + const { state } = useSettingsContext(); + const actions = useSettingsActions(); + const [geminiModelInput, setGeminiModelInput] = useState(''); + const [opencodeModelInput, setOpencodeModelInput] = useState(''); + const [geminiModelSaved, setGeminiModelSaved] = useState(false); + const [opencodeModelSaved, setOpencodeModelSaved] = useState(false); + + const fetchConfig = useCallback(async () => { + try { + actions.setWebSearchLoading(true); + actions.setWebSearchError(null); + const res = await fetch('/api/websearch'); + if (!res.ok) throw new Error('Failed to load WebSearch config'); + const data = await res.json(); + actions.setWebSearchConfig(data); + } catch (err) { + actions.setWebSearchError((err as Error).message); + } finally { + actions.setWebSearchLoading(false); + } + }, [actions]); + + const fetchStatus = useCallback(async () => { + try { + actions.setWebSearchStatusLoading(true); + const res = await fetch('/api/websearch/status'); + if (!res.ok) throw new Error('Failed to load status'); + const data = await res.json(); + actions.setWebSearchStatus(data); + } catch { + // Silent fail for status + } finally { + actions.setWebSearchStatusLoading(false); + } + }, [actions]); + + const saveConfig = useCallback( + async (updates: Partial) => { + const config = state.webSearchConfig; + if (!config) return; + + const optimisticConfig = { ...config, ...updates }; + actions.setWebSearchConfig(optimisticConfig); + + try { + actions.setWebSearchSaving(true); + actions.setWebSearchError(null); + + const res = await fetch('/api/websearch', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(optimisticConfig), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to save'); + } + + const data = await res.json(); + actions.setWebSearchConfig(data.websearch); + actions.setWebSearchSuccess(true); + setTimeout(() => actions.setWebSearchSuccess(false), 1500); + } catch (err) { + actions.setWebSearchConfig(config); + actions.setWebSearchError((err as Error).message); + } finally { + actions.setWebSearchSaving(false); + } + }, + [state.webSearchConfig, actions] + ); + + // Sync model inputs with config + useEffect(() => { + if (state.webSearchConfig) { + setGeminiModelInput(state.webSearchConfig.providers?.gemini?.model ?? 'gemini-2.5-flash'); + setOpencodeModelInput( + state.webSearchConfig.providers?.opencode?.model ?? 'opencode/grok-code' + ); + } + }, [state.webSearchConfig]); + + const saveGeminiModel = useCallback(async () => { + const currentModel = state.webSearchConfig?.providers?.gemini?.model ?? 'gemini-2.5-flash'; + if (geminiModelInput !== currentModel) { + const providers = state.webSearchConfig?.providers || {}; + await saveConfig({ + providers: { ...providers, gemini: { ...providers.gemini, model: geminiModelInput } }, + }); + setGeminiModelSaved(true); + setTimeout(() => setGeminiModelSaved(false), 2000); + } + }, [geminiModelInput, state.webSearchConfig, saveConfig]); + + const saveOpencodeModel = useCallback(async () => { + const currentModel = state.webSearchConfig?.providers?.opencode?.model ?? 'opencode/grok-code'; + if (opencodeModelInput !== currentModel) { + const providers = state.webSearchConfig?.providers || {}; + await saveConfig({ + providers: { ...providers, opencode: { ...providers.opencode, model: opencodeModelInput } }, + }); + setOpencodeModelSaved(true); + setTimeout(() => setOpencodeModelSaved(false), 2000); + } + }, [opencodeModelInput, state.webSearchConfig, saveConfig]); + + return { + config: state.webSearchConfig, + status: state.webSearchStatus, + loading: state.webSearchLoading, + statusLoading: state.webSearchStatusLoading, + saving: state.webSearchSaving, + error: state.webSearchError, + success: state.webSearchSuccess, + geminiModelInput, + setGeminiModelInput, + opencodeModelInput, + setOpencodeModelInput, + geminiModelSaved, + opencodeModelSaved, + fetchConfig, + fetchStatus, + saveConfig, + saveGeminiModel, + saveOpencodeModel, + }; +} diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx new file mode 100644 index 00000000..d658bc51 --- /dev/null +++ b/ui/src/pages/settings/index.tsx @@ -0,0 +1,148 @@ +/** + * Settings Page + * Main entry point with lazy-loaded sections and URL tab persistence + */ + +import { lazy, Suspense, startTransition, useEffect } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { Button } from '@/components/ui/button'; +import { RefreshCw, FileCode, Copy, Check, GripVertical } from 'lucide-react'; +import { CodeEditor } from '@/components/shared/code-editor'; +import { SettingsProvider } from './context'; +import { useSettingsTab, useRawConfig } from './hooks'; +import { TabNavigation } from './components/tab-navigation'; +import { SectionSkeleton } from './components/section-skeleton'; +import type { SettingsTab } from './types'; + +// Lazy-loaded sections +const WebSearchSection = lazy(() => import('./sections/websearch')); +const GlobalEnvSection = lazy(() => import('./sections/globalenv-section')); +const ProxySection = lazy(() => import('./sections/proxy')); + +// Inner component that uses context +function SettingsPageInner() { + const { activeTab, setActiveTab } = useSettingsTab(); + const { + rawConfig, + loading: rawConfigLoading, + copied, + fetchRawConfig, + copyToClipboard, + } = useRawConfig(); + + // Fetch raw config on mount + useEffect(() => { + fetchRawConfig(); + }, [fetchRawConfig]); + + const handleTabChange = (tab: SettingsTab) => { + startTransition(() => { + setActiveTab(tab); + }); + }; + + return ( +
+ + {/* Left Panel - Settings Controls */} + +
+ {/* Header with Tabs */} +
+ +
+ + {/* Tab Content */} + }> + {activeTab === 'websearch' && } + {activeTab === 'globalenv' && } + {activeTab === 'proxy' && } + +
+
+ + {/* Resize Handle */} + + + + + {/* Right Panel - Config Viewer */} + +
+ {/* Header */} +
+
+ +
+

config.yaml

+

~/.ccs/config.yaml

+
+
+
+ + +
+
+ + {/* Config Content - scrollable */} +
+ {rawConfigLoading ? ( +
+ + Loading... +
+ ) : rawConfig ? ( + {}} + language="yaml" + readonly + minHeight="auto" + className="min-h-full" + /> + ) : ( +
+
+ +

Config file not found

+ + ccs migrate + +
+
+ )} +
+
+
+
+
+ ); +} + +// Main export with context provider +export function SettingsPage() { + return ( + + + + ); +} diff --git a/ui/src/pages/settings/sections/globalenv-section.tsx b/ui/src/pages/settings/sections/globalenv-section.tsx new file mode 100644 index 00000000..f38b67fd --- /dev/null +++ b/ui/src/pages/settings/sections/globalenv-section.tsx @@ -0,0 +1,222 @@ +/** + * GlobalEnv Section + * Settings section for global environment variables + */ + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { RefreshCw, CheckCircle2, AlertCircle, Plus, Trash2 } from 'lucide-react'; +import { useGlobalEnvConfig, useRawConfig } from '../hooks'; + +export default function GlobalEnvSection() { + const { + config, + loading, + saving, + error, + success, + newEnvKey, + setNewEnvKey, + newEnvValue, + setNewEnvValue, + fetchConfig, + saveConfig, + addEnvVar, + removeEnvVar, + } = useGlobalEnvConfig(); + + const { fetchRawConfig } = useRawConfig(); + + // Load data on mount + useEffect(() => { + fetchConfig(); + fetchRawConfig(); + }, [fetchConfig, fetchRawConfig]); + + const toggleGlobalEnv = () => { + saveConfig({ enabled: !config?.enabled }); + }; + + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+

+ Environment variables injected into all non-Claude subscription profiles (gemini, codex, + agy, copilot, etc.) +

+ + {/* Enable/Disable Toggle */} +
+
+

+ {config?.enabled ? 'Global Env enabled' : 'Global Env disabled'} +

+

+ {config?.enabled + ? 'Env vars will be injected into third-party profiles' + : 'Env vars will not be injected'} +

+
+ +
+ + {/* Current Environment Variables */} +
+

Environment Variables

+ + {config?.env && Object.keys(config.env).length > 0 ? ( +
+ {Object.entries(config.env).map(([key, value]) => ( +
+ {key} + = + {value} + +
+ ))} +
+ ) : ( +
+

No environment variables configured

+
+ )} + + {/* Add New Variable */} +
+

Add New Variable

+
+ setNewEnvKey(e.target.value.toUpperCase())} + placeholder="KEY_NAME" + className="flex-1 font-mono text-sm h-9" + disabled={saving} + /> + = + setNewEnvValue(e.target.value)} + placeholder="value" + className="flex-1 font-mono text-sm h-9" + disabled={saving} + /> + +
+
+ + {/* Common Variables Quick Add */} +
+

Quick Add Common Variables

+
+ {[ + { key: 'DISABLE_BUG_COMMAND', value: '1' }, + { key: 'DISABLE_ERROR_REPORTING', value: '1' }, + { key: 'DISABLE_TELEMETRY', value: '1' }, + ].map( + ({ key, value }) => + !config?.env?.[key] && ( + + ) + )} + {config?.env && + ['DISABLE_BUG_COMMAND', 'DISABLE_ERROR_REPORTING', 'DISABLE_TELEMETRY'].every( + (k) => config.env[k] + ) && ( + + All common variables are configured + + )} +
+
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +} diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx new file mode 100644 index 00000000..365d7dfd --- /dev/null +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -0,0 +1,289 @@ +/** + * Proxy Section + * Settings section for CLIProxyAPI configuration (local/remote) + */ + +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Switch } from '@/components/ui/switch'; +import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud } from 'lucide-react'; +import { useProxyConfig, useRawConfig } from '../../hooks'; +import { LocalProxyCard } from './local-proxy-card'; +import { RemoteProxyCard } from './remote-proxy-card'; + +export default function ProxySection() { + const { + config, + loading, + saving, + error, + success, + testResult, + testing, + editedHost, + setEditedHost, + editedPort, + setEditedPort, + editedAuthToken, + setEditedAuthToken, + editedLocalPort, + setEditedLocalPort, + fetchConfig, + saveConfig, + testConnection, + } = useProxyConfig(); + + const { fetchRawConfig } = useRawConfig(); + + // Load data on mount + useEffect(() => { + fetchConfig(); + fetchRawConfig(); + }, [fetchConfig, fetchRawConfig]); + + if (loading || !config) { + return ( +
+
+ + Loading... +
+
+ ); + } + + const isRemoteMode = config.remote.enabled ?? false; + const remoteConfig = config.remote; + const fallbackConfig = config.fallback; + + // Get display values (edited or from config) + const hostInput = config.remote.host ?? ''; + const portInput = config.remote.port !== undefined ? config.remote.port.toString() : ''; + const authTokenInput = config.remote.auth_token ?? ''; + const localPortInput = (config.local.port ?? 8317).toString(); + + const displayHost = editedHost ?? hostInput; + const displayPort = editedPort ?? portInput; + const displayAuthToken = editedAuthToken ?? authTokenInput; + const displayLocalPort = editedLocalPort ?? localPortInput; + + // Save functions for blur events + const saveHost = () => { + const value = editedHost ?? displayHost; + if (value !== config.remote.host) { + saveConfig({ remote: { ...remoteConfig, host: value } }); + } + setEditedHost(null); + }; + + const savePort = () => { + const portStr = editedPort ?? displayPort; + const port = portStr === '' ? undefined : parseInt(portStr, 10); + const effectivePort = port && !isNaN(port) && port > 0 ? port : undefined; + + if (effectivePort !== config.remote.port) { + saveConfig({ remote: { ...remoteConfig, port: effectivePort } }); + } + setEditedPort(null); + }; + + const saveAuthToken = () => { + const value = editedAuthToken ?? displayAuthToken; + if (value !== config.remote.auth_token) { + saveConfig({ remote: { ...remoteConfig, auth_token: value } }); + } + setEditedAuthToken(null); + }; + + const saveLocalPort = () => { + const port = parseInt(editedLocalPort ?? displayLocalPort, 10); + if (!isNaN(port) && port !== config.local.port) { + saveConfig({ local: { ...config.local, port } }); + } + setEditedLocalPort(null); + }; + + const handleTestConnection = () => { + testConnection({ + host: displayHost, + port: displayPort, + protocol: config.remote.protocol || 'http', + authToken: displayAuthToken, + }); + }; + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+

+ Configure local or remote CLIProxyAPI connection for proxy-based profiles +

+ + {/* Mode Toggle - Card based selection */} +
+

Connection Mode

+
+ {/* Local Mode Card */} + + + {/* Remote Mode Card */} + +
+
+ + {/* Remote Settings - Show when remote mode is enabled */} + {isRemoteMode && ( + + )} + + {/* Fallback Settings */} +
+

Fallback Settings

+
+ {/* Enable Fallback */} +
+
+

Enable fallback to local

+

+ Use local proxy if remote is unreachable +

+
+ + saveConfig({ fallback: { ...fallbackConfig, enabled: checked } }) + } + disabled={saving || !isRemoteMode} + /> +
+ + {/* Auto-start on fallback */} +
+
+

Auto-start local proxy

+

+ Automatically start local proxy on fallback +

+
+ + saveConfig({ fallback: { ...fallbackConfig, auto_start: checked } }) + } + disabled={saving || !isRemoteMode || !fallbackConfig.enabled} + /> +
+
+
+ + {/* Local Proxy Settings - Only show in Local mode */} + {!isRemoteMode && ( + + )} +
+
+ + {/* Footer */} +
+ +
+ + ); +} diff --git a/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx b/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx new file mode 100644 index 00000000..88e9650b --- /dev/null +++ b/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx @@ -0,0 +1,66 @@ +/** + * Local Proxy Card + * Configuration card for local CLIProxyAPI settings + */ + +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import type { CliproxyServerConfig } from '../../types'; + +interface LocalProxyCardProps { + config: CliproxyServerConfig; + saving: boolean; + displayLocalPort: string; + setEditedLocalPort: (value: string | null) => void; + onSaveLocalPort: () => void; + onSaveConfig: (updates: Partial) => void; +} + +export function LocalProxyCard({ + config, + saving, + displayLocalPort, + setEditedLocalPort, + onSaveLocalPort, + onSaveConfig, +}: LocalProxyCardProps) { + const localConfig = config.local; + + return ( +
+

Local Proxy

+
+ {/* Port */} +
+ + setEditedLocalPort(e.target.value)} + onBlur={onSaveLocalPort} + placeholder="8317" + className="font-mono max-w-32" + disabled={saving} + /> +
+ + {/* Auto-start */} +
+
+

Auto-start

+

+ Start local proxy automatically when needed +

+
+ + onSaveConfig({ local: { ...localConfig, auto_start: checked } }) + } + disabled={saving} + /> +
+
+
+ ); +} diff --git a/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx b/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx new file mode 100644 index 00000000..29e26413 --- /dev/null +++ b/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx @@ -0,0 +1,184 @@ +/** + * Remote Proxy Card + * Configuration card for remote CLIProxyAPI settings + */ + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react'; +import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types'; + +interface RemoteProxyCardProps { + config: CliproxyServerConfig; + saving: boolean; + testing: boolean; + testResult: RemoteProxyStatus | null; + displayHost: string; + displayPort: string; + displayAuthToken: string; + setEditedHost: (value: string | null) => void; + setEditedPort: (value: string | null) => void; + setEditedAuthToken: (value: string | null) => void; + onSaveHost: () => void; + onSavePort: () => void; + onSaveAuthToken: () => void; + onSaveConfig: (updates: Partial) => void; + onTestConnection: () => void; +} + +export function RemoteProxyCard({ + config, + saving, + testing, + testResult, + displayHost, + displayPort, + displayAuthToken, + setEditedHost, + setEditedPort, + setEditedAuthToken, + onSaveHost, + onSavePort, + onSaveAuthToken, + onSaveConfig, + onTestConnection, +}: RemoteProxyCardProps) { + const remoteConfig = config.remote; + + // HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL) + const getDefaultPort = (protocol: 'http' | 'https') => (protocol === 'https' ? 443 : 8317); + + return ( +
+

+ + Remote Server Configuration +

+ + {/* Host */} +
+ + setEditedHost(e.target.value)} + onBlur={onSaveHost} + placeholder="192.168.1.100 or proxy.example.com" + className="font-mono" + disabled={saving} + /> +
+ + {/* Port and Protocol */} +
+
+ + setEditedPort(e.target.value.replace(/\D/g, ''))} + onBlur={onSavePort} + placeholder={`Leave empty for ${getDefaultPort(config.remote.protocol || 'http')}`} + className="font-mono" + disabled={saving} + /> +
+
+ + +
+
+ + {/* Auth Token */} +
+ + setEditedAuthToken(e.target.value)} + onBlur={onSaveAuthToken} + placeholder="Bearer token for authentication" + className="font-mono" + disabled={saving} + /> +
+ + {/* Test Connection */} +
+ + + {/* Test Result */} + {testResult && ( +
+
+ {testResult.reachable ? ( + <> + + + Connected ({testResult.latencyMs}ms) + + + ) : ( + <> + + + {testResult.error || 'Connection failed'} + + + )} +
+
+ )} +
+
+ ); +} diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx new file mode 100644 index 00000000..5891f6d2 --- /dev/null +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -0,0 +1,226 @@ +/** + * WebSearch Section + * Settings section for WebSearch providers (Gemini, OpenCode, Grok) + */ + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react'; +import { useWebSearchConfig, useRawConfig } from '../../hooks'; +import { ProviderCard } from './provider-card'; + +export default function WebSearchSection() { + const { + config, + status, + loading, + statusLoading, + saving, + error, + success, + geminiModelInput, + setGeminiModelInput, + opencodeModelInput, + setOpencodeModelInput, + geminiModelSaved, + opencodeModelSaved, + fetchConfig, + fetchStatus, + saveConfig, + saveGeminiModel, + saveOpencodeModel, + } = useWebSearchConfig(); + + const { fetchRawConfig } = useRawConfig(); + + // Collapsible install hints state + const [showGeminiHint, setShowGeminiHint] = useState(false); + const [showOpencodeHint, setShowOpencodeHint] = useState(false); + const [showGrokHint, setShowGrokHint] = useState(false); + + // Load data on mount + useEffect(() => { + fetchConfig(); + fetchStatus(); + fetchRawConfig(); + }, [fetchConfig, fetchStatus, fetchRawConfig]); + + const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false; + const isGrokEnabled = config?.providers?.grok?.enabled ?? false; + const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false; + + const toggleGemini = () => { + const providers = config?.providers || {}; + const currentState = providers.gemini?.enabled ?? false; + saveConfig({ + enabled: !currentState || isGrokEnabled || isOpenCodeEnabled, + providers: { ...providers, gemini: { ...providers.gemini, enabled: !currentState } }, + }); + }; + + const toggleGrok = () => { + const providers = config?.providers || {}; + const currentState = providers.grok?.enabled ?? false; + saveConfig({ + enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, + providers: { ...providers, grok: { ...providers.grok, enabled: !currentState } }, + }); + }; + + const toggleOpenCode = () => { + const providers = config?.providers || {}; + const currentState = providers.opencode?.enabled ?? false; + saveConfig({ + enabled: isGeminiEnabled || isGrokEnabled || !currentState, + providers: { ...providers, opencode: { ...providers.opencode, enabled: !currentState } }, + }); + }; + + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+

+ CLI-based web search for third-party profiles (gemini, codex, agy, etc.) +

+ + {/* Status Summary */} +
+
+

+ {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} +

+ {statusLoading ? ( +

Checking status...

+ ) : status?.readiness ? ( +

{status.readiness.message}

+ ) : null} +
+ +
+ + {/* CLI Providers */} +
+

Providers

+ + + + + + +
+
+
+ + {/* Footer */} +
+ +
+ + ); +} diff --git a/ui/src/pages/settings/sections/websearch/provider-card.tsx b/ui/src/pages/settings/sections/websearch/provider-card.tsx new file mode 100644 index 00000000..5792d680 --- /dev/null +++ b/ui/src/pages/settings/sections/websearch/provider-card.tsx @@ -0,0 +1,172 @@ +/** + * Provider Card Component + * Reusable card for CLI provider configuration in WebSearch section + */ + +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react'; + +export interface ProviderCardProps { + name: string; + label: string; + badge: string; + badgeColor: 'green' | 'blue' | 'purple'; + enabled: boolean; + installed: boolean; + statusLoading: boolean; + saving: boolean; + onToggle: () => void; + modelInput?: string; + setModelInput?: (v: string) => void; + onModelBlur?: () => void; + modelSaved?: boolean; + modelPlaceholder?: string; + showHint: boolean; + setShowHint: (v: boolean) => void; + installCmd: string; + docsUrl: string; + hintColor: 'amber' | 'blue' | 'purple'; +} + +export function ProviderCard({ + name, + label, + badge, + badgeColor, + enabled, + installed, + statusLoading, + saving, + onToggle, + modelInput, + setModelInput, + onModelBlur, + modelSaved, + modelPlaceholder, + showHint, + setShowHint, + installCmd, + docsUrl, + hintColor, +}: ProviderCardProps) { + const badgeClass = + badgeColor === 'green' + ? 'bg-green-500/10 text-green-600' + : badgeColor === 'blue' + ? 'bg-blue-500/10 text-blue-600' + : 'bg-purple-500/10 text-purple-600'; + + const hintBgClass = + hintColor === 'amber' + ? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300' + : hintColor === 'blue' + ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' + : 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300'; + + const hintCodeClass = + hintColor === 'amber' + ? 'bg-amber-100 dark:bg-amber-900/40' + : hintColor === 'blue' + ? 'bg-blue-100 dark:bg-blue-900/40' + : 'bg-purple-100 dark:bg-purple-900/40'; + + const hintTextClass = + hintColor === 'amber' + ? 'text-amber-600 dark:text-amber-400' + : hintColor === 'blue' + ? 'text-blue-600 dark:text-blue-400' + : 'text-purple-600 dark:text-purple-400'; + + const displayName = + name === 'opencode' ? 'OpenCode' : name.charAt(0).toUpperCase() + name.slice(1); + + return ( +
+
+
+ +
+
+

{name}

+ + {badge} + + {installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

{label}

+
+
+ +
+ + {/* Model input when enabled */} + {enabled && modelInput !== undefined && setModelInput && onModelBlur && ( +
+
+ + setModelInput(e.target.value)} + onBlur={onModelBlur} + placeholder={modelPlaceholder} + className="h-8 text-sm font-mono" + disabled={saving} + /> + {modelSaved && ( + + + Saved + + )} +
+
+ )} + + {/* Installation hint when not installed */} + {!installed && !statusLoading && ( +
+ + {showHint && ( +
+

+ Install globally{' '} + {badge === 'GROK_API_KEY' ? '(requires xAI API key)' : '(FREE tier available)'}: +

+ + {installCmd} + + + + View documentation + +
+ )} +
+ )} +
+ ); +} diff --git a/ui/src/pages/settings/settings-context.ts b/ui/src/pages/settings/settings-context.ts new file mode 100644 index 00000000..d0329ac8 --- /dev/null +++ b/ui/src/pages/settings/settings-context.ts @@ -0,0 +1,150 @@ +/** + * Settings Context Definition + * Context and types for settings page state management + */ + +import { createContext, type Dispatch } from 'react'; +import type { + WebSearchConfig, + GlobalEnvConfig, + CliproxyServerConfig, + WebSearchStatus, + RemoteProxyStatus, +} from './types'; + +// === State === + +export interface SettingsState { + // WebSearch state + webSearchConfig: WebSearchConfig | null; + webSearchStatus: WebSearchStatus | null; + webSearchLoading: boolean; + webSearchStatusLoading: boolean; + webSearchSaving: boolean; + webSearchError: string | null; + webSearchSuccess: boolean; + // GlobalEnv state + globalEnvConfig: GlobalEnvConfig | null; + globalEnvLoading: boolean; + globalEnvSaving: boolean; + globalEnvError: string | null; + globalEnvSuccess: boolean; + // Proxy state + proxyConfig: CliproxyServerConfig | null; + proxyLoading: boolean; + proxySaving: boolean; + proxyError: string | null; + proxySuccess: boolean; + proxyTestResult: RemoteProxyStatus | null; + proxyTesting: boolean; + // Raw config + rawConfig: string | null; + rawConfigLoading: boolean; +} + +export const initialSettingsState: SettingsState = { + webSearchConfig: null, + webSearchStatus: null, + webSearchLoading: true, + webSearchStatusLoading: true, + webSearchSaving: false, + webSearchError: null, + webSearchSuccess: false, + globalEnvConfig: null, + globalEnvLoading: true, + globalEnvSaving: false, + globalEnvError: null, + globalEnvSuccess: false, + proxyConfig: null, + proxyLoading: true, + proxySaving: false, + proxyError: null, + proxySuccess: false, + proxyTestResult: null, + proxyTesting: false, + rawConfig: null, + rawConfigLoading: false, +}; + +// === Actions === + +export type SettingsAction = + | { type: 'SET_WEBSEARCH_CONFIG'; payload: WebSearchConfig | null } + | { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null } + | { type: 'SET_WEBSEARCH_LOADING'; payload: boolean } + | { type: 'SET_WEBSEARCH_STATUS_LOADING'; payload: boolean } + | { type: 'SET_WEBSEARCH_SAVING'; payload: boolean } + | { type: 'SET_WEBSEARCH_ERROR'; payload: string | null } + | { type: 'SET_WEBSEARCH_SUCCESS'; payload: boolean } + | { type: 'SET_GLOBALENV_CONFIG'; payload: GlobalEnvConfig | null } + | { type: 'SET_GLOBALENV_LOADING'; payload: boolean } + | { type: 'SET_GLOBALENV_SAVING'; payload: boolean } + | { type: 'SET_GLOBALENV_ERROR'; payload: string | null } + | { type: 'SET_GLOBALENV_SUCCESS'; payload: boolean } + | { type: 'SET_PROXY_CONFIG'; payload: CliproxyServerConfig | null } + | { type: 'SET_PROXY_LOADING'; payload: boolean } + | { type: 'SET_PROXY_SAVING'; payload: boolean } + | { type: 'SET_PROXY_ERROR'; payload: string | null } + | { type: 'SET_PROXY_SUCCESS'; payload: boolean } + | { type: 'SET_PROXY_TEST_RESULT'; payload: RemoteProxyStatus | null } + | { type: 'SET_PROXY_TESTING'; payload: boolean } + | { type: 'SET_RAW_CONFIG'; payload: string | null } + | { type: 'SET_RAW_CONFIG_LOADING'; payload: boolean }; + +export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState { + switch (action.type) { + case 'SET_WEBSEARCH_CONFIG': + return { ...state, webSearchConfig: action.payload }; + case 'SET_WEBSEARCH_STATUS': + return { ...state, webSearchStatus: action.payload }; + case 'SET_WEBSEARCH_LOADING': + return { ...state, webSearchLoading: action.payload }; + case 'SET_WEBSEARCH_STATUS_LOADING': + return { ...state, webSearchStatusLoading: action.payload }; + case 'SET_WEBSEARCH_SAVING': + return { ...state, webSearchSaving: action.payload }; + case 'SET_WEBSEARCH_ERROR': + return { ...state, webSearchError: action.payload }; + case 'SET_WEBSEARCH_SUCCESS': + return { ...state, webSearchSuccess: action.payload }; + case 'SET_GLOBALENV_CONFIG': + return { ...state, globalEnvConfig: action.payload }; + case 'SET_GLOBALENV_LOADING': + return { ...state, globalEnvLoading: action.payload }; + case 'SET_GLOBALENV_SAVING': + return { ...state, globalEnvSaving: action.payload }; + case 'SET_GLOBALENV_ERROR': + return { ...state, globalEnvError: action.payload }; + case 'SET_GLOBALENV_SUCCESS': + return { ...state, globalEnvSuccess: action.payload }; + case 'SET_PROXY_CONFIG': + return { ...state, proxyConfig: action.payload }; + case 'SET_PROXY_LOADING': + return { ...state, proxyLoading: action.payload }; + case 'SET_PROXY_SAVING': + return { ...state, proxySaving: action.payload }; + case 'SET_PROXY_ERROR': + return { ...state, proxyError: action.payload }; + case 'SET_PROXY_SUCCESS': + return { ...state, proxySuccess: action.payload }; + case 'SET_PROXY_TEST_RESULT': + return { ...state, proxyTestResult: action.payload }; + case 'SET_PROXY_TESTING': + return { ...state, proxyTesting: action.payload }; + case 'SET_RAW_CONFIG': + return { ...state, rawConfig: action.payload }; + case 'SET_RAW_CONFIG_LOADING': + return { ...state, rawConfigLoading: action.payload }; + default: + return state; + } +} + +// === Context === + +export interface SettingsContextValue { + state: SettingsState; + dispatch: Dispatch; +} + +export const SettingsContext = createContext(null); diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts new file mode 100644 index 00000000..55ea8cfa --- /dev/null +++ b/ui/src/pages/settings/types.ts @@ -0,0 +1,56 @@ +/** + * Settings Page Types + * Type definitions for WebSearch, GlobalEnv, and Proxy configurations + */ + +import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; + +// === WebSearch Types === + +export interface ProviderConfig { + enabled?: boolean; + model?: string; + timeout?: number; +} + +export interface WebSearchProvidersConfig { + gemini?: ProviderConfig; + grok?: ProviderConfig; + opencode?: ProviderConfig; +} + +export interface WebSearchConfig { + enabled: boolean; + providers?: WebSearchProvidersConfig; +} + +export interface CliStatus { + installed: boolean; + path: string | null; + version: string | null; +} + +export interface WebSearchStatus { + geminiCli: CliStatus; + grokCli: CliStatus; + opencodeCli: CliStatus; + readiness: { + status: 'ready' | 'unavailable'; + message: string; + }; +} + +// === GlobalEnv Types === + +export interface GlobalEnvConfig { + enabled: boolean; + env: Record; +} + +// === Tab Types === + +export type SettingsTab = 'websearch' | 'globalenv' | 'proxy'; + +// === Re-exports from api-client === + +export type { CliproxyServerConfig, RemoteProxyStatus };