Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import { Check, ChevronLeft, ChevronRight, Loader2, Pin, Search, Star } from 'lucide-react';
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import {
|
|
filterComposerModels,
|
|
resolveComposerEnterModelId,
|
|
resolvePinnedAndRecentModels,
|
|
type ComposerModelPrefEntry,
|
|
type ComposerModelPrefs,
|
|
type ComposerPickerModel,
|
|
} from '../../infrastructure/ai/composerPicker';
|
|
import type { AgentModelPreset, ProviderConfig } from '../../infrastructure/ai/types';
|
|
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
|
|
import { useProviderModelCatalog } from './useProviderModelCatalog';
|
|
|
|
export const COMPOSER_PROVIDER_PICKER_WIDTH = 260;
|
|
export const COMPOSER_MODEL_PICKER_WIDTH = 260;
|
|
|
|
export interface ComposerModelPickerProps {
|
|
providers?: ProviderConfig[];
|
|
selectedProviderId?: string;
|
|
selectedModelId?: string;
|
|
modelPresets?: AgentModelPreset[];
|
|
prefs: ComposerModelPrefs;
|
|
onSelectProviderModel?: (providerId: string, modelId: string, contextWindow?: number) => void;
|
|
onSelectModel?: (modelId: string) => void;
|
|
onTogglePinned: (entry: ComposerModelPrefEntry) => void;
|
|
}
|
|
|
|
const rowClassName =
|
|
'flex h-8 w-full items-center gap-2 px-2.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer';
|
|
|
|
const SectionLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
<div className="px-2.5 pt-1.5 pb-0.5 text-[10px] tracking-wide text-muted-foreground/45">
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
const ModelRow: React.FC<{
|
|
model: ComposerPickerModel;
|
|
selected: boolean;
|
|
pinned: boolean;
|
|
onSelect: () => void;
|
|
onTogglePinned: () => void;
|
|
pinLabel: string;
|
|
unpinLabel: string;
|
|
}> = ({ model, selected, pinned, onSelect, onTogglePinned, pinLabel, unpinLabel }) => (
|
|
<div className="group/row relative">
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={selected}
|
|
onClick={onSelect}
|
|
className={rowClassName}
|
|
>
|
|
{selected
|
|
? <Check size={11} className="text-primary shrink-0" />
|
|
: <span className="w-[11px] shrink-0" />}
|
|
<span className="min-w-0 flex-1 truncate text-foreground/88">{model.name}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={pinned ? unpinLabel : pinLabel}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onTogglePinned();
|
|
}}
|
|
className={`absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 transition-opacity ${
|
|
pinned
|
|
? 'text-amber-400/90 opacity-100'
|
|
: 'text-muted-foreground/45 opacity-0 group-hover/row:opacity-100 hover:text-foreground/70'
|
|
}`}
|
|
>
|
|
<Star size={11} fill={pinned ? 'currentColor' : 'none'} />
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
export const ComposerModelPicker: React.FC<ComposerModelPickerProps> = ({
|
|
providers = [],
|
|
selectedProviderId,
|
|
selectedModelId,
|
|
modelPresets = [],
|
|
prefs,
|
|
onSelectProviderModel,
|
|
onSelectModel,
|
|
onTogglePinned,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const hasProviders = providers.length > 0;
|
|
const [previewProviderId, setPreviewProviderId] = useState(
|
|
selectedProviderId || providers[0]?.id || '',
|
|
);
|
|
const [query, setQuery] = useState('');
|
|
const [view, setView] = useState<'models' | 'providers'>('models');
|
|
|
|
useEffect(() => {
|
|
if (selectedProviderId) setPreviewProviderId(selectedProviderId);
|
|
}, [selectedProviderId]);
|
|
|
|
const previewProvider = hasProviders
|
|
? providers.find((provider) => provider.id === previewProviderId) ?? providers[0]
|
|
: undefined;
|
|
const catalog = useProviderModelCatalog(previewProvider, hasProviders);
|
|
|
|
const models = useMemo<ComposerPickerModel[]>(() => {
|
|
if (hasProviders) return catalog.models;
|
|
return modelPresets.map((preset) => ({
|
|
id: preset.id,
|
|
name: preset.name,
|
|
description: preset.description,
|
|
}));
|
|
}, [catalog.models, hasProviders, modelPresets]);
|
|
|
|
const filtered = useMemo(() => filterComposerModels(models, query), [models, query]);
|
|
const grouped = useMemo(
|
|
() => resolvePinnedAndRecentModels({
|
|
models: filtered,
|
|
prefs,
|
|
providerId: previewProvider?.id,
|
|
allowMissing: Boolean(previewProvider?.id) && !query.trim(),
|
|
}),
|
|
[filtered, prefs, previewProvider?.id, query],
|
|
);
|
|
const pinnedKeys = useMemo(
|
|
() => new Set(
|
|
prefs.pinned
|
|
.filter((entry) => !previewProvider || !entry.providerId || entry.providerId === previewProvider.id)
|
|
.map((entry) => entry.modelId),
|
|
),
|
|
[prefs.pinned, previewProvider],
|
|
);
|
|
|
|
const trimmedQuery = query.trim();
|
|
const showCustom = Boolean(
|
|
hasProviders
|
|
&& trimmedQuery
|
|
&& !models.some((model) => model.id.toLowerCase() === trimmedQuery.toLowerCase()),
|
|
);
|
|
|
|
const selectModel = (modelId: string) => {
|
|
const contextWindow = models.find((model) => model.id === modelId)?.contextWindow;
|
|
if (hasProviders && previewProvider) {
|
|
onSelectProviderModel?.(previewProvider.id, modelId, contextWindow);
|
|
return;
|
|
}
|
|
onSelectModel?.(modelId);
|
|
};
|
|
|
|
const prefEntryFor = (modelId: string): ComposerModelPrefEntry => (
|
|
previewProvider ? { providerId: previewProvider.id, modelId } : { modelId }
|
|
);
|
|
|
|
if (hasProviders && view === 'providers') {
|
|
return (
|
|
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setView('models')}
|
|
className={rowClassName}
|
|
>
|
|
<ChevronLeft size={12} className="text-muted-foreground/60 shrink-0" />
|
|
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/70">
|
|
{t('ai.chat.providers')}
|
|
</span>
|
|
</button>
|
|
<div className="mx-2 my-1 border-t border-border/40" />
|
|
{providers.map((provider) => {
|
|
const isBound = provider.id === selectedProviderId;
|
|
return (
|
|
<button
|
|
key={provider.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={isBound}
|
|
onClick={() => {
|
|
setPreviewProviderId(provider.id);
|
|
setQuery('');
|
|
setView('models');
|
|
}}
|
|
className={rowClassName}
|
|
>
|
|
<ProviderIconBadge provider={provider} size="xs" />
|
|
<span className="min-w-0 flex-1 truncate text-foreground/88">{provider.name}</span>
|
|
{isBound && <Check size={11} className="text-primary shrink-0" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-[260px] max-w-[calc(100vw-16px)] py-1">
|
|
{hasProviders && previewProvider && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
aria-label={t('ai.chat.selectProvider')}
|
|
onClick={() => setView('providers')}
|
|
className={rowClassName}
|
|
>
|
|
<ProviderIconBadge provider={previewProvider} size="xs" />
|
|
<span className="min-w-0 flex-1 truncate text-foreground/88">{previewProvider.name}</span>
|
|
<ChevronRight size={12} className="text-muted-foreground/50 shrink-0" />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div className="px-2 pb-1">
|
|
<div className="flex h-7 items-center gap-1.5 rounded-md bg-muted/40 px-2">
|
|
<Search size={11} className="text-muted-foreground/50 shrink-0" />
|
|
<input
|
|
autoFocus
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && trimmedQuery) {
|
|
event.preventDefault();
|
|
const nextId = resolveComposerEnterModelId({
|
|
query: trimmedQuery,
|
|
models,
|
|
grouped,
|
|
filtered,
|
|
showCustom,
|
|
});
|
|
if (nextId) selectModel(nextId);
|
|
}
|
|
}}
|
|
placeholder={t('ai.chat.searchModels')}
|
|
className="h-full w-full bg-transparent text-[12px] text-foreground/88 outline-none placeholder:text-muted-foreground/40"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-h-[280px] overflow-y-auto">
|
|
{catalog.loading && (
|
|
<div className="flex h-8 items-center gap-1.5 px-2.5 text-[11px] text-muted-foreground/55">
|
|
<Loader2 size={11} className="animate-spin" />
|
|
{t('ai.chat.loadingModels')}
|
|
</div>
|
|
)}
|
|
|
|
{showCustom && (
|
|
<button
|
|
type="button"
|
|
onClick={() => selectModel(trimmedQuery)}
|
|
className={rowClassName}
|
|
>
|
|
<Pin size={11} className="text-muted-foreground/55 shrink-0" />
|
|
<span className="min-w-0 truncate text-foreground/85">
|
|
{t('ai.chat.useCustomModel').replace('{id}', trimmedQuery)}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{grouped.pinned.length > 0 && (
|
|
<>
|
|
<SectionLabel>{t('ai.chat.pinned')}</SectionLabel>
|
|
{grouped.pinned.map((model) => (
|
|
<ModelRow
|
|
key={`pin-${model.id}`}
|
|
model={model}
|
|
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
|
pinned
|
|
onSelect={() => selectModel(model.id)}
|
|
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
|
pinLabel={t('ai.chat.pinModel')}
|
|
unpinLabel={t('ai.chat.unpinModel')}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{grouped.recent.length > 0 && (
|
|
<>
|
|
<SectionLabel>{t('ai.chat.recent')}</SectionLabel>
|
|
{grouped.recent.map((model) => (
|
|
<ModelRow
|
|
key={`recent-${model.id}`}
|
|
model={model}
|
|
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
|
pinned={pinnedKeys.has(model.id)}
|
|
onSelect={() => selectModel(model.id)}
|
|
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
|
pinLabel={t('ai.chat.pinModel')}
|
|
unpinLabel={t('ai.chat.unpinModel')}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{(grouped.pinned.length > 0 || grouped.recent.length > 0) && grouped.rest.length > 0 && (
|
|
<SectionLabel>{t('ai.chat.models')}</SectionLabel>
|
|
)}
|
|
|
|
{grouped.rest.map((model) => (
|
|
<ModelRow
|
|
key={model.id}
|
|
model={model}
|
|
selected={model.id === selectedModelId && (!hasProviders || previewProvider?.id === selectedProviderId)}
|
|
pinned={pinnedKeys.has(model.id)}
|
|
onSelect={() => selectModel(model.id)}
|
|
onTogglePinned={() => onTogglePinned(prefEntryFor(model.id))}
|
|
pinLabel={t('ai.chat.pinModel')}
|
|
unpinLabel={t('ai.chat.unpinModel')}
|
|
/>
|
|
))}
|
|
|
|
{!catalog.loading && filtered.length === 0 && !showCustom && (
|
|
<div className="px-2.5 py-2 text-[11px] text-muted-foreground/50">
|
|
{catalog.error || t('ai.chat.noMatchingModels')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default React.memo(ComposerModelPicker);
|