2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
2025-12-27 02:22:59 +02:00
|
|
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
2026-03-20 01:01:03 +02:00
|
|
|
import { Button } from '@/components/ui/button';
|
2026-08-22 19:50:16 +03:00
|
|
|
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
|
|
|
|
|
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
2026-02-24 03:28:30 +02:00
|
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
2025-12-27 02:22:59 +02:00
|
|
|
import { cn } from '@/lib/utils';
|
2026-02-24 03:28:30 +02:00
|
|
|
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-02-24 03:28:30 +02:00
|
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
2026-07-18 00:11:05 +03:00
|
|
|
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
2025-12-27 02:22:59 +02:00
|
|
|
|
|
|
|
|
const ADD_PROVIDER_ID = '__add_provider__';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-02-24 03:28:30 +02:00
|
|
|
interface ProviderSourceInfo {
|
|
|
|
|
exists: boolean;
|
|
|
|
|
path?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProviderSources {
|
|
|
|
|
auth: ProviderSourceInfo;
|
|
|
|
|
user: ProviderSourceInfo;
|
|
|
|
|
project: ProviderSourceInfo;
|
|
|
|
|
custom?: ProviderSourceInfo;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getCurrentDirectory = (): string | null => {
|
|
|
|
|
const dir = opencodeClient.getDirectory();
|
|
|
|
|
if (typeof dir === 'string' && dir.trim().length > 0) {
|
|
|
|
|
return dir.trim();
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-28 02:17:09 +02:00
|
|
|
interface ProvidersSidebarProps {
|
|
|
|
|
onItemSelect?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-08-22 19:50:16 +03:00
|
|
|
// Settings browses whichever project its own selector points at; the app
|
|
|
|
|
// stays where it is.
|
|
|
|
|
const settingsDirectory = useSettingsDirectory();
|
|
|
|
|
const providers = useConfigStore((state) => selectProvidersForDirectory(state, settingsDirectory));
|
2025-12-27 02:22:59 +02:00
|
|
|
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
|
|
|
|
|
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
2026-02-24 03:28:30 +02:00
|
|
|
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
|
|
|
|
const [sourcesByProvider, setSourcesByProvider] = React.useState<Record<string, ProviderSources>>({});
|
|
|
|
|
const directory = React.useMemo(() => {
|
2026-08-22 19:50:16 +03:00
|
|
|
if (settingsDirectory) return settingsDirectory;
|
2026-02-24 03:28:30 +02:00
|
|
|
// tie refresh to active project changes (directory is stored in the client)
|
|
|
|
|
void activeProjectId;
|
|
|
|
|
return getCurrentDirectory();
|
2026-08-22 19:50:16 +03:00
|
|
|
}, [activeProjectId, settingsDirectory]);
|
|
|
|
|
|
|
|
|
|
// The app only loads providers for the project it is on; Settings has to ask
|
|
|
|
|
// for the one it is looking at.
|
|
|
|
|
const loadProviders = useConfigStore((state) => state.loadProviders);
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!settingsDirectory) return;
|
|
|
|
|
void loadProviders({ directory: settingsDirectory, source: 'settings:providers' });
|
|
|
|
|
}, [loadProviders, settingsDirectory]);
|
2026-02-24 03:28:30 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (providers.length === 0) {
|
|
|
|
|
setSourcesByProvider({});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
const loadAllSources = async () => {
|
|
|
|
|
const tasks = providers.map(async (provider) => {
|
|
|
|
|
try {
|
|
|
|
|
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
2026-06-02 00:43:05 +03:00
|
|
|
// OpenChamber-only metadata endpoint: the SDK exposes provider data but
|
|
|
|
|
// not local auth/source-file provenance used by this settings sidebar.
|
|
|
|
|
const response = await runtimeFetch(`/api/provider/${encodeURIComponent(provider.id)}/source${query}`, {
|
2026-02-24 03:28:30 +02:00
|
|
|
method: 'GET',
|
|
|
|
|
headers: { Accept: 'application/json' },
|
|
|
|
|
});
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
const sources = (payload?.sources ?? payload?.data?.sources) as ProviderSources | undefined;
|
|
|
|
|
if (!sources) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSourcesByProvider((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[provider.id]: sources,
|
|
|
|
|
}));
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await Promise.all(tasks);
|
|
|
|
|
};
|
2025-12-27 02:22:59 +02:00
|
|
|
|
2026-02-24 03:28:30 +02:00
|
|
|
void loadAllSources();
|
2025-12-28 03:28:13 +02:00
|
|
|
|
2026-02-24 03:28:30 +02:00
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [directory, providers]);
|
|
|
|
|
|
|
|
|
|
const bgClass = 'bg-background';
|
|
|
|
|
|
|
|
|
|
const projectProviders = React.useMemo(() => {
|
|
|
|
|
return providers.filter((p) => Boolean(sourcesByProvider[p.id]?.project?.exists));
|
|
|
|
|
}, [providers, sourcesByProvider]);
|
|
|
|
|
|
|
|
|
|
const userProviders = React.useMemo(() => {
|
|
|
|
|
return providers.filter((p) => !sourcesByProvider[p.id]?.project?.exists);
|
|
|
|
|
}, [providers, sourcesByProvider]);
|
2025-12-28 03:28:13 +02:00
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
return (
|
2025-12-28 03:28:13 +02:00
|
|
|
<div className={cn('flex h-full flex-col', bgClass)}>
|
2026-02-24 03:28:30 +02:00
|
|
|
<div className="border-b px-3 pt-4 pb-3">
|
2026-07-18 00:11:05 +03:00
|
|
|
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.providers.sidebar.title')}</h2>
|
2026-02-24 03:28:30 +02:00
|
|
|
<SettingsProjectSelector className="mb-3" />
|
2025-12-27 02:22:59 +02:00
|
|
|
<div className="flex items-center justify-between gap-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
<span className="typography-meta text-muted-foreground">{t('settings.providers.sidebar.total', { count: providers.length })}</span>
|
2026-03-20 01:01:03 +02:00
|
|
|
<Button size="sm"
|
2025-12-28 02:17:09 +02:00
|
|
|
variant="ghost"
|
2026-02-24 03:28:30 +02:00
|
|
|
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
2025-12-28 02:17:09 +02:00
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedProvider(ADD_PROVIDER_ID);
|
|
|
|
|
onItemSelect?.();
|
|
|
|
|
}}
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={t('settings.providers.sidebar.actions.connectProviderAria')}
|
|
|
|
|
title={t('settings.providers.sidebar.actions.connectProviderTitle')}
|
2025-12-28 02:17:09 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="add" className="h-3.5 w-3.5" />
|
2026-03-20 01:01:03 +02:00
|
|
|
</Button>
|
2025-12-27 02:22:59 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
|
|
|
|
|
{providers.length === 0 ? (
|
|
|
|
|
<div className="py-12 px-4 text-center text-muted-foreground">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="stack" className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
2026-04-26 14:03:39 +03:00
|
|
|
<p className="typography-ui-label font-medium">{t('settings.providers.sidebar.empty.title')}</p>
|
|
|
|
|
<p className="typography-meta mt-1 opacity-75">{t('settings.providers.sidebar.empty.description')}</p>
|
2025-12-27 02:22:59 +02:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-02-24 03:28:30 +02:00
|
|
|
<>
|
|
|
|
|
{userProviders.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.providers.sidebar.section.userProviders')}
|
2026-02-24 03:28:30 +02:00
|
|
|
</div>
|
|
|
|
|
{userProviders.map((provider) => (
|
|
|
|
|
<ProviderListItem
|
|
|
|
|
key={provider.id}
|
|
|
|
|
provider={provider}
|
|
|
|
|
selectedProviderId={selectedProviderId}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedProvider(provider.id);
|
|
|
|
|
onItemSelect?.();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{projectProviders.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div className={cn('px-2 pb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground', userProviders.length > 0 ? 'pt-3' : 'pt-2')}>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('settings.providers.sidebar.section.projectProviders')}
|
2026-02-24 03:28:30 +02:00
|
|
|
</div>
|
|
|
|
|
{projectProviders.map((provider) => (
|
|
|
|
|
<ProviderListItem
|
|
|
|
|
key={provider.id}
|
|
|
|
|
provider={provider}
|
|
|
|
|
selectedProviderId={selectedProviderId}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedProvider(provider.id);
|
|
|
|
|
onItemSelect?.();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2025-12-27 02:22:59 +02:00
|
|
|
)}
|
|
|
|
|
</ScrollableOverlay>
|
|
|
|
|
</div>
|
2025-12-07 19:32:53 +02:00
|
|
|
);
|
|
|
|
|
};
|
2026-02-24 03:28:30 +02:00
|
|
|
|
|
|
|
|
const ProviderListItem: React.FC<{
|
|
|
|
|
provider: { id: string; name?: string; models?: unknown[] };
|
|
|
|
|
selectedProviderId: string;
|
|
|
|
|
onSelect: () => void;
|
|
|
|
|
}> = ({ provider, selectedProviderId, onSelect }) => {
|
|
|
|
|
const modelCount = Array.isArray(provider.models) ? provider.models.length : 0;
|
|
|
|
|
const isSelected = provider.id === selectedProviderId;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={provider.id}
|
|
|
|
|
className={cn(
|
|
|
|
|
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
|
|
|
|
|
isSelected ? 'bg-interactive-selection' : 'hover:bg-interactive-hover'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onSelect}
|
|
|
|
|
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
>
|
|
|
|
|
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
|
|
|
|
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
|
|
|
|
|
{provider.name || provider.id}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">
|
|
|
|
|
{modelCount}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|