mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-29 16:12:53 +08:00
fix: minimize clutter in /models
This commit is contained in:
@@ -2,9 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Check,
|
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
Clipboard,
|
|
||||||
Coins,
|
Coins,
|
||||||
Cpu,
|
Cpu,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -59,19 +57,6 @@ type ModelOption = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatUpdatedAt = (value?: string) => {
|
|
||||||
if (!value) {
|
|
||||||
return 'Not cached yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
|
||||||
return 'Not cached yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.toLocaleString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
cursor: 'Cursor',
|
cursor: 'Cursor',
|
||||||
@@ -246,7 +231,6 @@ function HelpContent({ data }: { data: HelpCommandData }) {
|
|||||||
function ModelsContent({
|
function ModelsContent({
|
||||||
data,
|
data,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
providerModelCacheCatalog,
|
|
||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
onHardRefreshProviderModels,
|
onHardRefreshProviderModels,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -254,14 +238,12 @@ function ModelsContent({
|
|||||||
}: {
|
}: {
|
||||||
data: ModelCommandData;
|
data: ModelCommandData;
|
||||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||||
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
|
||||||
providerModelsRefreshing: boolean;
|
providerModelsRefreshing: boolean;
|
||||||
onHardRefreshProviderModels: () => void;
|
onHardRefreshProviderModels: () => void;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
|
||||||
const [changingModel, setChangingModel] = useState<string | null>(null);
|
const [changingModel, setChangingModel] = useState<string | null>(null);
|
||||||
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
||||||
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
||||||
@@ -269,7 +251,6 @@ function ModelsContent({
|
|||||||
const currentModel = data?.current?.model || 'Unknown';
|
const currentModel = data?.current?.model || 'Unknown';
|
||||||
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
||||||
const liveDefinition = providerModelCatalog[currentProvider];
|
const liveDefinition = providerModelCatalog[currentProvider];
|
||||||
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
|
|
||||||
const availableOptions = useMemo<ModelOption[]>(() => {
|
const availableOptions = useMemo<ModelOption[]>(() => {
|
||||||
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
||||||
return liveDefinition.OPTIONS;
|
return liveDefinition.OPTIONS;
|
||||||
@@ -282,7 +263,6 @@ function ModelsContent({
|
|||||||
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
||||||
return availableModels.map((model) => ({ value: model, label: model }));
|
return availableModels.map((model) => ({ value: model, label: model }));
|
||||||
}, [data, liveDefinition]);
|
}, [data, liveDefinition]);
|
||||||
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
|
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
@@ -296,18 +276,8 @@ function ModelsContent({
|
|||||||
});
|
});
|
||||||
}, [availableOptions, query]);
|
}, [availableOptions, query]);
|
||||||
|
|
||||||
const activeOption = availableOptions.find((option) => option.value === currentModel);
|
|
||||||
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
||||||
|
const showSearch = availableOptions.length > 6;
|
||||||
const copyModel = (model: string) => {
|
|
||||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
||||||
void navigator.clipboard.writeText(model).catch(() => undefined);
|
|
||||||
}
|
|
||||||
setCopiedModel(model);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
setCopiedModel((current) => (current === model ? null : current));
|
|
||||||
}, 1300);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectModel = async (model: string) => {
|
const handleSelectModel = async (model: string) => {
|
||||||
setChangingModel(model);
|
setChangingModel(model);
|
||||||
@@ -330,162 +300,106 @@ function ModelsContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
{/* Compact context bar: active model + refresh, no clutter */}
|
||||||
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
Active model · {providerLabel}
|
||||||
{providerLabel}
|
</p>
|
||||||
</Badge>
|
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
|
||||||
{availableOptions.length} models
|
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||||
</Badge>
|
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
|
||||||
</div>
|
→ {pendingSessionModel} next
|
||||||
|
</span>
|
||||||
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
)}
|
||||||
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
|
</p>
|
||||||
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
|
|
||||||
{currentModel}
|
|
||||||
</p>
|
|
||||||
{activeOption?.label && activeOption.label !== currentModel && (
|
|
||||||
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
|
|
||||||
)}
|
|
||||||
{activeOption?.description && (
|
|
||||||
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
|
|
||||||
)}
|
|
||||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
|
||||||
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
|
|
||||||
Next response: {pendingSessionModel}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
|
||||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
|
||||||
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
|
||||||
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
|
||||||
Catalog Refresh
|
|
||||||
</p>
|
|
||||||
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
|
||||||
All providers
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
||||||
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
|
||||||
or when a new model is missing.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onHardRefreshProviderModels}
|
|
||||||
disabled={providerModelsRefreshing}
|
|
||||||
className="mt-2 h-8 w-full rounded-xl px-3"
|
|
||||||
>
|
|
||||||
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
|
||||||
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
|
||||||
{hasConcreteSessionId
|
|
||||||
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
|
||||||
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
|
||||||
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onHardRefreshProviderModels}
|
||||||
|
disabled={providerModelsRefreshing}
|
||||||
|
title="Refresh model list from providers"
|
||||||
|
aria-label="Refresh model list from providers"
|
||||||
|
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${providerModelsRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
|
{showSearch && (
|
||||||
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||||
<div className="min-w-0">
|
)}
|
||||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
|
|
||||||
{filteredOptions.length} shown
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredOptions.length > 0 ? (
|
{filteredOptions.length > 0 ? (
|
||||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{filteredOptions.map((option, index) => {
|
{filteredOptions.map((option, index) => {
|
||||||
const isCurrent = option.value === currentModel;
|
const isCurrent = option.value === currentModel;
|
||||||
const wasCopied = copiedModel === option.value;
|
const isPendingSelection = option.value === pendingSessionModel;
|
||||||
const isPendingSelection = option.value === pendingSessionModel;
|
const isChanging = option.value === changingModel;
|
||||||
const isChanging = option.value === changingModel;
|
return (
|
||||||
return (
|
<button
|
||||||
<div
|
key={option.value}
|
||||||
key={option.value}
|
type="button"
|
||||||
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
|
onClick={() => handleSelectModel(option.value)}
|
||||||
isCurrent
|
disabled={Boolean(changingModel)}
|
||||||
? 'border-primary/45 bg-primary/10'
|
aria-label={`Select model ${option.value}`}
|
||||||
: isPendingSelection
|
className={`settings-content-enter group flex min-h-[4rem] flex-col rounded-2xl border p-3 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60 ${
|
||||||
? 'border-emerald-500/35 bg-emerald-500/10'
|
isCurrent
|
||||||
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
? 'border-primary/45 bg-primary/10'
|
||||||
}`}
|
: isPendingSelection
|
||||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
? 'border-emerald-500/35 bg-emerald-500/10'
|
||||||
>
|
: 'border-border/70 bg-background/80 hover:border-primary/30 hover:bg-background'
|
||||||
<button
|
}`}
|
||||||
type="button"
|
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||||
onClick={() => handleSelectModel(option.value)}
|
>
|
||||||
disabled={Boolean(changingModel)}
|
<span className="flex items-center justify-between gap-2">
|
||||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||||
aria-label={`Use model ${option.value}`}
|
{isCurrent ? (
|
||||||
>
|
<BadgeCheck className="h-4 w-4 shrink-0 text-primary" />
|
||||||
<span className="flex items-center gap-2">
|
) : isChanging ? (
|
||||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||||
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{option.label && option.label !== option.value && (
|
{option.label && option.label !== option.value && (
|
||||||
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
|
||||||
)}
|
)}
|
||||||
{option.description && (
|
{option.description && (
|
||||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
|
<span className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||||
)}
|
)}
|
||||||
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
|
{isCurrent && (
|
||||||
{isPendingSelection && !isCurrent && (
|
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>
|
||||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
)}
|
||||||
Next response selection
|
{isPendingSelection && !isCurrent && (
|
||||||
</span>
|
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-500 dark:text-emerald-400">
|
||||||
)}
|
Applies next response
|
||||||
{isChanging && (
|
</span>
|
||||||
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
)}
|
||||||
Applying...
|
</button>
|
||||||
</span>
|
);
|
||||||
)}
|
})}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyModel(option.value)}
|
|
||||||
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
|
|
||||||
aria-label={`Copy model id ${option.value}`}
|
|
||||||
>
|
|
||||||
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No models match that search.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Single quiet line of guidance / feedback */}
|
||||||
|
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{selectionNotice ? (
|
||||||
|
<span className="text-foreground">{selectionNotice}</span>
|
||||||
|
) : hasConcreteSessionId ? (
|
||||||
|
'Your choice applies to this session on the next response.'
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-10 text-center text-sm text-muted-foreground">
|
'Your choice becomes the default model for new turns.'
|
||||||
No models match that search.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -606,7 +520,6 @@ export default function CommandResultModal({
|
|||||||
payload,
|
payload,
|
||||||
onClose,
|
onClose,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
providerModelCacheCatalog,
|
|
||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
onHardRefreshProviderModels,
|
onHardRefreshProviderModels,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -624,9 +537,9 @@ export default function CommandResultModal({
|
|||||||
icon: CircleHelp,
|
icon: CircleHelp,
|
||||||
},
|
},
|
||||||
models: {
|
models: {
|
||||||
eyebrow: 'Model inventory',
|
eyebrow: 'Model selection',
|
||||||
title: 'Available Models',
|
title: 'Choose a Model',
|
||||||
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
subtitle: 'Pick the model this provider should use.',
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
@@ -700,7 +613,6 @@ export default function CommandResultModal({
|
|||||||
<ModelsContent
|
<ModelsContent
|
||||||
data={payload.data as ModelCommandData}
|
data={payload.data as ModelCommandData}
|
||||||
providerModelCatalog={providerModelCatalog}
|
providerModelCatalog={providerModelCatalog}
|
||||||
providerModelCacheCatalog={providerModelCacheCatalog}
|
|
||||||
providerModelsRefreshing={providerModelsRefreshing}
|
providerModelsRefreshing={providerModelsRefreshing}
|
||||||
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
|
|||||||
Reference in New Issue
Block a user