mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
feat(copilot): redesign config form to match CLIProxy pattern
- Split-view layout (40% left / 60% right) matching CLIProxy design - Left panel: Model Config tab with FlexibleModelSelector components, Settings tab, and Info tab - Right panel: Raw JSON editor for copilot.settings.json - Add preset buttons for quick model tier mapping - Bidirectional sync between UI and raw JSON - Simplify copilot.tsx page layout with narrower sidebar
This commit is contained in:
@@ -2,28 +2,129 @@
|
||||
* Copilot Config Form
|
||||
*
|
||||
* Form for configuring GitHub Copilot integration settings.
|
||||
* Split-view layout matching CLIProxy provider editor:
|
||||
* - Left: Friendly UI with model mapping selectors
|
||||
* - Right: Raw JSON editor for copilot.settings.json
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
} from '@/components/ui/select';
|
||||
import { useCopilot } from '@/hooks/use-copilot';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
|
||||
// Lazy load CodeEditor
|
||||
const CodeEditor = lazy(() =>
|
||||
import('@/components/code-editor').then((m) => ({ default: m.CodeEditor }))
|
||||
);
|
||||
|
||||
// Model presets for quick configuration
|
||||
const MODEL_PRESETS = [
|
||||
{
|
||||
name: 'Claude Opus 4.5',
|
||||
default: 'claude-opus-4-5-20250514',
|
||||
opus: 'claude-opus-4-5-20250514',
|
||||
sonnet: 'claude-sonnet-4-20250514',
|
||||
haiku: 'claude-haiku-3-5-20250514',
|
||||
},
|
||||
{
|
||||
name: 'Claude Sonnet 4',
|
||||
default: 'claude-sonnet-4-20250514',
|
||||
opus: 'claude-sonnet-4-20250514',
|
||||
sonnet: 'claude-sonnet-4-20250514',
|
||||
haiku: 'claude-haiku-3-5-20250514',
|
||||
},
|
||||
{
|
||||
name: 'GPT-4o',
|
||||
default: 'gpt-4o',
|
||||
opus: 'gpt-4o',
|
||||
sonnet: 'gpt-4o',
|
||||
haiku: 'gpt-4o-mini',
|
||||
},
|
||||
];
|
||||
|
||||
interface FlexibleModelSelectorProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string | undefined;
|
||||
onChange: (model: string) => void;
|
||||
models: { id: string }[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function FlexibleModelSelector({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
models,
|
||||
disabled,
|
||||
}: FlexibleModelSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<label className="text-xs font-medium">{label}</label>
|
||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Select model">
|
||||
{value && <span className="truncate font-mono text-xs">{value}</span>}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">
|
||||
Available Models ({models.length})
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopilotConfigForm() {
|
||||
const { config, configLoading, models, modelsLoading, updateConfigAsync, isUpdating } =
|
||||
useCopilot();
|
||||
const {
|
||||
config,
|
||||
configLoading,
|
||||
models,
|
||||
modelsLoading,
|
||||
rawSettings,
|
||||
rawSettingsLoading,
|
||||
updateConfigAsync,
|
||||
isUpdating,
|
||||
saveRawSettingsAsync,
|
||||
isSavingRawSettings,
|
||||
refetchRawSettings,
|
||||
} = useCopilot();
|
||||
|
||||
// Track local overrides for form fields
|
||||
const [localOverrides, setLocalOverrides] = useState<{
|
||||
@@ -34,16 +135,26 @@ export function CopilotConfigForm() {
|
||||
model?: string;
|
||||
rateLimit?: string;
|
||||
waitOnLimit?: boolean;
|
||||
opusModel?: string;
|
||||
sonnetModel?: string;
|
||||
haikuModel?: string;
|
||||
}>({});
|
||||
|
||||
// Raw JSON editor state
|
||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
|
||||
// Use local overrides if set, otherwise use config values
|
||||
const enabled = localOverrides.enabled ?? config?.enabled ?? false;
|
||||
const autoStart = localOverrides.autoStart ?? config?.auto_start ?? false;
|
||||
const port = localOverrides.port ?? config?.port ?? 4141;
|
||||
const accountType = localOverrides.accountType ?? config?.account_type ?? 'individual';
|
||||
const model = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514';
|
||||
const currentModel = localOverrides.model ?? config?.model ?? 'claude-opus-4-5-20250514';
|
||||
const rateLimit = localOverrides.rateLimit ?? config?.rate_limit?.toString() ?? '';
|
||||
const waitOnLimit = localOverrides.waitOnLimit ?? config?.wait_on_limit ?? true;
|
||||
const opusModel = localOverrides.opusModel ?? config?.opus_model ?? '';
|
||||
const sonnetModel = localOverrides.sonnetModel ?? config?.sonnet_model ?? '';
|
||||
const haikuModel = localOverrides.haikuModel ?? config?.haiku_model ?? '';
|
||||
|
||||
const updateField = <K extends keyof typeof localOverrides>(
|
||||
key: K,
|
||||
@@ -52,182 +163,521 @@ export function CopilotConfigForm() {
|
||||
setLocalOverrides((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Batch update for presets
|
||||
const applyPreset = (preset: (typeof MODEL_PRESETS)[0]) => {
|
||||
setLocalOverrides((prev) => ({
|
||||
...prev,
|
||||
model: preset.default,
|
||||
opusModel: preset.opus,
|
||||
sonnetModel: preset.sonnet,
|
||||
haikuModel: preset.haiku,
|
||||
}));
|
||||
toast.success(`Applied "${preset.name}" preset`);
|
||||
};
|
||||
|
||||
// Raw JSON content
|
||||
const rawJsonContent = useMemo(() => {
|
||||
if (rawJsonEdits !== null) return rawJsonEdits;
|
||||
if (rawSettings?.settings) return JSON.stringify(rawSettings.settings, null, 2);
|
||||
return '{\n "env": {}\n}';
|
||||
}, [rawJsonEdits, rawSettings]);
|
||||
|
||||
const handleRawJsonChange = useCallback((value: string) => {
|
||||
setRawJsonEdits(value);
|
||||
}, []);
|
||||
|
||||
// Check if JSON is valid
|
||||
const isRawJsonValid = useMemo(() => {
|
||||
try {
|
||||
JSON.parse(rawJsonContent);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [rawJsonContent]);
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasChanges = useMemo(() => {
|
||||
const hasLocalChanges = Object.keys(localOverrides).length > 0;
|
||||
const hasJsonChanges =
|
||||
rawJsonEdits !== null && rawJsonEdits !== JSON.stringify(rawSettings?.settings, null, 2);
|
||||
return hasLocalChanges || hasJsonChanges;
|
||||
}, [localOverrides, rawJsonEdits, rawSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateConfigAsync({
|
||||
enabled,
|
||||
auto_start: autoStart,
|
||||
port,
|
||||
account_type: accountType,
|
||||
model,
|
||||
rate_limit: rateLimit ? parseInt(rateLimit, 10) : null,
|
||||
wait_on_limit: waitOnLimit,
|
||||
});
|
||||
// Clear local overrides after successful save
|
||||
// Save config changes
|
||||
if (Object.keys(localOverrides).length > 0) {
|
||||
await updateConfigAsync({
|
||||
enabled,
|
||||
auto_start: autoStart,
|
||||
port,
|
||||
account_type: accountType,
|
||||
model: currentModel,
|
||||
rate_limit: rateLimit ? parseInt(rateLimit, 10) : null,
|
||||
wait_on_limit: waitOnLimit,
|
||||
opus_model: opusModel || undefined,
|
||||
sonnet_model: sonnetModel || undefined,
|
||||
haiku_model: haikuModel || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Save raw JSON changes
|
||||
if (rawJsonEdits !== null && isRawJsonValid) {
|
||||
const settingsToSave = JSON.parse(rawJsonContent);
|
||||
await saveRawSettingsAsync({
|
||||
settings: settingsToSave,
|
||||
expectedMtime: rawSettings?.mtime,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear local state
|
||||
setLocalOverrides({});
|
||||
toast.success('Copilot configuration has been updated.');
|
||||
} catch {
|
||||
toast.error('Failed to save settings.');
|
||||
setRawJsonEdits(null);
|
||||
toast.success('Copilot configuration saved');
|
||||
} catch (error) {
|
||||
if ((error as Error).message === 'CONFLICT') {
|
||||
setConflictDialog(true);
|
||||
} else {
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (configLoading) {
|
||||
const handleConflictResolve = async (overwrite: boolean) => {
|
||||
setConflictDialog(false);
|
||||
if (overwrite) {
|
||||
await refetchRawSettings();
|
||||
handleSave();
|
||||
} else {
|
||||
setRawJsonEdits(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (configLoading || rawSettingsLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
<CardDescription>Configure GitHub Copilot integration settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled">Enable Copilot</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow using GitHub Copilot subscription with Claude Code
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => updateField('enabled', v)}
|
||||
/>
|
||||
// Left column: Friendly UI
|
||||
const renderFriendlyUI = () => (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="config" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="config" className="flex-1">
|
||||
Model Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
Info
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Port */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port">Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => updateField('port', parseInt(e.target.value, 10))}
|
||||
min={1024}
|
||||
max={65535}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Local port for copilot-api proxy (default: 4141)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-type">Account Type</Label>
|
||||
<Select
|
||||
value={accountType}
|
||||
onValueChange={(v) => updateField('accountType', v as typeof accountType)}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Model Config Tab */}
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||
>
|
||||
<SelectTrigger id="account-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="individual">Individual</SelectItem>
|
||||
<SelectItem value="business">Business</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Your GitHub Copilot subscription type</p>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Presets
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Apply pre-configured model mappings
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MODEL_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<Select value={model} onValueChange={(v) => updateField('model', v)}>
|
||||
<SelectTrigger id="model">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsLoading ? (
|
||||
<SelectItem value={model} disabled>
|
||||
Loading...
|
||||
</SelectItem>
|
||||
) : (
|
||||
models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name} ({m.provider})
|
||||
</SelectItem>
|
||||
))
|
||||
<Separator />
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Configure which models to use for each tier
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<FlexibleModelSelector
|
||||
label="Default Model"
|
||||
description="Used when no specific tier is requested"
|
||||
value={currentModel}
|
||||
onChange={(model) => updateField('model', model)}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Opus (Most capable)"
|
||||
description="For complex reasoning tasks"
|
||||
value={opusModel || currentModel}
|
||||
onChange={(model) => updateField('opusModel', model)}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet (Balanced)"
|
||||
description="Balance of speed and capability"
|
||||
value={sonnetModel || currentModel}
|
||||
onChange={(model) => updateField('sonnetModel', model)}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Haiku (Fast)"
|
||||
description="Quick responses for simple tasks"
|
||||
value={haikuModel || currentModel}
|
||||
onChange={(model) => updateField('haikuModel', model)}
|
||||
models={models}
|
||||
disabled={modelsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Settings Tab */}
|
||||
<TabsContent
|
||||
value="settings"
|
||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled" className="text-sm font-medium">
|
||||
Enable Copilot
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow using GitHub Copilot subscription
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => updateField('enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Basic Settings</h3>
|
||||
|
||||
{/* Port */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port" className="text-xs">
|
||||
Port
|
||||
</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => updateField('port', parseInt(e.target.value, 10))}
|
||||
min={1024}
|
||||
max={65535}
|
||||
className="max-w-[150px] h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Account Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-type" className="text-xs">
|
||||
Account Type
|
||||
</Label>
|
||||
<Select
|
||||
value={accountType}
|
||||
onValueChange={(v) => updateField('accountType', v as typeof accountType)}
|
||||
>
|
||||
<SelectTrigger id="account-type" className="max-w-[150px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="individual">Individual</SelectItem>
|
||||
<SelectItem value="business">Business</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Rate Limiting</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate-limit" className="text-xs">
|
||||
Rate Limit (seconds)
|
||||
</Label>
|
||||
<Input
|
||||
id="rate-limit"
|
||||
type="number"
|
||||
value={rateLimit}
|
||||
onChange={(e) => updateField('rateLimit', e.target.value)}
|
||||
placeholder="No limit"
|
||||
min={0}
|
||||
className="max-w-[150px] h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="wait-on-limit" className="text-xs">
|
||||
Wait on Rate Limit
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Wait instead of error when limit hit
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="wait-on-limit"
|
||||
checked={waitOnLimit}
|
||||
onCheckedChange={(v) => updateField('waitOnLimit', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Daemon Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Daemon Settings</h3>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-start" className="text-xs">
|
||||
Auto-start Daemon
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Start copilot-api when using profile
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-start"
|
||||
checked={autoStart}
|
||||
onCheckedChange={(v) => updateField('autoStart', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Info Tab */}
|
||||
<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">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4" />
|
||||
Configuration Info
|
||||
</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Provider</span>
|
||||
<span className="font-mono">GitHub Copilot</span>
|
||||
</div>
|
||||
{rawSettings && (
|
||||
<>
|
||||
<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">
|
||||
{rawSettings.path}
|
||||
</code>
|
||||
<CopyButton value={rawSettings.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">Status</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
rawSettings.exists
|
||||
? 'w-fit text-green-600 border-green-200 bg-green-50'
|
||||
: 'w-fit text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{rawSettings.exists ? 'File exists' : 'Using defaults'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<UsageCommand label="Run with Copilot" command="ccs copilot" />
|
||||
<UsageCommand label="Authenticate" command="ccs copilot auth" />
|
||||
<UsageCommand label="Start daemon" command="ccs copilot --start" />
|
||||
<UsageCommand label="Stop daemon" command="ccs copilot --stop" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Right column: 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-6 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={handleRawJsonChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<div 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 className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Copilot Configuration</h2>
|
||||
{rawSettings && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
copilot.settings.json
|
||||
</Badge>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Model to use via Copilot (default: Claude Opus 4.5)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rate Limit */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate-limit">Rate Limit (seconds)</Label>
|
||||
<Input
|
||||
id="rate-limit"
|
||||
type="number"
|
||||
value={rateLimit}
|
||||
onChange={(e) => updateField('rateLimit', e.target.value)}
|
||||
placeholder="No limit"
|
||||
min={0}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Minimum seconds between requests (leave empty for no limit)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Wait on Limit */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="wait-on-limit">Wait on Rate Limit</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Wait instead of error when rate limit is hit
|
||||
</p>
|
||||
</div>
|
||||
{rawSettings && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified:{' '}
|
||||
{rawSettings.exists ? new Date(rawSettings.mtime).toLocaleString() : 'Never saved'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id="wait-on-limit"
|
||||
checked={waitOnLimit}
|
||||
onCheckedChange={(v) => updateField('waitOnLimit', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetchRawSettings()}
|
||||
disabled={rawSettingsLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${rawSettingsLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isUpdating || isSavingRawSettings || !hasChanges || !isRawJsonValid}
|
||||
>
|
||||
{isUpdating || isSavingRawSettings ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto Start */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-start">Auto-start Daemon</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically start copilot-api when using profile
|
||||
</p>
|
||||
{/* 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>
|
||||
<Switch
|
||||
id="auto-start"
|
||||
checked={autoStart}
|
||||
onCheckedChange={(v) => updateField('autoStart', v)}
|
||||
/>
|
||||
{renderRawEditor()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<Button onClick={handleSave} disabled={isUpdating} className="w-full">
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Usage command with copy button */
|
||||
function UsageCommand({ label, command }: { label: string; command: string }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{label}</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">
|
||||
{command}
|
||||
</code>
|
||||
<CopyButton value={command} size="icon" className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+263
-14
@@ -1,24 +1,273 @@
|
||||
/**
|
||||
* Copilot Page
|
||||
*
|
||||
* GitHub Copilot integration settings page.
|
||||
* Copilot Page - Master-Detail Layout
|
||||
* Left sidebar: Status overview + Quick actions
|
||||
* Right panel: Split-view configuration form (matches CLIProxy design)
|
||||
*/
|
||||
|
||||
import { CopilotStatusCard } from '@/components/copilot/copilot-status-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Github,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Power,
|
||||
PowerOff,
|
||||
Key,
|
||||
Server,
|
||||
Cpu,
|
||||
} from 'lucide-react';
|
||||
import { useCopilot } from '@/hooks/use-copilot';
|
||||
import { CopilotConfigForm } from '@/components/copilot/copilot-config-form';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function CopilotPage() {
|
||||
// Status section component
|
||||
function StatusSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">GitHub Copilot</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Use your GitHub Copilot subscription with Claude Code
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3">
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Status item component
|
||||
function StatusItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
status,
|
||||
statusText,
|
||||
variant = 'default',
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
status: boolean;
|
||||
statusText?: string;
|
||||
variant?: 'default' | 'warning';
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-muted/50">
|
||||
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{status ? (
|
||||
<>
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
variant === 'warning' ? 'text-yellow-500' : 'text-green-500'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
variant === 'warning' ? 'text-yellow-500' : 'text-green-500'
|
||||
)}
|
||||
>
|
||||
{statusText || 'Yes'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{statusText || 'No'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state when loading
|
||||
function LoadingSidebar() {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopilotPage() {
|
||||
const {
|
||||
status,
|
||||
statusLoading,
|
||||
refetchStatus,
|
||||
startAuth,
|
||||
isAuthenticating,
|
||||
startDaemon,
|
||||
isStartingDaemon,
|
||||
stopDaemon,
|
||||
isStoppingDaemon,
|
||||
} = useCopilot();
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex">
|
||||
{/* Left Sidebar - Status Overview */}
|
||||
<div className="w-64 border-r flex flex-col bg-muted/30 shrink-0">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="w-5 h-5 text-primary" />
|
||||
<h1 className="font-semibold">Copilot</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => refetchStatus()}
|
||||
disabled={statusLoading}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">GitHub Copilot proxy</p>
|
||||
</div>
|
||||
|
||||
{/* Status Overview */}
|
||||
<ScrollArea className="flex-1">
|
||||
{statusLoading ? (
|
||||
<LoadingSidebar />
|
||||
) : (
|
||||
<div className="p-3 space-y-4">
|
||||
{/* Warning Banner */}
|
||||
<div className="flex items-start gap-2 rounded-md border border-yellow-500/20 bg-yellow-500/10 p-2.5">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-300">
|
||||
Reverse-engineered API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Integration Status */}
|
||||
<StatusSection title="Integration">
|
||||
<StatusItem
|
||||
icon={Power}
|
||||
label="Enabled"
|
||||
status={status?.enabled ?? false}
|
||||
statusText={status?.enabled ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Server}
|
||||
label="copilot-api"
|
||||
status={status?.installed ?? false}
|
||||
statusText={status?.installed ? 'Installed' : 'Missing'}
|
||||
/>
|
||||
</StatusSection>
|
||||
|
||||
{/* Authentication */}
|
||||
<StatusSection title="Auth">
|
||||
<StatusItem
|
||||
icon={Key}
|
||||
label="GitHub"
|
||||
status={status?.authenticated ?? false}
|
||||
statusText={status?.authenticated ? 'Connected' : 'Not Connected'}
|
||||
/>
|
||||
{!status?.authenticated && status?.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full mt-2"
|
||||
onClick={() => startAuth()}
|
||||
disabled={isAuthenticating}
|
||||
>
|
||||
{isAuthenticating ? 'Authenticating...' : 'Authenticate'}
|
||||
</Button>
|
||||
)}
|
||||
</StatusSection>
|
||||
|
||||
{/* Daemon */}
|
||||
<StatusSection title="Daemon">
|
||||
<StatusItem
|
||||
icon={Cpu}
|
||||
label="Status"
|
||||
status={status?.daemon_running ?? false}
|
||||
statusText={status?.daemon_running ? 'Running' : 'Stopped'}
|
||||
/>
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground">
|
||||
Port: {status?.port ?? 4141}
|
||||
</div>
|
||||
{status?.authenticated && (
|
||||
<div className="px-1">
|
||||
{status?.daemon_running ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => stopDaemon()}
|
||||
disabled={isStoppingDaemon}
|
||||
>
|
||||
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
|
||||
{isStoppingDaemon ? 'Stopping...' : 'Stop'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => startDaemon()}
|
||||
disabled={isStartingDaemon}
|
||||
>
|
||||
<Power className="w-3.5 h-3.5 mr-1.5" />
|
||||
{isStartingDaemon ? 'Starting...' : 'Start'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StatusSection>
|
||||
|
||||
{/* Quick Status */}
|
||||
<StatusSection title="Config">
|
||||
<div className="px-3 py-2 rounded-lg bg-muted/50 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Model</span>
|
||||
<span className="font-mono truncate max-w-[100px] text-[10px]">
|
||||
{status?.model ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Account</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-4">
|
||||
{status?.account_type ?? 'individual'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</StatusSection>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Proxy</span>
|
||||
{status?.daemon_running ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3 text-muted-foreground" />
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Split-view Configuration Form */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-background overflow-hidden">
|
||||
<CopilotConfigForm />
|
||||
</div>
|
||||
|
||||
<CopilotStatusCard />
|
||||
<CopilotConfigForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user