refactor(ui): split profile-editor into profiles/editor/ directory

- extract 531-line component into 10 focused modules

- move profile dialogs, card, deck, table to profiles/

- fix react-hook-form watch() using useWatch for compiler compat

- add barrel export in profiles/index.ts
This commit is contained in:
kaitranntt
2025-12-19 19:41:17 -05:00
parent 946030c836
commit 6778c4d637
16 changed files with 1644 additions and 527 deletions
+12 -527
View File
@@ -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<string, string>;
}
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<Record<string, string>>({});
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
const [newEnvKey, setNewEnvKey] = useState('');
const queryClient = useQueryClient();
// Fetch settings for selected profile
const { data, isLoading, isError, refetch } = useQuery<SettingsResponse>({
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 = () => (
<div className="h-full flex flex-col">
<Tabs defaultValue="env" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
<TabsTrigger value="env" className="flex-1">
Environment Variables
</TabsTrigger>
<TabsTrigger value="info" className="flex-1">
Info & Usage
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
<TabsContent
value="env"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
>
{/* Scrollable Environment Variables List */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
{currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
<>
{Object.entries(currentSettings.env).map(([key, value]) => (
<div key={key} className="space-y-1.5">
<Label className="text-xs font-medium flex items-center gap-2 text-muted-foreground">
{key}
{isSensitiveKey(key) && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
sensitive
</Badge>
)}
</Label>
{isSensitiveKey(key) ? (
<MaskedInput
value={value}
onChange={(e) => updateEnvValue(key, e.target.value)}
className="font-mono text-sm h-8"
/>
) : (
<Input
value={value}
onChange={(e) => updateEnvValue(key, e.target.value)}
className="font-mono text-sm h-8"
/>
)}
</div>
))}
</>
) : (
<div className="py-8 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed text-sm">
<p>No environment variables configured.</p>
<p className="text-xs mt-1 opacity-70">
Add variables using the input below or edit the JSON directly.
</p>
</div>
)}
</div>
</ScrollArea>
{/* Fixed Add Input at Bottom */}
<div className="p-4 border-t bg-background shrink-0">
<Label className="text-xs font-medium text-muted-foreground">
Add Environment Variable
</Label>
<div className="flex gap-2 mt-2">
<Input
placeholder="VARIABLE_NAME"
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
className="font-mono text-sm h-8"
onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()}
/>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={addNewEnvVar}
disabled={!newEnvKey.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
</TabsContent>
<TabsContent
value="info"
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
>
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
{/* Profile Information */}
<div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
Profile Information
</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
{data && (
<>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Profile Name</span>
<span className="font-mono">{data.profile}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span>
<div className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{data.path}
</code>
<CopyButton value={data.path} size="icon" className="h-5 w-5" />
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Last Modified</span>
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</div>
</>
)}
</div>
</div>
{/* Usage */}
<div>
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div>
<Label className="text-xs text-muted-foreground">Run with profile</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs {profileName} "prompt"
</code>
<CopyButton
value={`ccs ${profileName} "prompt"`}
size="icon"
className="h-6 w-6"
/>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Set as default</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs default {profileName}
</code>
<CopyButton
value={`ccs default ${profileName}`}
size="icon"
className="h-6 w-6"
/>
</div>
</div>
</div>
</div>
</div>
</ScrollArea>
</TabsContent>
</div>
</Tabs>
</div>
);
// Render Right Column Content (Raw JSON Editor)
const renderRawEditor = () => (
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading editor...</span>
</div>
}
>
<div className="h-full flex flex-col">
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
Invalid JSON syntax
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
onChange={handleRawJsonChange}
language="json"
minHeight="100%"
/>
</div>
</div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
<GlobalEnvIndicator profileEnv={settings?.env} />
</div>
</div>
</div>
</Suspense>
);
return (
<div key={profileKey} className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{profileName}</h2>
{data?.path && (
<Badge variant="outline" className="text-xs">
{data.path.replace(/^.*\//, '')}
</Badge>
)}
</div>
{data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => refetch()} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
{onDelete && (
<Button variant="ghost" size="sm" onClick={onDelete}>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
)}
<Button
size="sm"
onClick={handleSave}
disabled={saveMutation.isPending || !hasChanges || !isRawJsonValid}
>
{saveMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-1" />
Save
</>
)}
</Button>
</div>
</div>
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading settings...</span>
</div>
) : isError ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">
Failed to load settings for this profile.
</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />
Retry
</Button>
</div>
</div>
) : (
// Split Layout (40% Left / 60% Right)
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
{/* Left Column: Friendly UI */}
<div className="flex flex-col overflow-hidden bg-muted/5">{renderFriendlyUI()}</div>
{/* Right Column: Raw Editor */}
<div className="flex flex-col overflow-hidden">
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
Raw Configuration (JSON)
</span>
</div>
{renderRawEditor()}
</div>
</div>
)}
<ConfirmDialog
open={conflictDialog}
title="File Modified Externally"
description="This settings file was modified by another process. Overwrite with your changes or discard?"
confirmText="Overwrite"
variant="destructive"
onConfirm={() => handleConflictResolve(true)}
onCancel={() => handleConflictResolve(false)}
/>
</div>
);
}
export type { Settings, SettingsResponse, ProfileEditorProps } from './profiles/editor';
@@ -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 */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
{currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
<>
{Object.entries(currentSettings.env).map(([key, value]) => (
<div key={key} className="space-y-1.5">
<Label className="text-xs font-medium flex items-center gap-2 text-muted-foreground">
{key}
{isSensitiveKey(key) && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4">
sensitive
</Badge>
)}
</Label>
{isSensitiveKey(key) ? (
<MaskedInput
value={value}
onChange={(e) => onEnvValueChange(key, e.target.value)}
className="font-mono text-sm h-8"
/>
) : (
<Input
value={value}
onChange={(e) => onEnvValueChange(key, e.target.value)}
className="font-mono text-sm h-8"
/>
)}
</div>
))}
</>
) : (
<div className="py-8 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed text-sm">
<p>No environment variables configured.</p>
<p className="text-xs mt-1 opacity-70">
Add variables using the input below or edit the JSON directly.
</p>
</div>
)}
</div>
</ScrollArea>
{/* Fixed Add Input at Bottom */}
<div className="p-4 border-t bg-background shrink-0">
<Label className="text-xs font-medium text-muted-foreground">
Add Environment Variable
</Label>
<div className="flex gap-2 mt-2">
<Input
placeholder="VARIABLE_NAME"
value={newEnvKey}
onChange={(e) => onNewEnvKeyChange(e.target.value.toUpperCase())}
className="font-mono text-sm h-8"
onKeyDown={(e) => e.key === 'Enter' && onAddEnvVar()}
/>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={onAddEnvVar}
disabled={!newEnvKey.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
</>
);
}
@@ -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 (
<div className="h-full flex flex-col">
<Tabs defaultValue="env" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
<TabsTrigger value="env" className="flex-1">
Environment Variables
</TabsTrigger>
<TabsTrigger value="info" className="flex-1">
Info & Usage
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
<TabsContent
value="env"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
>
<EnvEditorSection
currentSettings={currentSettings}
newEnvKey={newEnvKey}
onNewEnvKeyChange={onNewEnvKeyChange}
onEnvValueChange={onEnvValueChange}
onAddEnvVar={onAddEnvVar}
/>
</TabsContent>
<TabsContent
value="info"
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
>
<InfoSection profileName={profileName} data={data} />
</TabsContent>
</div>
</Tabs>
</div>
);
}
@@ -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 (
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{profileName}</h2>
{data?.path && (
<Badge variant="outline" className="text-xs">
{data.path.replace(/^.*\//, '')}
</Badge>
)}
</div>
{data && (
<p className="text-xs text-muted-foreground mt-0.5">
Last modified: {new Date(data.mtime).toLocaleString()}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
{onDelete && (
<Button variant="ghost" size="sm" onClick={onDelete}>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
)}
<Button size="sm" onClick={onSave} disabled={isSaving || !hasChanges || !isRawJsonValid}>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-1" />
Save
</>
)}
</Button>
</div>
</div>
);
}
+218
View File
@@ -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<Record<string, string>>({});
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
const [newEnvKey, setNewEnvKey] = useState('');
const queryClient = useQueryClient();
// Fetch settings for selected profile
const { data, isLoading, isError, refetch } = useQuery<SettingsResponse>({
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 (
<div key={profileName} className="flex-1 flex flex-col overflow-hidden">
<HeaderSection
profileName={profileName}
data={data}
isLoading={isLoading}
isSaving={saveMutation.isPending}
hasChanges={computedHasChanges}
isRawJsonValid={computedIsRawJsonValid}
onRefresh={() => refetch()}
onDelete={onDelete}
onSave={() => saveMutation.mutate()}
/>
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">Loading settings...</span>
</div>
) : isError ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">Failed to load settings.</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />
Retry
</Button>
</div>
</div>
) : (
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
<div className="flex flex-col overflow-hidden bg-muted/5">
<FriendlyUISection
profileName={profileName}
data={data}
currentSettings={currentSettings}
newEnvKey={newEnvKey}
onNewEnvKeyChange={setNewEnvKey}
onEnvValueChange={updateEnvValue}
onAddEnvVar={addNewEnvVar}
/>
</div>
<div className="flex flex-col overflow-hidden">
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
Raw Configuration (JSON)
</span>
</div>
<RawEditorSection
rawJsonContent={computedRawJsonContent}
isRawJsonValid={computedIsRawJsonValid}
rawJsonEdits={rawJsonEdits}
settings={settings}
onChange={handleRawJsonChange}
/>
</div>
</div>
)}
<ConfirmDialog
open={conflictDialog}
title="File Modified Externally"
description="Overwrite with your changes or discard?"
confirmText="Overwrite"
variant="destructive"
onConfirm={() => handleConflictResolve(true)}
onCancel={() => handleConflictResolve(false)}
/>
</div>
);
}
// 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';
@@ -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 (
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
{/* Profile Information */}
<div>
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
Profile Information
</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
{data && (
<>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Profile Name</span>
<span className="font-mono">{data.profile}</span>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">File Path</span>
<div className="flex items-center gap-2 min-w-0">
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
{data.path}
</code>
<CopyButton value={data.path} size="icon" className="h-5 w-5" />
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
<span className="font-medium text-muted-foreground">Last Modified</span>
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
</div>
</>
)}
</div>
</div>
{/* Usage */}
<div>
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
<div>
<Label className="text-xs text-muted-foreground">Run with profile</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs {profileName} "prompt"
</code>
<CopyButton value={`ccs ${profileName} "prompt"`} size="icon" className="h-6 w-6" />
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Set as default</Label>
<div className="mt-1 flex gap-2">
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
ccs default {profileName}
</code>
<CopyButton value={`ccs default ${profileName}`} size="icon" className="h-6 w-6" />
</div>
</div>
</div>
</div>
</div>
</ScrollArea>
);
}
@@ -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 (
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading editor...</span>
</div>
}
>
<div className="h-full flex flex-col">
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
Invalid JSON syntax
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
onChange={onChange}
language="json"
minHeight="100%"
/>
</div>
</div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
<GlobalEnvIndicator profileEnv={settings?.env} />
</div>
</div>
</div>
</Suspense>
);
}
@@ -0,0 +1,19 @@
/**
* Types for Profile Editor
*/
export interface Settings {
env?: Record<string, string>;
}
export interface SettingsResponse {
profile: string;
settings: Settings;
mtime: number;
path: string;
}
export interface ProfileEditorProps {
profileName: string;
onDelete?: () => void;
}
@@ -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<string, string>;
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<SettingsResponse>({
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,
};
}
@@ -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));
}
+14
View File
@@ -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';
@@ -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 (
<Card className={profile.isActive ? 'border-primary' : ''}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{profile.name}</h3>
{profile.isActive && (
<Badge variant="default" className="text-xs">
Active
</Badge>
)}
</div>
<Button variant="ghost" size="sm" onClick={onSwitch} disabled={profile.isActive}>
{profile.isActive ? 'Active' : 'Switch'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{profile.model && (
<div className="text-sm text-muted-foreground">Model: {profile.model}</div>
)}
{profile.lastUsed && (
<div className="text-sm text-muted-foreground">Last used: {profile.lastUsed}</div>
)}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onConfig} className="flex-1">
<SettingsIcon className="w-4 h-4 mr-1" />
Config
</Button>
<Button variant="outline" size="sm" onClick={onTest} className="flex-1">
<PlayIcon className="w-4 h-4 mr-1" />
Test
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -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<typeof schema>;
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<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
control,
reset,
} = useForm<FormData>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] p-0 gap-0 overflow-hidden">
<DialogHeader className="p-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5 text-primary" />
Create API Profile
</DialogTitle>
<DialogDescription>Configure a custom API endpoint for Claude Code.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="relative">
Basic Information
{hasBasicErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
<TabsTrigger value="models" className="relative">
Model Configuration
{hasModelErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-y-auto max-h-[60vh]">
<TabsContent value="basic" className="p-6 space-y-6 mt-0">
<div className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">
ccs my-api "prompt"
</code>
</p>
)}
</div>
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">
API Base URL <span className="text-destructive">*</span>
</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">Toggle API key visibility</span>
</Button>
</div>
{errors.apiKey && (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
)}
</div>
</div>
</TabsContent>
<TabsContent value="models" className="p-6 mt-0 space-y-6">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium mb-1">Model Mapping</p>
<p className="text-xs opacity-90">
Claude Code requests specific model tiers (Opus/Sonnet/Haiku). Map these tiers
to the specific models supported by your API provider.
</p>
</div>
</div>
<div className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
ANTHROPIC_MODEL
</Badge>
</Label>
<Input
id="model"
{...register('model')}
placeholder={DEFAULT_MODEL}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Fallback model if no specific tier is requested
</p>
</div>
<div className="grid gap-4 pt-2 border-t">
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Mapping (Primary)
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_SONNET
</Badge>
</Label>
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder="e.g. gpt-4o, claude-3-5-sonnet"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Mapping (Complex Tasks)
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_OPUS
</Badge>
</Label>
<Input
id="opusModel"
{...register('opusModel')}
placeholder="e.g. o1-preview, claude-3-opus"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Mapping (Fast Tasks)
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_HAIKU
</Badge>
</Label>
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder="e.g. gpt-4o-mini, claude-3-haiku"
className="font-mono text-sm h-9"
/>
</div>
</div>
</div>
</TabsContent>
</div>
<DialogFooter className="p-6 pt-2 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className={cn(createMutation.isPending && 'opacity-80')}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</>
)}
</Button>
</DialogFooter>
</Tabs>
</form>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-32 w-full" />
</div>
))}
</div>
);
}
if (error) {
return <div className="text-destructive text-sm">Failed to load profiles: {error.message}</div>;
}
const profiles = response?.profiles || [];
if (!profiles || profiles.length === 0) {
return (
<div className="text-muted-foreground text-center py-8">
No profiles configured. Create your first profile to get started.
</div>
);
}
// Use real profile data directly
const normalizedProfiles = profiles.map((profile) => ({
...profile,
configured: profile.configured ?? false, // Ensure configured is always boolean
}));
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Profiles</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{normalizedProfiles.map((profile) => (
<ProfileCard
key={profile.name}
profile={profile}
onSwitch={() => console.log('Switch to', profile.name)}
onConfig={() => console.log('Config', profile.name)}
onTest={() => console.log('Test', profile.name)}
/>
))}
</div>
</div>
);
}
@@ -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<typeof schema>;
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<FormData>({
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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{profile ? 'Edit Profile' : 'Create API Profile'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...register('name')} placeholder="my-api" disabled={!!profile} />
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
</div>
<div>
<Label htmlFor="baseUrl">Base URL</Label>
<Input id="baseUrl" {...register('baseUrl')} placeholder="https://api.example.com" />
{errors.baseUrl && (
<span className="text-xs text-red-500">{errors.baseUrl.message}</span>
)}
</div>
<div>
<Label htmlFor="apiKey">API Key</Label>
<Input id="apiKey" type="password" {...register('apiKey')} />
{errors.apiKey && <span className="text-xs text-red-500">{errors.apiKey.message}</span>}
</div>
<div>
<Label htmlFor="model">Default Model (ANTHROPIC_MODEL)</Label>
<Input id="model" {...register('model')} placeholder={DEFAULT_MODEL} />
<p className="text-xs text-muted-foreground mt-1">
Leave blank to use: {DEFAULT_MODEL}
</p>
</div>
{/* Model Mapping Section */}
<div className="border rounded-md">
<button
type="button"
className="w-full flex items-center justify-between p-3 text-sm font-medium hover:bg-muted/50 transition-colors"
onClick={() => setShowModelMapping(!showModelMapping)}
>
<span>Model Mapping (Opus/Sonnet/Haiku)</span>
{showModelMapping ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{showModelMapping && (
<div className="p-3 pt-0 space-y-3 border-t">
<p className="text-xs text-muted-foreground">
Configure different model IDs for each tier. Useful for API proxies that route
different model types to different backends.
</p>
<div>
<Label htmlFor="opusModel" className="text-xs">
Opus Model (ANTHROPIC_DEFAULT_OPUS_MODEL)
</Label>
<Input
id="opusModel"
{...register('opusModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="h-8 text-sm"
/>
</div>
<div>
<Label htmlFor="sonnetModel" className="text-xs">
Sonnet Model (ANTHROPIC_DEFAULT_SONNET_MODEL)
</Label>
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="h-8 text-sm"
/>
</div>
<div>
<Label htmlFor="haikuModel" className="text-xs">
Haiku Model (ANTHROPIC_DEFAULT_HAIKU_MODEL)
</Label>
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="h-8 text-sm"
/>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{createMutation.isPending || updateMutation.isPending
? 'Saving...'
: profile
? 'Update'
: 'Create'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -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<Profile>[] = [
{
accessorKey: 'name',
header: 'Name',
size: 200,
},
{
accessorKey: 'settingsPath',
header: 'Settings Path',
},
{
accessorKey: 'configured',
header: 'Status',
size: 100,
cell: ({ row }) => (
<span className={row.original.configured ? 'text-green-600' : 'text-yellow-600'}>
{row.original.configured ? '[OK]' : '[!]'}
</span>
),
},
{
id: 'actions',
header: 'Actions',
size: 100,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-white dark:bg-zinc-950 border shadow-md">
{onEditSettings && (
<DropdownMenuItem onClick={() => onEditSettings(row.original)}>
<Edit className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 focus:text-red-600 focus:bg-red-100/50"
onClick={() => deleteMutation.mutate(row.original.name)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
if (data.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No profiles found. Create one to get started.
</div>
);
}
return (
<div className="border rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isAction = header.id === 'actions';
const isStatus = header.id === 'configured';
const isName = header.id === 'name';
return (
<TableHead
key={header.id}
className={
isAction
? 'w-[50px]'
: isStatus
? 'w-[100px]'
: isName
? 'w-[200px]'
: 'w-auto'
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}