diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx index b582c00d..dfa4b522 100644 --- a/ui/src/components/profile-editor.tsx +++ b/ui/src/components/profile-editor.tsx @@ -1,531 +1,16 @@ /** - * Profile Editor Component - * Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON) + * Profile Editor - Re-export from modular structure + * @deprecated Import from '@/components/profiles/editor' directly */ -import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { MaskedInput } from '@/components/ui/masked-input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Badge } from '@/components/ui/badge'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-react'; -import { toast } from 'sonner'; -import { CopyButton } from '@/components/ui/copy-button'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { GlobalEnvIndicator } from '@/components/global-env-indicator'; +/* eslint-disable react-refresh/only-export-components */ +export { + ProfileEditor, + EnvEditorSection, + InfoSection, + RawEditorSection, + useProfileEditor, + isSensitiveKey, +} from './profiles/editor'; -// Lazy load CodeEditor to reduce initial bundle size -const CodeEditor = lazy(() => - import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) -); - -interface Settings { - env?: Record; -} - -interface SettingsResponse { - profile: string; - settings: Settings; - mtime: number; - path: string; -} - -interface ProfileEditorProps { - profileName: string; - onDelete?: () => void; -} - -export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { - const [localEdits, setLocalEdits] = useState>({}); - const [conflictDialog, setConflictDialog] = useState(false); - const [rawJsonEdits, setRawJsonEdits] = useState(null); - const [newEnvKey, setNewEnvKey] = useState(''); - const queryClient = useQueryClient(); - - // Fetch settings for selected profile - const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ['settings', profileName], - queryFn: async () => { - const res = await fetch(`/api/settings/${profileName}/raw`); - if (!res.ok) { - throw new Error(`Failed to load settings: ${res.status}`); - } - return res.json(); - }, - }); - - // Derive raw JSON content - const settings = data?.settings; - const rawJsonContent = useMemo(() => { - if (rawJsonEdits !== null) { - return rawJsonEdits; - } - if (settings) { - return JSON.stringify(settings, null, 2); - } - return ''; - }, [rawJsonEdits, settings]); - - const handleRawJsonChange = useCallback((value: string) => { - setRawJsonEdits(value); - }, []); - - // Derive current settings by merging original data with local edits - // Prioritize rawJsonEdits if available - const currentSettings = useMemo((): Settings | undefined => { - if (rawJsonEdits !== null) { - try { - return JSON.parse(rawJsonEdits); - } catch { - // If invalid JSON, fall back to undefined or partial state - // The UI will likely show empty or potentially broken state if JSON is invalid, - // but the Raw Editor will show the error. - } - } - - if (!settings) return undefined; - return { - ...settings, - env: { - ...settings.env, - ...localEdits, - }, - }; - }, [settings, localEdits, rawJsonEdits]); - - // Sync Visual Editor changes to Raw JSON - const updateEnvValue = (key: string, value: string) => { - const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - - // Update local edits - setLocalEdits((prev) => ({ - ...prev, - [key]: value, - })); - - // Update rawJsonEdits to keep sync - const newSettings = { ...currentSettings, env: newEnv }; - setRawJsonEdits(JSON.stringify(newSettings, null, 2)); - }; - - const addNewEnvVar = () => { - if (!newEnvKey.trim()) return; - const key = newEnvKey.trim(); - const newEnv = { ...(currentSettings?.env || {}), [key]: '' }; - - setLocalEdits((prev) => ({ - ...prev, - [key]: '', - })); - - const newSettings = { ...currentSettings, env: newEnv }; - setRawJsonEdits(JSON.stringify(newSettings, null, 2)); - - setNewEnvKey(''); - }; - - // Check if raw JSON is valid - const isRawJsonValid = useMemo(() => { - try { - JSON.parse(rawJsonContent); - return true; - } catch { - return false; - } - }, [rawJsonContent]); - - // Check if there are unsaved changes - const hasChanges = useMemo(() => { - if (rawJsonEdits !== null) { - return rawJsonEdits !== JSON.stringify(settings, null, 2); - } - return Object.keys(localEdits).length > 0; - }, [rawJsonEdits, localEdits, settings]); - - // Save mutation - const saveMutation = useMutation({ - mutationFn: async () => { - let settingsToSave: Settings; - - try { - // Always save from rawJsonContent as it's the source of truth - settingsToSave = JSON.parse(rawJsonContent); - } catch { - // Fallback (should typically not happen if validation is correct) - settingsToSave = { - ...data?.settings, - env: { - ...data?.settings?.env, - ...localEdits, - }, - }; - } - - const res = await fetch(`/api/settings/${profileName}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - settings: settingsToSave, - expectedMtime: data?.mtime, - }), - }); - - if (res.status === 409) { - throw new Error('CONFLICT'); - } - - if (!res.ok) { - throw new Error('Failed to save'); - } - - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); - queryClient.invalidateQueries({ queryKey: ['profiles'] }); - setLocalEdits({}); - setRawJsonEdits(null); - toast.success('Settings saved'); - }, - onError: (error: Error) => { - if (error.message === 'CONFLICT') { - setConflictDialog(true); - } else { - toast.error(error.message); - } - }, - }); - - const handleSave = () => { - saveMutation.mutate(); - }; - - const handleConflictResolve = async (overwrite: boolean) => { - setConflictDialog(false); - if (overwrite) { - await refetch(); - saveMutation.mutate(); - } else { - setLocalEdits({}); - setRawJsonEdits(null); - } - }; - - const isSensitiveKey = (key: string): boolean => { - const sensitivePatterns = [ - /^ANTHROPIC_AUTH_TOKEN$/, - /_API_KEY$/, - /_AUTH_TOKEN$/, - /^API_KEY$/, - /^AUTH_TOKEN$/, - /_SECRET$/, - /^SECRET$/, - ]; - return sensitivePatterns.some((pattern) => pattern.test(key)); - }; - - // Reset state when profile changes - const profileKey = profileName; - - // Render Left Column Content (Environment + Info + Usage) - const renderFriendlyUI = () => ( -
- -
- - - Environment Variables - - - Info & Usage - - -
- -
- - {/* Scrollable Environment Variables List */} - -
- {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( - <> - {Object.entries(currentSettings.env).map(([key, value]) => ( -
- - {isSensitiveKey(key) ? ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm h-8" - /> - ) : ( - updateEnvValue(key, e.target.value)} - className="font-mono text-sm h-8" - /> - )} -
- ))} - - ) : ( -
-

No environment variables configured.

-

- Add variables using the input below or edit the JSON directly. -

-
- )} -
-
- - {/* Fixed Add Input at Bottom */} -
- -
- setNewEnvKey(e.target.value.toUpperCase())} - className="font-mono text-sm h-8" - onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} - /> - -
-
-
- - - -
- {/* Profile Information */} -
-

- - Profile Information -

-
- {data && ( - <> -
- Profile Name - {data.profile} -
-
- File Path -
- - {data.path} - - -
-
-
- Last Modified - {new Date(data.mtime).toLocaleString()} -
- - )} -
-
- - {/* Usage */} -
-

Quick Usage

-
-
- -
- - ccs {profileName} "prompt" - - -
-
-
- -
- - ccs default {profileName} - - -
-
-
-
-
-
-
-
-
-
- ); - - // Render Right Column Content (Raw JSON Editor) - const renderRawEditor = () => ( - - - Loading editor... - - } - > -
- {!isRawJsonValid && rawJsonEdits !== null && ( -
- - Invalid JSON syntax -
- )} -
-
- -
-
- {/* Global Env Indicator */} -
-
- -
-
-
-
- ); - - return ( -
- {/* Header */} -
-
-
-

{profileName}

- {data?.path && ( - - {data.path.replace(/^.*\//, '')} - - )} -
- {data && ( -

- Last modified: {new Date(data.mtime).toLocaleString()} -

- )} -
-
- - {onDelete && ( - - )} - -
-
- - {isLoading ? ( -
- - Loading settings... -
- ) : isError ? ( -
-
-

- Failed to load settings for this profile. -

- -
-
- ) : ( - // Split Layout (40% Left / 60% Right) -
- {/* Left Column: Friendly UI */} -
{renderFriendlyUI()}
- - {/* Right Column: Raw Editor */} -
-
- - - Raw Configuration (JSON) - -
- {renderRawEditor()} -
-
- )} - - handleConflictResolve(true)} - onCancel={() => handleConflictResolve(false)} - /> -
- ); -} +export type { Settings, SettingsResponse, ProfileEditorProps } from './profiles/editor'; diff --git a/ui/src/components/profiles/editor/env-editor-section.tsx b/ui/src/components/profiles/editor/env-editor-section.tsx new file mode 100644 index 00000000..2bee9534 --- /dev/null +++ b/ui/src/components/profiles/editor/env-editor-section.tsx @@ -0,0 +1,101 @@ +/** + * Environment Variables Editor Section + * Visual editor for profile environment variables + */ + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { MaskedInput } from '@/components/ui/masked-input'; +import { Plus } from 'lucide-react'; +import { isSensitiveKey } from './utils'; +import type { Settings } from './types'; + +interface EnvEditorSectionProps { + currentSettings: Settings | undefined; + newEnvKey: string; + onNewEnvKeyChange: (value: string) => void; + onEnvValueChange: (key: string, value: string) => void; + onAddEnvVar: () => void; +} + +export function EnvEditorSection({ + currentSettings, + newEnvKey, + onNewEnvKeyChange, + onEnvValueChange, + onAddEnvVar, +}: EnvEditorSectionProps) { + return ( + <> + {/* Scrollable Environment Variables List */} + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + <> + {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + onEnvValueChange(key, e.target.value)} + className="font-mono text-sm h-8" + /> + ) : ( + onEnvValueChange(key, e.target.value)} + className="font-mono text-sm h-8" + /> + )} +
+ ))} + + ) : ( +
+

No environment variables configured.

+

+ Add variables using the input below or edit the JSON directly. +

+
+ )} +
+
+ + {/* Fixed Add Input at Bottom */} +
+ +
+ onNewEnvKeyChange(e.target.value.toUpperCase())} + className="font-mono text-sm h-8" + onKeyDown={(e) => e.key === 'Enter' && onAddEnvVar()} + /> + +
+
+ + ); +} diff --git a/ui/src/components/profiles/editor/friendly-ui-section.tsx b/ui/src/components/profiles/editor/friendly-ui-section.tsx new file mode 100644 index 00000000..d9af4c00 --- /dev/null +++ b/ui/src/components/profiles/editor/friendly-ui-section.tsx @@ -0,0 +1,68 @@ +/** + * Friendly UI Section + * Left column with environment variables and info tabs + */ + +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { EnvEditorSection } from './env-editor-section'; +import { InfoSection } from './info-section'; +import type { Settings, SettingsResponse } from './types'; + +interface FriendlyUISectionProps { + profileName: string; + data: SettingsResponse | undefined; + currentSettings: Settings | undefined; + newEnvKey: string; + onNewEnvKeyChange: (key: string) => void; + onEnvValueChange: (key: string, value: string) => void; + onAddEnvVar: () => void; +} + +export function FriendlyUISection({ + profileName, + data, + currentSettings, + newEnvKey, + onNewEnvKeyChange, + onEnvValueChange, + onAddEnvVar, +}: FriendlyUISectionProps) { + return ( +
+ +
+ + + Environment Variables + + + Info & Usage + + +
+ +
+ + + + + + + +
+
+
+ ); +} diff --git a/ui/src/components/profiles/editor/header-section.tsx b/ui/src/components/profiles/editor/header-section.tsx new file mode 100644 index 00000000..db5d2466 --- /dev/null +++ b/ui/src/components/profiles/editor/header-section.tsx @@ -0,0 +1,75 @@ +/** + * Profile Editor Header Section + * Top header with profile name, badge, last modified, and action buttons + */ + +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Save, Loader2, Trash2, RefreshCw } from 'lucide-react'; + +interface HeaderSectionProps { + profileName: string; + data: { path?: string; mtime: number } | undefined; + isLoading: boolean; + isSaving: boolean; + hasChanges: boolean; + isRawJsonValid: boolean; + onRefresh: () => void; + onDelete?: () => void; + onSave: () => void; +} + +export function HeaderSection({ + profileName, + data, + isLoading, + isSaving, + hasChanges, + isRawJsonValid, + onRefresh, + onDelete, + onSave, +}: HeaderSectionProps) { + return ( +
+
+
+

{profileName}

+ {data?.path && ( + + {data.path.replace(/^.*\//, '')} + + )} +
+ {data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ )} +
+
+ + {onDelete && ( + + )} + +
+
+ ); +} diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx new file mode 100644 index 00000000..d15fb411 --- /dev/null +++ b/ui/src/components/profiles/editor/index.tsx @@ -0,0 +1,218 @@ +/** + * Profile Editor Component + * Inline editor for API profile settings with 2-column layout (Friendly UI + Raw JSON) + */ + +/* eslint-disable react-refresh/only-export-components */ +import { useState, useMemo, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/shared/confirm-dialog'; +import { Loader2, Code2, RefreshCw } from 'lucide-react'; +import { toast } from 'sonner'; + +import { HeaderSection } from './header-section'; +import { FriendlyUISection } from './friendly-ui-section'; +import { RawEditorSection } from './raw-editor-section'; +import type { ProfileEditorProps, Settings, SettingsResponse } from './types'; + +export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { + const [localEdits, setLocalEdits] = useState>({}); + const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [newEnvKey, setNewEnvKey] = useState(''); + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['settings', profileName], + queryFn: async () => { + const res = await fetch(`/api/settings/${profileName}/raw`); + if (!res.ok) throw new Error(`Failed to load settings: ${res.status}`); + return res.json(); + }, + }); + + const settings = data?.settings; + + // Derive current settings by merging original data with local edits + const currentSettings = useMemo((): Settings | undefined => { + if (rawJsonEdits !== null) { + try { + return JSON.parse(rawJsonEdits); + } catch { + // Fall back to settings merge + } + } + if (!settings) return undefined; + return { ...settings, env: { ...settings.env, ...localEdits } }; + }, [settings, localEdits, rawJsonEdits]); + + // Compute raw JSON content + const computedRawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) return rawJsonEdits; + if (settings) return JSON.stringify(settings, null, 2); + return ''; + }, [rawJsonEdits, settings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Sync Visual Editor changes to Raw JSON + const updateEnvValue = (key: string, value: string) => { + const newEnv = { ...(currentSettings?.env || {}), [key]: value }; + setLocalEdits((prev) => ({ ...prev, [key]: value })); + setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2)); + }; + + const addNewEnvVar = () => { + if (!newEnvKey.trim()) return; + const key = newEnvKey.trim(); + const newEnv = { ...(currentSettings?.env || {}), [key]: '' }; + setLocalEdits((prev) => ({ ...prev, [key]: '' })); + setRawJsonEdits(JSON.stringify({ ...currentSettings, env: newEnv }, null, 2)); + setNewEnvKey(''); + }; + + // Computed validity and changes check + const computedIsRawJsonValid = useMemo(() => { + try { + JSON.parse(computedRawJsonContent); + return true; + } catch { + return false; + } + }, [computedRawJsonContent]); + + const computedHasChanges = useMemo(() => { + if (rawJsonEdits !== null) return rawJsonEdits !== JSON.stringify(settings, null, 2); + return Object.keys(localEdits).length > 0; + }, [rawJsonEdits, localEdits, settings]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + try { + settingsToSave = JSON.parse(computedRawJsonContent); + } catch { + settingsToSave = { ...data?.settings, env: { ...data?.settings?.env, ...localEdits } }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: settingsToSave, expectedMtime: data?.mtime }), + }); + + if (res.status === 409) throw new Error('CONFLICT'); + if (!res.ok) throw new Error('Failed to save'); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + setLocalEdits({}); + setRawJsonEdits(null); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') setConflictDialog(true); + else toast.error(error.message); + }, + }); + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetch(); + saveMutation.mutate(); + } else { + setLocalEdits({}); + setRawJsonEdits(null); + } + }; + + return ( +
+ refetch()} + onDelete={onDelete} + onSave={() => saveMutation.mutate()} + /> + + {isLoading ? ( +
+ + Loading settings... +
+ ) : isError ? ( +
+
+

Failed to load settings.

+ +
+
+ ) : ( +
+
+ +
+
+
+ + + Raw Configuration (JSON) + +
+ +
+
+ )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} + +// Re-exports +export { EnvEditorSection } from './env-editor-section'; +export { InfoSection } from './info-section'; +export { RawEditorSection } from './raw-editor-section'; +export { HeaderSection } from './header-section'; +export { FriendlyUISection } from './friendly-ui-section'; +export { useProfileEditor } from './use-profile-editor'; +export { isSensitiveKey } from './utils'; +export type { Settings, SettingsResponse, ProfileEditorProps } from './types'; diff --git a/ui/src/components/profiles/editor/info-section.tsx b/ui/src/components/profiles/editor/info-section.tsx new file mode 100644 index 00000000..9e148a42 --- /dev/null +++ b/ui/src/components/profiles/editor/info-section.tsx @@ -0,0 +1,79 @@ +/** + * Profile Info Section + * Displays profile information and usage commands + */ + +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Label } from '@/components/ui/label'; +import { CopyButton } from '@/components/ui/copy-button'; +import { Info } from 'lucide-react'; +import type { SettingsResponse } from './types'; + +interface InfoSectionProps { + profileName: string; + data: SettingsResponse | undefined; +} + +export function InfoSection({ profileName, data }: InfoSectionProps) { + return ( + +
+ {/* Profile Information */} +
+

+ + Profile Information +

+
+ {data && ( + <> +
+ Profile Name + {data.profile} +
+
+ File Path +
+ + {data.path} + + +
+
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+ + )} +
+
+ + {/* Usage */} +
+

Quick Usage

+
+
+ +
+ + ccs {profileName} "prompt" + + +
+
+
+ +
+ + ccs default {profileName} + + +
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx new file mode 100644 index 00000000..318a3610 --- /dev/null +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -0,0 +1,66 @@ +/** + * Raw Editor Section + * JSON editor panel for profile settings + */ + +import { Suspense, lazy } from 'react'; +import { Loader2, X } from 'lucide-react'; +import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; +import type { Settings } from './types'; + +// Lazy load CodeEditor +const CodeEditor = lazy(() => + import('@/components/shared/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +interface RawEditorSectionProps { + rawJsonContent: string; + isRawJsonValid: boolean; + rawJsonEdits: string | null; + settings: Settings | undefined; + onChange: (value: string) => void; +} + +export function RawEditorSection({ + rawJsonContent, + isRawJsonValid, + rawJsonEdits, + settings, + onChange, +}: RawEditorSectionProps) { + return ( + + + Loading editor... + + } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+ {/* Global Env Indicator */} +
+
+ +
+
+
+
+ ); +} diff --git a/ui/src/components/profiles/editor/types.ts b/ui/src/components/profiles/editor/types.ts new file mode 100644 index 00000000..d7e36a13 --- /dev/null +++ b/ui/src/components/profiles/editor/types.ts @@ -0,0 +1,19 @@ +/** + * Types for Profile Editor + */ + +export interface Settings { + env?: Record; +} + +export interface SettingsResponse { + profile: string; + settings: Settings; + mtime: number; + path: string; +} + +export interface ProfileEditorProps { + profileName: string; + onDelete?: () => void; +} diff --git a/ui/src/components/profiles/editor/use-profile-editor.ts b/ui/src/components/profiles/editor/use-profile-editor.ts new file mode 100644 index 00000000..cceafb68 --- /dev/null +++ b/ui/src/components/profiles/editor/use-profile-editor.ts @@ -0,0 +1,139 @@ +/** + * Profile Editor Hook + * Query + mutation logic for profile settings + */ + +import { useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import type { Settings, SettingsResponse } from './types'; + +interface UseProfileEditorOptions { + profileName: string; + localEdits: Record; + rawJsonEdits: string | null; + rawJsonContent: string; + onSuccess: () => void; + onConflict: () => void; +} + +export function useProfileEditor({ + profileName, + localEdits, + rawJsonEdits, + rawJsonContent, + onSuccess, + onConflict, +}: UseProfileEditorOptions) { + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const query = useQuery({ + queryKey: ['settings', profileName], + queryFn: async () => { + const res = await fetch(`/api/settings/${profileName}/raw`); + if (!res.ok) { + throw new Error(`Failed to load settings: ${res.status}`); + } + return res.json(); + }, + }); + + // Derive current settings by merging original data with local edits + // eslint-disable-next-line react-hooks/preserve-manual-memoization -- Intentional: merge raw JSON edits over query data + const currentSettings = useMemo((): Settings | undefined => { + if (rawJsonEdits !== null) { + try { + return JSON.parse(rawJsonEdits); + } catch { + // If invalid JSON, fall back + } + } + + if (!query.data?.settings) return undefined; + return { + ...query.data.settings, + env: { + ...query.data.settings.env, + ...localEdits, + }, + }; + }, [query.data?.settings, localEdits, rawJsonEdits]); + + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check if there are unsaved changes + const hasChanges = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits !== JSON.stringify(query.data?.settings, null, 2); + } + return Object.keys(localEdits).length > 0; + }, [rawJsonEdits, localEdits, query.data?.settings]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + settingsToSave = { + ...query.data?.settings, + env: { + ...query.data?.settings?.env, + ...localEdits, + }, + }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: settingsToSave, + expectedMtime: query.data?.mtime, + }), + }); + + if (res.status === 409) { + throw new Error('CONFLICT'); + } + + if (!res.ok) { + throw new Error('Failed to save'); + } + + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + onSuccess(); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + onConflict(); + } else { + toast.error(error.message); + } + }, + }); + + return { + query, + currentSettings, + isRawJsonValid, + hasChanges, + saveMutation, + }; +} diff --git a/ui/src/components/profiles/editor/utils.ts b/ui/src/components/profiles/editor/utils.ts new file mode 100644 index 00000000..bbbd6cdb --- /dev/null +++ b/ui/src/components/profiles/editor/utils.ts @@ -0,0 +1,17 @@ +/** + * Utility functions for Profile Editor + */ + +/** Check if a key is considered sensitive (API keys, tokens, etc.) */ +export function isSensitiveKey(key: string): boolean { + const sensitivePatterns = [ + /^ANTHROPIC_AUTH_TOKEN$/, + /_API_KEY$/, + /_AUTH_TOKEN$/, + /^API_KEY$/, + /^AUTH_TOKEN$/, + /_SECRET$/, + /^SECRET$/, + ]; + return sensitivePatterns.some((pattern) => pattern.test(key)); +} diff --git a/ui/src/components/profiles/index.ts b/ui/src/components/profiles/index.ts new file mode 100644 index 00000000..366de67e --- /dev/null +++ b/ui/src/components/profiles/index.ts @@ -0,0 +1,14 @@ +/** + * Profiles Components Barrel Export + */ + +// Main profile components +export { ProfileCard } from './profile-card'; +export { ProfileCreateDialog } from './profile-create-dialog'; +export { ProfileDeck } from './profile-deck'; +export { ProfileDialog } from './profile-dialog'; +export { ProfilesTable } from './profiles-table'; + +// Profile editor (from subdirectory) +export { ProfileEditor } from './editor'; +export type { Settings, SettingsResponse, ProfileEditorProps } from './editor'; diff --git a/ui/src/components/profiles/profile-card.tsx b/ui/src/components/profiles/profile-card.tsx new file mode 100644 index 00000000..b760c392 --- /dev/null +++ b/ui/src/components/profiles/profile-card.tsx @@ -0,0 +1,58 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { SettingsIcon, PlayIcon } from 'lucide-react'; + +interface ProfileCardProps { + profile: { + name: string; + settingsPath: string; + configured: boolean; + isActive?: boolean; + lastUsed?: string; + model?: string; + }; + onSwitch?: () => void; + onConfig?: () => void; + onTest?: () => void; +} + +export function ProfileCard({ profile, onSwitch, onConfig, onTest }: ProfileCardProps) { + return ( + + +
+
+

{profile.name}

+ {profile.isActive && ( + + Active + + )} +
+ +
+
+ + {profile.model && ( +
Model: {profile.model}
+ )} + {profile.lastUsed && ( +
Last used: {profile.lastUsed}
+ )} +
+ + +
+
+
+ ); +} diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx new file mode 100644 index 00000000..802297b3 --- /dev/null +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -0,0 +1,347 @@ +/** + * Profile Create Dialog Component + * Modal dialog with tabbed interface for creating new API profiles + * Includes Quick Start templates and advanced model configuration + */ + +/* eslint-disable react-hooks/set-state-in-effect */ +import { useState, useEffect } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { useCreateProfile } from '@/hooks/use-profiles'; +import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; + +const schema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'), + baseUrl: z.string().url('Invalid URL format'), + apiKey: z.string().min(1, 'API key is required'), + model: z.string().optional(), + opusModel: z.string().optional(), + sonnetModel: z.string().optional(), + haikuModel: z.string().optional(), +}); + +type FormData = z.infer; + +interface ProfileCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: (name: string) => void; +} + +// Common URL mistakes to warn about +const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + +export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCreateDialogProps) { + const createMutation = useCreateProfile(); + const [activeTab, setActiveTab] = useState('basic'); + const [urlWarning, setUrlWarning] = useState(null); + const [showApiKey, setShowApiKey] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + control, + reset, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + baseUrl: '', + apiKey: '', + model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', + }, + }); + + const baseUrlValue = useWatch({ control, name: 'baseUrl' }); + + // Reset form when dialog opens + + useEffect(() => { + if (open) { + reset(); + setActiveTab('basic'); + setUrlWarning(null); + setShowApiKey(false); + } + }, [open, reset]); + + // Check for common URL mistakes + + useEffect(() => { + if (baseUrlValue) { + const lowerUrl = baseUrlValue.toLowerCase(); + for (const path of PROBLEMATIC_PATHS) { + if (lowerUrl.endsWith(path)) { + const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), ''); + setUrlWarning( + `URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}` + ); + return; + } + } + } + setUrlWarning(null); + }, [baseUrlValue]); + + const onSubmit = async (data: FormData) => { + try { + await createMutation.mutateAsync(data); + toast.success(`Profile "${data.name}" created`); + onSuccess(data.name); + onOpenChange(false); + } catch (error) { + toast.error((error as Error).message || 'Failed to create profile'); + } + }; + + const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey; + const hasModelErrors = + !!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel; + + return ( + + + + + + Create API Profile + + Configure a custom API endpoint for Claude Code. + + +
+ +
+ + + Basic Information + {hasBasicErrors && ( + + )} + + + Model Configuration + {hasModelErrors && ( + + )} + + +
+ +
+ +
+ {/* Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used in CLI:{' '} + + ccs my-api "prompt" + +

+ )} +
+ + {/* Base URL */} +
+ + + {errors.baseUrl ? ( +

{errors.baseUrl.message}

+ ) : urlWarning ? ( +
+ + {urlWarning} +
+ ) : ( +

+ The endpoint that accepts OpenAI-compatible and Anthropic requests +

+ )} +
+ + {/* API Key */} +
+ +
+ + +
+ {errors.apiKey && ( +

{errors.apiKey.message}

+ )} +
+
+
+ + +
+ +
+

Model Mapping

+

+ Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers + to the specific models supported by your API provider. +

+
+
+ +
+
+ + +

+ Fallback model if no specific tier is requested +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+ + + + + +
+
+
+
+ ); +} diff --git a/ui/src/components/profiles/profile-deck.tsx b/ui/src/components/profiles/profile-deck.tsx new file mode 100644 index 00000000..4a3255b0 --- /dev/null +++ b/ui/src/components/profiles/profile-deck.tsx @@ -0,0 +1,56 @@ +import { useProfiles } from '@/hooks/use-profiles'; +import { ProfileCard } from './profile-card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function ProfileDeck() { + const { data: response, isLoading, error } = useProfiles(); + + if (isLoading) { + return ( +
+ {[...Array(6)].map((_, i) => ( +
+ +
+ ))} +
+ ); + } + + if (error) { + return
Failed to load profiles: {error.message}
; + } + + const profiles = response?.profiles || []; + + if (!profiles || profiles.length === 0) { + return ( +
+ No profiles configured. Create your first profile to get started. +
+ ); + } + + // Use real profile data directly + const normalizedProfiles = profiles.map((profile) => ({ + ...profile, + configured: profile.configured ?? false, // Ensure configured is always boolean + })); + + return ( +
+

Profiles

+
+ {normalizedProfiles.map((profile) => ( + console.log('Switch to', profile.name)} + onConfig={() => console.log('Config', profile.name)} + onTest={() => console.log('Test', profile.name)} + /> + ))} +
+
+ ); +} diff --git a/ui/src/components/profiles/profile-dialog.tsx b/ui/src/components/profiles/profile-dialog.tsx new file mode 100644 index 00000000..f02ed87b --- /dev/null +++ b/ui/src/components/profiles/profile-dialog.tsx @@ -0,0 +1,226 @@ +/** + * Profile Dialog Component + * Phase 03: REST API Routes & CRUD + * Updated: Added model mapping fields for Opus/Sonnet/Haiku + */ + +/* eslint-disable react-hooks/set-state-in-effect */ +import { useState, useEffect } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles'; +import type { Profile } from '@/lib/api-client'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; + +const schema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid profile name'), + baseUrl: z.string().url('Invalid URL'), + apiKey: z.string().min(10, 'API key must be at least 10 characters'), + model: z.string().optional(), + opusModel: z.string().optional(), + sonnetModel: z.string().optional(), + haikuModel: z.string().optional(), +}); + +type FormData = z.infer; + +interface ProfileDialogProps { + open: boolean; + onClose: () => void; + profile?: Profile | null; +} + +export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { + const createMutation = useCreateProfile(); + const updateMutation = useUpdateProfile(); + const [showModelMapping, setShowModelMapping] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + control, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: profile + ? { + name: profile.name, + baseUrl: '', + apiKey: '', + model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', + } + : undefined, + }); + + // Watch model field to auto-expand model mapping when custom model is entered + const modelValue = useWatch({ control, name: 'model' }); + + useEffect(() => { + // Auto-show model mapping if user enters a custom model (not default) + if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') { + setShowModelMapping(true); + } + }, [modelValue]); + + // Reset state when dialog opens/closes + + useEffect(() => { + if (!open) { + setShowModelMapping(false); + } + }, [open]); + + const onSubmit = async (data: FormData) => { + try { + if (profile) { + // Update mode + await updateMutation.mutateAsync({ + name: profile.name, + data: { + baseUrl: data.baseUrl, + apiKey: data.apiKey, + model: data.model, + opusModel: data.opusModel, + sonnetModel: data.sonnetModel, + haikuModel: data.haikuModel, + }, + }); + } else { + // Create mode + await createMutation.mutateAsync(data); + } + reset(); + onClose(); + } catch (error) { + // Error is handled by the mutation hooks + console.error('Failed to save profile:', error); + } + }; + + return ( + + + + {profile ? 'Edit Profile' : 'Create API Profile'} + +
+
+ + + {errors.name && {errors.name.message}} +
+ +
+ + + {errors.baseUrl && ( + {errors.baseUrl.message} + )} +
+ +
+ + + {errors.apiKey && {errors.apiKey.message}} +
+ +
+ + +

+ Leave blank to use: {DEFAULT_MODEL} +

+
+ + {/* Model Mapping Section */} +
+ + + {showModelMapping && ( +
+

+ Configure different model IDs for each tier. Useful for API proxies that route + different model types to different backends. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/profiles/profiles-table.tsx b/ui/src/components/profiles/profiles-table.tsx new file mode 100644 index 00000000..c09ef990 --- /dev/null +++ b/ui/src/components/profiles/profiles-table.tsx @@ -0,0 +1,149 @@ +/** + * Profiles Table Component + * Phase 03: REST API Routes & CRUD + */ + +import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { MoreHorizontal, Trash2, Edit } from 'lucide-react'; +import { useDeleteProfile } from '@/hooks/use-profiles'; +import type { Profile } from '@/lib/api-client'; + +interface ProfilesTableProps { + data: Profile[]; + onEditSettings?: (profile: Profile) => void; +} + +export function ProfilesTable({ data, onEditSettings }: ProfilesTableProps) { + const deleteMutation = useDeleteProfile(); + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + size: 200, + }, + { + accessorKey: 'settingsPath', + header: 'Settings Path', + }, + { + accessorKey: 'configured', + header: 'Status', + size: 100, + cell: ({ row }) => ( + + {row.original.configured ? '[OK]' : '[!]'} + + ), + }, + { + id: 'actions', + header: 'Actions', + size: 100, + cell: ({ row }) => ( + + + + + + {onEditSettings && ( + onEditSettings(row.original)}> + + Edit + + )} + + deleteMutation.mutate(row.original.name)} + > + + Delete + + + + ), + }, + ]; + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + if (data.length === 0) { + return ( +
+ No profiles found. Create one to get started. +
+ ); + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isAction = header.id === 'actions'; + const isStatus = header.id === 'configured'; + const isName = header.id === 'name'; + + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +}