This commit is contained in:
2026-02-20 04:32:50 +00:00
parent d7503ca56b
commit d9f6eb14ed
24 changed files with 1426 additions and 1183 deletions
@@ -1,321 +1,534 @@
import { useState } from 'react';
import { Copy, Check, Play } from 'lucide-react';
import { Plus, Save, Trash2, RefreshCw, Loader2, X } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useServerSettings } from '@/state/useServerSettings';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
type OpencodeAuthInfo = { authenticated: boolean; providers: string[] };
type StoredApiKeys = { keys: { env: string; value: string }[] };
type LocalProviderEntry = {
id: string;
name: string;
url: string;
apiType: 'ollama' | 'openai-compatible' | 'lmstudio';
auth?: { type: 'api-key' | 'basic' };
};
type ProbeResult = {
success: boolean;
apiType?: LocalProviderEntry['apiType'];
name?: string;
needsAuth?: boolean;
authType?: 'api-key' | 'basic' | 'unknown';
models?: string[];
error?: string;
};
const PI_PROVIDERS: { key: string; env: string[] }[] = [
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
{ key: 'MiniMax', env: ['MINIMAX_API_KEY'] },
{ key: 'Groq', env: ['GROQ_API_KEY'] },
{ key: 'Mistral', env: ['MISTRAL_API_KEY'] },
{ key: 'xAI', env: ['XAI_API_KEY'] },
{ key: 'OpenRouter', env: ['OPENROUTER_API_KEY'] },
{ key: 'Hugging Face', env: ['HF_TOKEN'] },
{ key: 'GitHub Copilot', env: ['COPILOT_GITHUB_TOKEN'] },
{ key: 'Amazon Bedrock', env: ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION'] },
{ key: 'Google Vertex AI', env: ['GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION'] },
{ key: 'Azure OpenAI', env: ['AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_BASE_URL'] },
{ key: 'Anthropic', env: ['ANTHROPIC_API_KEY'] },
];
const TEXT_FIELDS = new Set([
'AWS_REGION',
'GOOGLE_APPLICATION_CREDENTIALS',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION',
'AZURE_OPENAI_BASE_URL',
]);
type ProbeState =
| { step: 'url' }
| { step: 'probing' }
| { step: 'auth'; probe: ProbeResult }
| { step: 'saving' };
export const AIHarnessesSection = () => {
const client = useClient();
const queryClient = useQueryClient();
const { aiHarnesses, saveSettings } = useServerSettings();
const [installing, setInstalling] = useState<{ claudeCode: boolean; opencode: boolean; piMono: boolean }>({
claudeCode: false,
opencode: false,
piMono: false,
});
const [copied, setCopied] = useState<string | null>(null);
const { data: opencodeVersion, isLoading: opencodeLoading } = useQuery({
queryKey: ['OPENCODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/opencode/version'),
enabled: !!aiHarnesses?.opencode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const { data: claudeVersion, isLoading: claudeLoading } = useQuery({
queryKey: ['CLAUDE_CODE_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/claude-code/version'),
enabled: !!aiHarnesses?.claudeCode,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const [installing, setInstalling] = useState(false);
const { data: piMonoVersion, isLoading: piMonoLoading } = useQuery({
queryKey: ['PI_MONO_VERSION'],
queryFn: () => client.get<VersionInfo>('/server-settings/pi-mono/version'),
enabled: !!aiHarnesses?.piMono,
refetchInterval: (query) => {
const data = query.state.data;
return data?.version && !data?.globalPath ? 1000 : false;
},
});
const { data: opencodeAuth } = useQuery({
queryKey: ['OPENCODE_AUTH'],
queryFn: () => client.get<OpencodeAuthInfo>('/server-settings/opencode/auth'),
enabled: !!opencodeVersion?.version,
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
const { data: piMonoKeys } = useQuery({
queryKey: ['PI_MONO_API_KEYS'],
queryFn: () => client.get<StoredApiKeys>('/server-settings/pi-mono/api-keys'),
enabled: !!piMonoVersion?.version,
});
const { data: claudeAuth } = useQuery({
queryKey: ['CLAUDE_CODE_AUTH'],
queryFn: () => client.get<ClaudeAuthInfo>('/server-settings/claude-code/auth'),
enabled: !!claudeVersion?.version,
refetchInterval: (query) => (!query.state.data?.authenticated ? 2000 : false),
const { data: localProviders = [] as LocalProviderEntry[] } = useQuery({
queryKey: ['PI_MONO_LOCAL_PROVIDERS'],
queryFn: () => client.get<LocalProviderEntry[]>('/server-settings/pi-mono/local-providers'),
enabled: !!piMonoVersion?.version,
});
const toggleHarness = (key: 'claudeCode' | 'opencode' | 'piMono', checked: boolean) => {
const updated = { ...aiHarnesses, [key]: checked };
saveSettings({ aiHarnesses: updated });
const { data: localHealth = {} as Record<string, boolean> } = useQuery({
queryKey: ['PI_MONO_LOCAL_HEALTH'],
queryFn: () => client.get<Record<string, boolean>>('/server-settings/pi-mono/local-providers/health'),
enabled: localProviders.length > 0,
refetchInterval: 15_000,
});
const [keyInputs, setKeyInputs] = useState<Record<string, string>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const [commandOpen, setCommandOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<string | null>(null);
// Local provider connection flow
const [addingLocal, setAddingLocal] = useState(false);
const [localName, setLocalName] = useState('');
const [localUrl, setLocalUrl] = useState('');
const [probeState, setProbeState] = useState<ProbeState>({ step: 'url' });
const [authApiKey, setAuthApiKey] = useState('');
const [authUsername, setAuthUsername] = useState('');
const [authPassword, setAuthPassword] = useState('');
const resetLocalForm = () => {
setAddingLocal(false);
setLocalName('');
setLocalUrl('');
setProbeState({ step: 'url' });
setAuthApiKey('');
setAuthUsername('');
setAuthPassword('');
};
const installClaude = async () => {
setInstalling((prev) => ({ ...prev, claudeCode: true }));
const storedEnvs = new Set(piMonoKeys?.keys.map((k: { env: string }) => k.env) ?? []);
const connectedProviders = PI_PROVIDERS.filter((p) => p.env.some((e) => storedEnvs.has(e)));
const unconnectedProviders = PI_PROVIDERS.filter((p) => !p.env.some((e) => storedEnvs.has(e)));
const getStoredMasked = (env: string) =>
piMonoKeys?.keys.find((k: { env: string; value: string }) => k.env === env)?.value ?? '';
const saveApiKey = async (env: string) => {
const value = keyInputs[env];
if (value === undefined) return;
setSavingKey(env);
try {
const result = await client.post<VersionInfo>('/server-settings/claude-code/install');
queryClient.setQueryData(['CLAUDE_CODE_VERSION'], result);
await client.put('/server-settings/pi-mono/api-keys', { key: env, value });
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
setKeyInputs((prev) => {
const next = { ...prev };
delete next[env];
return next;
});
} finally {
setInstalling((prev) => ({ ...prev, claudeCode: false }));
setSavingKey(null);
}
};
const installOpencode = async () => {
setInstalling((prev) => ({ ...prev, opencode: true }));
try {
const result = await client.post<VersionInfo>('/server-settings/opencode/install');
queryClient.setQueryData(['OPENCODE_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, opencode: false }));
const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => {
for (const env of provider.env) {
await client.put('/server-settings/pi-mono/api-keys', { key: env, value: '' });
}
queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] });
if (editingProvider === provider.key) setEditingProvider(null);
};
const installPiMono = async () => {
setInstalling((prev) => ({ ...prev, piMono: true }));
setInstalling(true);
try {
const result = await client.post<VersionInfo>('/server-settings/pi-mono/install');
queryClient.setQueryData(['PI_MONO_VERSION'], result);
} finally {
setInstalling((prev) => ({ ...prev, piMono: false }));
setInstalling(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(text);
setTimeout(() => setCopied(null), 1500);
const handleProbe = async (auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string }) => {
setProbeState({ step: 'probing' });
try {
const result = await client.post<ProbeResult>('/server-settings/pi-mono/local-providers/probe', {
url: localUrl.trim(),
auth,
});
if (!result.success) {
toast.error(result.error ?? 'Could not detect API type');
setProbeState({ step: 'url' });
return;
}
if (result.needsAuth) {
setProbeState({ step: 'auth', probe: result });
return;
}
// No auth needed — save directly
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
setProbeState({ step: 'url' });
}
};
const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const handleAuthSubmit = async (probe: ProbeResult) => {
const auth = probe.authType === 'basic'
? { type: 'basic' as const, username: authUsername, password: authPassword }
: { type: 'api-key' as const, apiKey: authApiKey };
const [confirmCommand, setConfirmCommand] = useState<{ command: string; refetchKeys: string[] } | null>(null);
// Re-probe with credentials to verify they work
setProbeState({ step: 'probing' });
try {
const result = await client.post<ProbeResult>('/server-settings/pi-mono/local-providers/probe', {
url: localUrl.trim(),
auth,
});
if (!result.success) {
toast.error(result.error ?? 'Could not connect with provided credentials');
setProbeState({ step: 'auth', probe });
return;
}
if (result.needsAuth) {
toast.error('Authentication failed');
setProbeState({ step: 'auth', probe });
return;
}
await saveLocalProvider(result, auth);
} catch {
toast.error('Failed to connect');
setProbeState({ step: 'auth', probe });
}
};
const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
<div className="mt-2 text-xs text-amber-600">
Not globally accessible. Run:
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
<button
type="button"
onClick={() => setConfirmCommand({ command, refetchKeys })}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Run in terminal"
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
type="button"
onClick={() => copyToClipboard(command)}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
title="Copy command"
>
{copied === command ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : (
<Copy className="h-3.5 w-3.5 text-duck-dark/50" />
)}
</button>
</div>
</div>
);
const saveLocalProvider = async (
probe: ProbeResult,
auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string },
) => {
setProbeState({ step: 'saving' });
try {
await client.post('/server-settings/pi-mono/local-providers', {
url: localUrl.trim(),
name: localName.trim() || probe.name,
apiType: probe.apiType,
auth,
});
queryClient.invalidateQueries({ queryKey: ['PI_MONO_LOCAL_PROVIDERS'] });
toast.success(`Connected to ${probe.name}`);
resetLocalForm();
} catch {
toast.error('Failed to save provider');
setProbeState({ step: 'url' });
}
};
const removeLocalProvider = async (id: string) => {
await client.delete(`/server-settings/pi-mono/local-providers/${id}`);
queryClient.invalidateQueries({ queryKey: ['PI_MONO_LOCAL_PROVIDERS'] });
};
return (
<>
<div className="flex flex-col gap-4">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={!!aiHarnesses?.opencode}
onCheckedChange={(checked) => toggleHarness('opencode', !!checked)}
/>
<span className="text-sm font-medium text-duck-dark">Opencode</span>
</label>
{aiHarnesses?.opencode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{opencodeLoading ? (
'Checking version...'
) : opencodeVersion?.version ? (
<>
<div>{opencodeVersion.version}</div>
<div>{opencodeVersion.path}</div>
{opencodeAuth && (
<div className={`mt-1 ${opencodeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{opencodeAuth.authenticated ? (
`Logged in (${opencodeAuth.providers.join(', ')})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/opencode/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!opencodeVersion.globalPath && opencodeVersion.path && (
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} refetchKeys={['OPENCODE_VERSION']} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installOpencode}
disabled={installing.opencode}
>
{installing.opencode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={!!aiHarnesses?.claudeCode}
onCheckedChange={(checked) => toggleHarness('claudeCode', !!checked)}
/>
<span className="text-sm font-medium text-duck-dark">Claude Code</span>
</label>
{aiHarnesses?.claudeCode && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{claudeLoading ? (
'Checking version...'
) : claudeVersion?.version ? (
<>
<div>{claudeVersion.version}</div>
<div>{claudeVersion.path}</div>
{claudeAuth && (
<div className={`mt-1 ${claudeAuth.authenticated ? 'text-green-600' : 'text-amber-600'}`}>
{claudeAuth.authenticated ? (
`Logged in (${claudeAuth.subscriptionType ?? 'unknown plan'})`
) : (
<div className="flex items-center gap-2">
<span>Not logged in</span>
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={() => client.post('/server-settings/claude-code/auth/login')}
>
Login
</Button>
</div>
)}
</div>
)}
{!claudeVersion.globalPath && claudeVersion.path && (
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} refetchKeys={['CLAUDE_CODE_VERSION']} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installClaude}
disabled={installing.claudeCode}
>
{installing.claudeCode ? 'Installing...' : 'Install'}
</Button>
)}
</div>
)}
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={!!aiHarnesses?.piMono}
onCheckedChange={(checked) => toggleHarness('piMono', !!checked)}
/>
<span className="text-sm font-medium text-duck-dark">Pi</span>
</label>
{aiHarnesses?.piMono && (
<div className="ml-7 mt-2 text-xs text-duck-dark/50">
{piMonoLoading ? (
'Checking version...'
) : piMonoVersion?.version ? (
<>
<div>{piMonoVersion.version}</div>
<div>{piMonoVersion.path}</div>
{!piMonoVersion.globalPath && piMonoVersion.path && (
<CopyCommand command={`sudo ln -s ${piMonoVersion.path} /usr/local/bin/pi`} refetchKeys={['PI_MONO_VERSION']} />
)}
</>
) : (
<Button
size="sm"
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-duck-dark">Pi Mono</span>
{piMonoLoading ? (
<span className="text-xs text-duck-dark/50">Checking...</span>
) : piMonoVersion?.version ? (
<div className="flex items-center gap-2">
<span className="text-xs text-duck-dark/50">{piMonoVersion.version}</span>
<button
type="button"
onClick={installPiMono}
disabled={installing.piMono}
disabled={installing}
className="p-0.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Update Pi Mono"
>
{installing.piMono ? 'Installing...' : 'Install'}
</Button>
)}
<RefreshCw className={`h-3 w-3 text-duck-teal ${installing ? 'animate-spin' : ''}`} />
</button>
</div>
) : (
<Button
size="sm"
className="h-6 text-xs bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
onClick={installPiMono}
disabled={installing}
>
{installing ? 'Installing...' : 'Install'}
</Button>
)}
</div>
{piMonoVersion?.version && (
<>
{/* Local Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Local Providers</span>
<div className="mt-3 flex flex-col gap-2">
{localProviders.map((lp: LocalProviderEntry) => (
<div key={lp.id} className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${lp.id in localHealth ? (localHealth[lp.id] ? 'bg-green-500' : 'bg-red-500') : 'bg-duck-dark/20'}`}
title={lp.id in localHealth ? (localHealth[lp.id] ? 'Online' : 'Offline') : 'Checking...'}
/>
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">{lp.name}</span>
<span className="text-duck-dark/40 dark:text-foreground/40 truncate">{lp.url}</span>
<button
type="button"
onClick={() => removeLocalProvider(lp.id)}
className="shrink-0 p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Remove provider"
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
</div>
))}
{addingLocal ? (
<div className="flex flex-col gap-2 max-w-sm">
{/* Step 1: Name + URL inputs stacked */}
<Input
type="text"
className="h-8 text-xs"
placeholder="Name (e.g. My Ollama)"
value={localName}
onChange={(ev) => setLocalName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Escape') resetLocalForm();
}}
disabled={probeState.step !== 'url'}
autoFocus
/>
<Input
type="url"
className="h-8 text-xs"
placeholder="http://localhost:11434"
value={localUrl}
onChange={(ev) => setLocalUrl(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Escape') resetLocalForm();
if (ev.key === 'Enter' && localUrl.trim() && probeState.step === 'url') handleProbe();
}}
disabled={probeState.step !== 'url'}
/>
<div className="flex items-center gap-2">
{probeState.step === 'url' && (
<Button
type="button"
size="sm"
className="h-7 text-xs bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!localUrl.trim()}
onClick={() => handleProbe()}
>
Connect
</Button>
)}
{(probeState.step === 'probing' || probeState.step === 'saving') && (
<Loader2 className="h-4 w-4 animate-spin text-duck-teal shrink-0" />
)}
<button
type="button"
onClick={resetLocalForm}
className="text-duck-dark/40 hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70 cursor-pointer transition-colors text-xs"
>
Cancel
</button>
</div>
{/* Step 2: Auth form (if needed) */}
{probeState.step === 'auth' && (
<div className="flex flex-col gap-2 rounded-md border border-duck-dark/10 dark:border-foreground/10 p-3">
<span className="text-duck-dark/70 dark:text-foreground/70 font-medium">
{probeState.probe.name} requires authentication
</span>
{probeState.probe.authType === 'basic' ? (
<>
<Input
type="text"
className="h-8 text-xs"
placeholder="Username"
value={authUsername}
onChange={(ev) => setAuthUsername(ev.target.value)}
autoFocus
/>
<Input
type="password"
className="h-8 text-xs"
placeholder="Password"
value={authPassword}
onChange={(ev) => setAuthPassword(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && authUsername && authPassword) handleAuthSubmit(probeState.probe);
}}
/>
<Button
type="button"
size="sm"
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authUsername || !authPassword}
onClick={() => handleAuthSubmit(probeState.probe)}
>
Authenticate
</Button>
</>
) : (
<>
<Input
type="password"
className="h-8 text-xs"
placeholder="API Key"
value={authApiKey}
onChange={(ev) => setAuthApiKey(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe);
}}
autoFocus
/>
<Button
type="button"
size="sm"
className="h-7 text-xs w-fit bg-duck-dark text-white hover:bg-duck-dark/90 dark:bg-foreground dark:text-background dark:hover:bg-foreground/90 cursor-pointer disabled:opacity-40"
disabled={!authApiKey}
onClick={() => handleAuthSubmit(probeState.probe)}
>
Authenticate
</Button>
</>
)}
</div>
)}
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit cursor-pointer"
onClick={() => setAddingLocal(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Local Provider
</Button>
)}
</div>
</div>
{/* Remote Providers */}
<div className="mt-6 text-xs text-duck-dark/50">
<span className="text-sm font-medium text-duck-dark dark:text-foreground">Remote Providers</span>
<div className="mt-3 flex flex-col gap-2">
{connectedProviders.map((provider) => (
<div key={provider.key} className="flex flex-col gap-1">
<div className="flex items-center gap-2 mt-1">
<span className="text-duck-dark/60 font-medium text-[11px]">{provider.key}</span>
<button
type="button"
onClick={() => disconnectProvider(provider)}
className="p-0.5 rounded hover:bg-red-500/10 cursor-pointer transition-colors"
title="Disconnect provider"
>
<Trash2 className="h-3 w-3 text-duck-dark/30 hover:text-red-500" />
</button>
</div>
{provider.env.map((env) => (
<div key={env} className="flex items-center gap-2">
<label className="w-52 text-duck-dark/70 shrink-0 truncate" title={env}>{env}</label>
<Input
type={TEXT_FIELDS.has(env) ? 'text' : 'password'}
className="h-7 text-xs flex-1"
placeholder={getStoredMasked(env) || 'Not set'}
value={keyInputs[env] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))}
/>
<button
type="button"
disabled={keyInputs[env] === undefined || savingKey === env}
onClick={() => saveApiKey(env)}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
</div>
))}
</div>
))}
{editingProvider && (() => {
const provider = PI_PROVIDERS.find((p) => p.key === editingProvider);
if (!provider || connectedProviders.includes(provider)) return null;
return (
<div key={provider.key} className="flex flex-col gap-1">
<span className="text-duck-dark/60 font-medium text-[11px] mt-1">{provider.key}</span>
{provider.env.map((env) => (
<div key={env} className="flex items-center gap-2">
<label className="w-52 text-duck-dark/70 shrink-0 truncate" title={env}>{env}</label>
<Input
type={TEXT_FIELDS.has(env) ? 'text' : 'password'}
className="h-7 text-xs flex-1"
placeholder="Not set"
value={keyInputs[env] ?? ''}
onChange={(ev) => setKeyInputs((prev) => ({ ...prev, [env]: ev.target.value }))}
autoFocus={env === provider.env[0]}
/>
<button
type="button"
disabled={keyInputs[env] === undefined || savingKey === env}
onClick={() => saveApiKey(env)}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-default"
title="Save key"
>
<Save className="h-3.5 w-3.5 text-duck-teal" />
</button>
</div>
))}
</div>
);
})()}
{unconnectedProviders.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1 w-fit cursor-pointer"
onClick={() => setCommandOpen(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Connect Provider
</Button>
)}
</div>
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Search providers..." />
<CommandList>
<CommandEmpty>No providers found.</CommandEmpty>
<CommandGroup heading="Available Providers">
{unconnectedProviders.map((provider) => (
<CommandItem
key={provider.key}
onSelect={() => {
setEditingProvider(provider.key);
setCommandOpen(false);
}}
>
<span>{provider.key}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
</>
)}
</div>
</div>
<AlertDialog open={!!confirmCommand} onOpenChange={(open) => !open && setConfirmCommand(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Run with elevated privileges</AlertDialogTitle>
<AlertDialogDescription>
You are about to run a command with elevated privileges (sudo). Are you sure?
</AlertDialogDescription>
</AlertDialogHeader>
<code className="text-xs bg-duck-dark/5 rounded px-3 py-2 text-duck-dark/70 break-all">{confirmCommand?.command}</code>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold"
onClick={() => {
if (confirmCommand) setRunCommand(confirmCommand);
setConfirmCommand(null);
}}
>
Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -131,7 +131,7 @@ export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) =
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2>
</div>
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p>
<div className="max-w-xl">{section.content}</div>
<div>{section.content}</div>
</div>
);
};
@@ -1,14 +1,11 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback, type DragEvent } from 'react';
import { toast } from 'sonner';
import { Terminal, Eye, Trash2, Bot, Server, Puzzle, Settings, X } from 'lucide-react';
import { Terminal, Eye, Bot, Settings, X, Plus } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
@@ -19,34 +16,19 @@ import { appRegistry } from '../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from '@/state/useSettings';
import { useUserState } from '@/state/useUserState';
import {
useClaudeModels,
useOpenCodeModels,
usePiMonoModels,
useVisibleClaudeModels,
useVisibleOpenCodeModels,
useVisiblePiMonoModels,
} from '@/state/useModels';
import { usePiMonoModels, useVisiblePiMonoModels } from '@/state/useModels';
import type { UserSettings } from '@/state/types/user-settings';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
import { PluginsSection } from './ServerSettings/PluginsSection';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './ServerSettings/run-command-channel';
const groups: SettingsSectionGroup[] = [
{
label: 'Server',
icon: Server,
sections: [
{ key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: <AIHarnessesSection /> },
{ key: 'plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable plugins', content: <PluginsSection /> },
],
},
{
label: 'AI',
icon: Bot,
sections: [
{ key: 'ai-harnesses', icon: Terminal, title: 'Providers', description: 'Remote and local AI providers', content: <AIHarnessesSection /> },
{ key: 'model-visibility', icon: Eye, title: 'Models', description: 'Enable or disable models', content: <ModelVisibilitySection /> },
{ key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: <ChatDefaultsSection /> },
{ key: 'model-visibility', icon: Eye, title: 'Model Visibility', description: 'Enable or disable models', content: <ModelVisibilitySection /> },
],
},
];
@@ -164,8 +146,6 @@ export const SystemSettings = () => {
function ChatDefaultsSection() {
const { settings, saveSettings } = useSettings();
const claudeModels = useVisibleClaudeModels();
const openCodeModels = useVisibleOpenCodeModels();
const piMonoModels = useVisiblePiMonoModels();
const [isSaving, setIsSaving] = useState(false);
@@ -181,25 +161,13 @@ function ChatDefaultsSection() {
setDefaultPwd(settings.chat.defaultPwd);
}, [settings]);
const allModels = useMemo(
() => [
...claudeModels.map((m) => ({ ...m, provider: 'Claude' })),
...openCodeModels.map((m) => ({ ...m, provider: m.provider ?? 'OpenCode' })),
...piMonoModels.map((m) => ({ ...m, provider: m.provider ?? 'Pi' })),
],
[claudeModels, openCodeModels, piMonoModels],
);
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
const isPiMono = piMonoModels.some((m) => m.id === model);
const isOpenCode = openCodeModels.some((m) => m.id === model);
const defaultProvider = isPiMono ? ('pi-mono' as const) : isOpenCode ? ('opencode' as const) : ('claude' as const);
const updated: UserSettings = {
...settings,
chat: { defaultProvider, defaultModel: model, systemPrompt, temperature, defaultPwd },
chat: { defaultProvider: 'pi-mono', defaultModel: model, systemPrompt, temperature, defaultPwd },
};
await saveSettings(updated);
toast.success('Chat defaults saved');
@@ -219,10 +187,10 @@ function ChatDefaultsSection() {
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent className="z-[600]">
{allModels.map((m) => (
<SelectItem key={`${m.provider}:${m.id}`} value={m.id}>
{piMonoModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
<span className="font-bold">{m.name}</span>
<span className="text-duck-dark/50 ml-1">({m.provider})</span>
{m.provider && <span className="text-duck-dark/50 ml-1">({m.provider})</span>}
</SelectItem>
))}
</SelectContent>
@@ -269,25 +237,81 @@ function ChatDefaultsSection() {
);
}
type ModelPillProps = {
label: string;
modelKey: string;
enabled: boolean;
onAction: (key: string) => void;
onDragStart: (ev: DragEvent, key: string) => void;
};
const ModelPill = ({ label, modelKey, enabled, onAction, onDragStart }: ModelPillProps) => (
<span
draggable
onDragStart={(ev) => onDragStart(ev, modelKey)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
>
{label}
<button
onClick={() => onAction(modelKey)}
className="ml-0.5 p-0.5 rounded-full hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
{enabled ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
</button>
</span>
);
type DropZoneProps = {
label: string;
children: React.ReactNode;
onDrop: (key: string) => void;
};
const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
const [over, setOver] = useState(false);
const handleDragOver = useCallback((ev: DragEvent) => {
ev.preventDefault();
setOver(true);
}, []);
const handleDragLeave = useCallback(() => setOver(false), []);
const handleDrop = useCallback(
(ev: DragEvent) => {
ev.preventDefault();
setOver(false);
const key = ev.dataTransfer.getData('text/plain');
if (key) onDrop(key);
},
[onDrop],
);
return (
<div className="grid gap-1.5">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">{label}</span>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`min-h-[48px] p-2 rounded-lg border border-dashed transition-colors flex flex-wrap gap-1.5 ${over ? 'border-duck-teal bg-duck-teal/5' : 'border-duck-dark/15 dark:border-foreground/15'}`}
>
{children}
</div>
</div>
);
};
function ModelVisibilitySection() {
const { settings, saveSettings } = useSettings();
const claudeModels = useClaudeModels();
const openCodeModels = useOpenCodeModels();
const piMonoModels = usePiMonoModels();
const [subTab, setSubTab] = useUserState('ai-settings-visibility-tab', 'claude');
const [activeProvider, setActiveProvider] = useUserState<string>('model-visibility-provider', '');
const enabledModels = settings.ai?.enabledModels ?? [];
const enabledProviders = settings.ai?.enabledProviders ?? [];
const toggleModel = async (key: string) => {
const isEnabled = enabledModels.includes(key);
const newEnabled = isEnabled ? enabledModels.filter((id) => id !== key) : [...enabledModels, key];
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: newEnabled } });
};
const buildProviderGroups = (models: { id: string; name: string; provider?: string }[]) => {
const providerGroups = useMemo(() => {
const groups: Record<string, { id: string; name: string }[]> = {};
for (const m of models) {
for (const m of piMonoModels) {
const provider = m.provider ?? 'Other';
if (!groups[provider]) groups[provider] = [];
groups[provider].push({ id: m.id, name: m.name });
@@ -295,250 +319,88 @@ function ModelVisibilitySection() {
return Object.entries(groups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, models]) => ({ provider, models: models.sort((a, b) => a.name.localeCompare(b.name)) }));
};
}, [piMonoModels]);
const ocGroups = useMemo(() => buildProviderGroups(openCodeModels), [openCodeModels]);
const piGroups = useMemo(() => buildProviderGroups(piMonoModels), [piMonoModels]);
const providers = useMemo(() => providerGroups.map((g) => g.provider), [providerGroups]);
const [addingProvider, setAddingProvider] = useState(false);
const [selectedNewProvider, setSelectedNewProvider] = useState<string>('');
// Auto-select first provider if none selected or stale
const selected = providers.includes(activeProvider) ? activeProvider : providers[0] ?? '';
const currentGroups = subTab === 'opencode' ? ocGroups : piGroups;
const disabledProviders = currentGroups.filter((g) => !enabledProviders.includes(g.provider));
const currentGroup = providerGroups.find((g) => g.provider === selected);
const handleEnableProvider = async () => {
if (!selectedNewProvider) return;
await saveSettings({
...settings,
ai: { ...settings.ai, enabledProviders: [...enabledProviders, selectedNewProvider] },
});
setAddingProvider(false);
setSelectedNewProvider('');
};
const { enabled, disabled } = useMemo(() => {
if (!currentGroup) return { enabled: [], disabled: [] };
const en: { id: string; name: string; key: string }[] = [];
const dis: { id: string; name: string; key: string }[] = [];
for (const m of currentGroup.models) {
const key = `${currentGroup.provider}:${m.id}`;
if (enabledModels.includes(key)) {
en.push({ ...m, key });
} else {
dis.push({ ...m, key });
}
}
return { enabled: en, disabled: dis };
}, [currentGroup, enabledModels]);
const handleRemoveProvider = async (provider: string) => {
await saveSettings({
...settings,
ai: { ...settings.ai, enabledProviders: enabledProviders.filter((p) => p !== provider) },
});
};
return (
<Tabs value={subTab} onValueChange={setSubTab}>
<TabsList className="w-full mb-4">
<TabsTrigger value="claude" className="flex-1 cursor-pointer">
Claude
</TabsTrigger>
<TabsTrigger value="opencode" className="flex-1 cursor-pointer">
OpenCode
</TabsTrigger>
<TabsTrigger value="pi-mono" className="flex-1 cursor-pointer">
Pi
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
<div className="grid gap-1">
{claudeModels.map((m) => (
<label
key={m.id}
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-duck-dark/5 cursor-pointer"
>
<span className="text-sm text-duck-dark">{m.name}</span>
<Switch checked={enabledModels.includes(m.id)} onCheckedChange={() => toggleModel(m.id)} />
</label>
))}
</div>
</TabsContent>
<TabsContent value="opencode">
<ProviderGroupTab
groups={ocGroups}
enabledProviders={enabledProviders}
enabledModels={enabledModels}
disabledProviders={disabledProviders}
addingProvider={addingProvider}
selectedNewProvider={selectedNewProvider}
emptyLabel="No OpenCode models available."
onSetAddingProvider={setAddingProvider}
onSetSelectedNewProvider={setSelectedNewProvider}
onEnableProvider={handleEnableProvider}
onToggleModel={toggleModel}
onRemoveProvider={handleRemoveProvider}
/>
</TabsContent>
<TabsContent value="pi-mono">
<ProviderGroupTab
groups={piGroups}
enabledProviders={enabledProviders}
enabledModels={enabledModels}
disabledProviders={subTab === 'pi-mono' ? piGroups.filter((g) => !enabledProviders.includes(g.provider)) : disabledProviders}
addingProvider={addingProvider}
selectedNewProvider={selectedNewProvider}
emptyLabel="No Pi models available."
onSetAddingProvider={setAddingProvider}
onSetSelectedNewProvider={setSelectedNewProvider}
onEnableProvider={handleEnableProvider}
onToggleModel={toggleModel}
onRemoveProvider={handleRemoveProvider}
/>
</TabsContent>
</Tabs>
const enableModel = useCallback(
async (key: string) => {
if (enabledModels.includes(key)) return;
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: [...enabledModels, key] } });
},
[settings, enabledModels, saveSettings],
);
}
// --- Shared components ---
const disableModel = useCallback(
async (key: string) => {
await saveSettings({ ...settings, ai: { ...settings.ai, enabledModels: enabledModels.filter((id) => id !== key) } });
},
[settings, enabledModels, saveSettings],
);
type ProviderGroup = { provider: string; models: { id: string; name: string }[] };
const onDragStart = useCallback((ev: DragEvent, key: string) => {
ev.dataTransfer.setData('text/plain', key);
ev.dataTransfer.effectAllowed = 'move';
}, []);
type ProviderGroupTabProps = {
groups: ProviderGroup[];
enabledProviders: string[];
enabledModels: string[];
disabledProviders: ProviderGroup[];
addingProvider: boolean;
selectedNewProvider: string;
emptyLabel: string;
onSetAddingProvider: (v: boolean) => void;
onSetSelectedNewProvider: (v: string) => void;
onEnableProvider: () => void;
onToggleModel: (key: string) => void;
onRemoveProvider: (provider: string) => void;
};
const ProviderGroupTab = ({
groups,
enabledProviders,
enabledModels,
disabledProviders,
addingProvider,
selectedNewProvider,
emptyLabel,
onSetAddingProvider,
onSetSelectedNewProvider,
onEnableProvider,
onToggleModel,
onRemoveProvider,
}: ProviderGroupTabProps) => (
<>
<div className="flex justify-end items-center gap-2 mb-3">
{addingProvider ? (
<>
<Select value={selectedNewProvider} onValueChange={onSetSelectedNewProvider}>
<SelectTrigger className="h-9 flex-1 bg-background/60 border-duck-dark/20 text-duck-dark text-sm">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent className="z-[600]">
{disabledProviders.map((g) => (
<SelectItem key={g.provider} value={g.provider}>
{g.provider}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
disabled={!selectedNewProvider}
onClick={onEnableProvider}
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold disabled:opacity-50"
>
Enable
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onSetAddingProvider(true)}
disabled={disabledProviders.length === 0}
>
Add Provider
</Button>
)}
</div>
{groups.length === 0 ? (
<p className="text-sm text-duck-dark/40">{emptyLabel}</p>
) : (
<ProviderList
groups={groups}
enabledProviders={enabledProviders}
enabledModels={enabledModels}
onToggleModel={onToggleModel}
onRemoveProvider={onRemoveProvider}
/>
)}
</>
);
type ProviderListProps = {
groups: ProviderGroup[];
enabledProviders: string[];
enabledModels: string[];
onToggleModel: (key: string) => void;
onRemoveProvider: (provider: string) => void;
};
const ProviderList = ({
groups,
enabledProviders,
enabledModels,
onToggleModel,
onRemoveProvider,
}: ProviderListProps) => {
const [openProvider, setOpenProvider] = useUserState<string>('ai-settings-provider-accordion', '');
const enabled = groups.filter((g) => enabledProviders.includes(g.provider));
if (enabled.length === 0) {
return <p className="text-sm text-duck-dark/40">No providers enabled.</p>;
if (providerGroups.length === 0) {
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">No models available. Configure API keys in AI Settings.</p>;
}
return (
<Accordion type="single" collapsible value={openProvider} onValueChange={(v) => setOpenProvider(v ?? '')}>
{enabled.map((g) => (
<AccordionItem key={g.provider} value={g.provider}>
<AccordionTrigger className="py-2 px-3 text-sm font-medium text-duck-dark hover:no-underline [&>svg]:ml-1">
<span className="flex-1 text-left">{g.provider}</span>
<span className="text-xs opacity-60 mr-5">{g.models.length}</span>
<button
type="button"
onClick={(ev) => {
ev.stopPropagation();
onRemoveProvider(g.provider);
}}
className="p-1 text-duck-dark/30 hover:text-red-500 cursor-pointer transition-colors mr-3"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</AccordionTrigger>
<AccordionContent className="px-3 pb-2">
<div className="grid gap-1 max-h-52 overflow-y-auto">
{[...g.models]
.sort((a, b) => {
const aEnabled = enabledModels.includes(`${g.provider}:${a.id}`);
const bEnabled = enabledModels.includes(`${g.provider}:${b.id}`);
if (aEnabled !== bEnabled) return aEnabled ? -1 : 1;
return a.name.localeCompare(b.name);
})
.map((m) => {
const key = `${g.provider}:${m.id}`;
return (
<label
key={m.id}
className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-duck-dark/5 cursor-pointer"
>
<span className="text-sm text-duck-dark/70">{m.name}</span>
<Switch checked={enabledModels.includes(key)} onCheckedChange={() => onToggleModel(key)} />
</label>
);
})}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<div className="grid gap-4">
{/* Provider tabs */}
<div className="flex flex-wrap gap-1 border-b border-duck-dark/10 dark:border-foreground/10 pb-1">
{providers.map((p) => (
<button
key={p}
onClick={() => setActiveProvider(p)}
className={`px-3 py-1.5 text-sm font-medium rounded-t-md transition-colors cursor-pointer ${
p === selected
? 'bg-duck-teal/10 text-duck-teal border-b-2 border-duck-teal'
: 'text-duck-dark/50 dark:text-foreground/50 hover:text-duck-dark dark:hover:text-foreground hover:bg-duck-dark/5 dark:hover:bg-foreground/5'
}`}
>
{p}
</button>
))}
</div>
{/* Enabled section */}
<DropZone label="Enabled" onDrop={enableModel}>
{enabled.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span>}
{enabled.map((m) => (
<ModelPill key={m.key} label={m.name} modelKey={m.key} enabled onAction={disableModel} onDragStart={onDragStart} />
))}
</DropZone>
{/* Disabled section */}
<DropZone label="Disabled" onDrop={disableModel}>
{disabled.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span>}
{disabled.map((m) => (
<ModelPill key={m.key} label={m.name} modelKey={m.key} enabled={false} onAction={enableModel} onDragStart={onDragStart} />
))}
</DropZone>
</div>
);
};
}