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:
kaitranntt
2025-12-18 00:22:14 -05:00
parent 882792a4fb
commit 7886259c36
2 changed files with 875 additions and 176 deletions
+612 -162
View File
@@ -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
View File
@@ -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>
);
}