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,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>
);
};
}