Files
openchamber/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx
T
Bohdan Triapitsyn 0079347edc refactor(settings): scope the settings project selector to settings
Picking a project in Settings called setActiveProject, which relocates
the chat, the session list, the file tree and the Git surface. Reading
another project's MCP servers or agents moved the user's whole app.

It had to, because the configuration stores resolved the directory
themselves from the active project and held one flat list. Each of them
now takes an explicit directory — omitted still means the active project,
so every caller outside Settings is unchanged — and keys loaded data by
directory next to a flat mirror of the active project. Chat, autocompletes
and pickers keep reading that mirror; a load for another directory writes
only the map. A failed load restores that directory's previous list.

Settings resolves its own directory through useSettingsDirectory, backed
by a session-local settingsProjectPath that follows the active project
until the user picks something else.
2026-08-22 20:31:31 +03:00

233 lines
8.7 KiB
TypeScript

import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Button } from '@/components/ui/button';
import { selectProvidersForDirectory, useConfigStore } from '@/stores/useConfigStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { cn } from '@/lib/utils';
import { SettingsProjectSelector } from '@/components/sections/shared/SettingsProjectSelector';
import { Icon } from "@/components/icon/Icon";
import { opencodeClient } from '@/lib/opencode/client';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { SETTINGS_PANEL_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
const ADD_PROVIDER_ID = '__add_provider__';
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;
};
interface ProvidersSidebarProps {
onItemSelect?: () => void;
}
export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
// 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));
const selectedProviderId = useConfigStore((state) => state.selectedProviderId);
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const [sourcesByProvider, setSourcesByProvider] = React.useState<Record<string, ProviderSources>>({});
const directory = React.useMemo(() => {
if (settingsDirectory) return settingsDirectory;
// tie refresh to active project changes (directory is stored in the client)
void activeProjectId;
return getCurrentDirectory();
}, [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]);
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)}` : '';
// 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}`, {
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);
};
void loadAllSources();
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]);
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className={`${SETTINGS_PANEL_TITLE_CLASS} mb-3`}>{t('settings.providers.sidebar.title')}</h2>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.providers.sidebar.total', { count: providers.length })}</span>
<Button size="sm"
variant="ghost"
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
onClick={() => {
setSelectedProvider(ADD_PROVIDER_ID);
onItemSelect?.();
}}
aria-label={t('settings.providers.sidebar.actions.connectProviderAria')}
title={t('settings.providers.sidebar.actions.connectProviderTitle')}
>
<Icon name="add" className="h-3.5 w-3.5" />
</Button>
</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">
<Icon name="stack" className="mx-auto mb-3 h-10 w-10 opacity-50" />
<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>
</div>
) : (
<>
{userProviders.length > 0 && (
<>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('settings.providers.sidebar.section.userProviders')}
</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')}>
{t('settings.providers.sidebar.section.projectProviders')}
</div>
{projectProviders.map((provider) => (
<ProviderListItem
key={provider.id}
provider={provider}
selectedProviderId={selectedProviderId}
onSelect={() => {
setSelectedProvider(provider.id);
onItemSelect?.();
}}
/>
))}
</>
)}
</>
)}
</ScrollableOverlay>
</div>
);
};
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>
);
};