feat(ui): add copilot dashboard page

- use-copilot.ts: React hook for copilot state management

- copilot-status-card.tsx: status display with auth/daemon controls

- copilot-config-form.tsx: configuration form with model selector

- copilot.tsx: main page combining status and config

- add /copilot route and sidebar navigation
This commit is contained in:
kaitranntt
2025-12-17 21:41:06 -05:00
parent c84db38f6a
commit 6b04532f41
6 changed files with 608 additions and 0 deletions
+2
View File
@@ -21,6 +21,7 @@ const CliproxyPage = lazy(() =>
const CliproxyControlPanelPage = lazy(() =>
import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage }))
);
const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage })));
const AccountsPage = lazy(() =>
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
);
@@ -43,6 +44,7 @@ export default function App() {
<Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
<Route path="/copilot" element={<CopilotPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/health" element={<HealthPage />} />
+2
View File
@@ -10,6 +10,7 @@ import {
ChevronRight,
BarChart3,
Gauge,
Github,
} from 'lucide-react';
import {
Sidebar,
@@ -54,6 +55,7 @@ const navGroups = [
{ path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' },
],
},
{ path: '/copilot', icon: Github, label: 'GitHub Copilot' },
{
path: '/accounts',
icon: Users,
@@ -0,0 +1,233 @@
/**
* Copilot Config Form
*
* Form for configuring GitHub Copilot integration settings.
*/
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCopilot } from '@/hooks/use-copilot';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
export function CopilotConfigForm() {
const { config, configLoading, models, modelsLoading, updateConfigAsync, isUpdating } =
useCopilot();
// Track local overrides for form fields
const [localOverrides, setLocalOverrides] = useState<{
enabled?: boolean;
autoStart?: boolean;
port?: number;
accountType?: 'individual' | 'business' | 'enterprise';
model?: string;
rateLimit?: string;
waitOnLimit?: boolean;
}>({});
// 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 rateLimit = localOverrides.rateLimit ?? config?.rate_limit?.toString() ?? '';
const waitOnLimit = localOverrides.waitOnLimit ?? config?.wait_on_limit ?? true;
const updateField = <K extends keyof typeof localOverrides>(
key: K,
value: (typeof localOverrides)[K]
) => {
setLocalOverrides((prev) => ({ ...prev, [key]: value }));
};
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
setLocalOverrides({});
toast.success('Copilot configuration has been updated.');
} catch {
toast.error('Failed to save settings.');
}
};
if (configLoading) {
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>
);
}
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)}
/>
</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)}
>
<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>
{/* 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>
))
)}
</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>
<Switch
id="wait-on-limit"
checked={waitOnLimit}
onCheckedChange={(v) => updateField('waitOnLimit', v)}
/>
</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>
</div>
<Switch
id="auto-start"
checked={autoStart}
onCheckedChange={(v) => updateField('autoStart', v)}
/>
</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>
);
}
@@ -0,0 +1,173 @@
/**
* Copilot Status Card
*
* Displays GitHub Copilot integration status overview.
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useCopilot } from '@/hooks/use-copilot';
import { CheckCircle2, XCircle, AlertTriangle, Loader2 } from 'lucide-react';
export function CopilotStatusCard() {
const {
status,
statusLoading,
startAuth,
isAuthenticating,
startDaemon,
isStartingDaemon,
stopDaemon,
isStoppingDaemon,
} = useCopilot();
if (statusLoading) {
return (
<Card>
<CardHeader>
<CardTitle>GitHub Copilot Status</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</CardContent>
</Card>
);
}
if (!status) {
return (
<Card>
<CardHeader>
<CardTitle>GitHub Copilot Status</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">Failed to load status</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
GitHub Copilot Status
{status.enabled ? (
<Badge variant="default">Enabled</Badge>
) : (
<Badge variant="secondary">Disabled</Badge>
)}
</CardTitle>
<CardDescription>Use your GitHub Copilot subscription with Claude Code</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Warning Banner */}
<div className="flex items-start gap-2 rounded-md border border-yellow-500/20 bg-yellow-500/10 p-3">
<AlertTriangle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-sm text-yellow-700 dark:text-yellow-300">
This uses a reverse-engineered API. Excessive usage may trigger GitHub abuse detection.
</p>
</div>
{/* Status Grid */}
<div className="grid gap-4 md:grid-cols-3">
{/* Installed */}
<div className="flex items-center gap-2">
{status.installed ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
copilot-api {status.installed ? 'Installed' : 'Not Installed'}
</span>
</div>
{/* Authenticated */}
<div className="flex items-center gap-2">
{status.authenticated ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<span className="text-sm">
{status.authenticated ? 'Authenticated' : 'Not Authenticated'}
</span>
</div>
{/* Daemon */}
<div className="flex items-center gap-2">
{status.daemon_running ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-muted-foreground" />
)}
<span className="text-sm">Daemon {status.daemon_running ? 'Running' : 'Stopped'}</span>
</div>
</div>
{/* Quick Info */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span>Port: {status.port}</span>
<span>Model: {status.model}</span>
<span>Auto-start: {status.auto_start ? 'Yes' : 'No'}</span>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2 pt-2">
{!status.authenticated && (
<Button
onClick={() => startAuth()}
disabled={isAuthenticating || !status.installed}
size="sm"
>
{isAuthenticating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
'Authenticate with GitHub'
)}
</Button>
)}
{status.daemon_running ? (
<Button
onClick={() => stopDaemon()}
disabled={isStoppingDaemon}
variant="outline"
size="sm"
>
{isStoppingDaemon ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Stopping...
</>
) : (
'Stop Daemon'
)}
</Button>
) : (
<Button
onClick={() => startDaemon()}
disabled={isStartingDaemon || !status.authenticated}
variant="outline"
size="sm"
>
{isStartingDaemon ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting...
</>
) : (
'Start Daemon'
)}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Copilot API Hook
*
* React hook for managing GitHub Copilot integration state.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
// Types
export interface CopilotStatus {
enabled: boolean;
installed: boolean;
authenticated: boolean;
daemon_running: boolean;
port: number;
model: string;
account_type: 'individual' | 'business' | 'enterprise';
auto_start: boolean;
rate_limit: number | null;
wait_on_limit: boolean;
}
export interface CopilotConfig {
enabled: boolean;
auto_start: boolean;
port: number;
account_type: 'individual' | 'business' | 'enterprise';
rate_limit: number | null;
wait_on_limit: boolean;
model: string;
}
export interface CopilotModel {
id: string;
name: string;
provider: 'openai' | 'anthropic';
isDefault?: boolean;
isCurrent?: boolean;
}
// API functions
async function fetchCopilotStatus(): Promise<CopilotStatus> {
const res = await fetch(`${API_BASE}/copilot/status`);
if (!res.ok) throw new Error('Failed to fetch copilot status');
return res.json();
}
async function fetchCopilotConfig(): Promise<CopilotConfig> {
const res = await fetch(`${API_BASE}/copilot/config`);
if (!res.ok) throw new Error('Failed to fetch copilot config');
return res.json();
}
async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: string }> {
const res = await fetch(`${API_BASE}/copilot/models`);
if (!res.ok) throw new Error('Failed to fetch copilot models');
return res.json();
}
async function updateCopilotConfig(config: Partial<CopilotConfig>): Promise<{ success: boolean }> {
const res = await fetch(`${API_BASE}/copilot/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) throw new Error('Failed to update copilot config');
return res.json();
}
async function startCopilotAuth(): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_BASE}/copilot/auth/start`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to start auth');
return res.json();
}
async function startCopilotDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> {
const res = await fetch(`${API_BASE}/copilot/daemon/start`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to start daemon');
return res.json();
}
async function stopCopilotDaemon(): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_BASE}/copilot/daemon/stop`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to stop daemon');
return res.json();
}
// Hook
export function useCopilot() {
const queryClient = useQueryClient();
// Queries
const statusQuery = useQuery({
queryKey: ['copilot-status'],
queryFn: fetchCopilotStatus,
refetchInterval: 5000, // Refresh every 5 seconds
});
const configQuery = useQuery({
queryKey: ['copilot-config'],
queryFn: fetchCopilotConfig,
});
const modelsQuery = useQuery({
queryKey: ['copilot-models'],
queryFn: fetchCopilotModels,
});
// Mutations
const updateConfigMutation = useMutation({
mutationFn: updateCopilotConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['copilot-status'] });
queryClient.invalidateQueries({ queryKey: ['copilot-config'] });
},
});
const startAuthMutation = useMutation({
mutationFn: startCopilotAuth,
onSuccess: () => {
// Delay refetch to allow auth to complete
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['copilot-status'] });
}, 2000);
},
});
const startDaemonMutation = useMutation({
mutationFn: startCopilotDaemon,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['copilot-status'] });
},
});
const stopDaemonMutation = useMutation({
mutationFn: stopCopilotDaemon,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['copilot-status'] });
},
});
return {
// Status
status: statusQuery.data,
statusLoading: statusQuery.isLoading,
statusError: statusQuery.error,
refetchStatus: statusQuery.refetch,
// Config
config: configQuery.data,
configLoading: configQuery.isLoading,
// Models
models: modelsQuery.data?.models ?? [],
currentModel: modelsQuery.data?.current,
modelsLoading: modelsQuery.isLoading,
// Mutations
updateConfig: updateConfigMutation.mutate,
updateConfigAsync: updateConfigMutation.mutateAsync,
isUpdating: updateConfigMutation.isPending,
startAuth: startAuthMutation.mutate,
isAuthenticating: startAuthMutation.isPending,
startDaemon: startDaemonMutation.mutate,
isStartingDaemon: startDaemonMutation.isPending,
stopDaemon: stopDaemonMutation.mutate,
isStoppingDaemon: stopDaemonMutation.isPending,
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Copilot Page
*
* GitHub Copilot integration settings page.
*/
import { CopilotStatusCard } from '@/components/copilot/copilot-status-card';
import { CopilotConfigForm } from '@/components/copilot/copilot-config-form';
export function CopilotPage() {
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>
<CopilotStatusCard />
<CopilotConfigForm />
</div>
);
}