refactor(provider-settings): model + provider lists

Signed-off-by: Siin Xu <31815270+SiinXu@users.noreply.github.com>
This commit is contained in:
Siin Xu
2026-07-02 20:39:29 -07:00
parent ab7263cd32
commit f3bbe79e69
18 changed files with 291 additions and 216 deletions

View File

@@ -169,7 +169,7 @@ export default function HealthCheckDrawer({
{isChecking ? (
<div className="px-4 pt-3 pb-2">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="font-medium text-[13px] text-foreground/85">
<span className="text-(length:--font-size-body-sm) font-medium text-foreground/85">
{t('settings.models.check.pipeline_heading')}
</span>
<span className={drawerClasses.healthProgressMeta}>
@@ -242,7 +242,7 @@ export default function HealthCheckDrawer({
rightCell = (
<Tooltip
content={
<span className="block max-w-full whitespace-pre-wrap text-left text-[12px] leading-snug">
<span className="text-(length:--font-size-body-xs) block max-w-full whitespace-pre-wrap text-left leading-snug">
{skipReasonText}
</span>
}
@@ -250,7 +250,7 @@ export default function HealthCheckDrawer({
classNames={{
placeholder: 'block min-w-0 w-full max-w-full overflow-hidden'
}}>
<span className="block w-full min-w-0 cursor-default truncate text-end text-[12px] text-muted-foreground/80">
<span className="text-(length:--font-size-body-xs) block w-full min-w-0 cursor-default truncate text-end text-muted-foreground/80">
{t('settings.models.check.status_skipped')}
</span>
</Tooltip>
@@ -258,7 +258,7 @@ export default function HealthCheckDrawer({
} else if (checking) {
statusCell = <Loader2 className="size-4 shrink-0 animate-spin text-warning" aria-hidden />
rightCell = (
<span className="shrink-0 font-medium text-[12px] text-warning">
<span className="text-(length:--font-size-body-xs) shrink-0 font-medium text-warning">
{t('settings.models.check.status_checking')}
</span>
)
@@ -266,16 +266,16 @@ export default function HealthCheckDrawer({
statusCell = (
<span className="mx-auto block size-1.5 shrink-0 rounded-full bg-muted-foreground/35" aria-hidden />
)
rightCell = <span className="shrink-0 text-[12px] text-muted-foreground/50" />
rightCell = <span className="text-(length:--font-size-body-xs) shrink-0 text-muted-foreground/50" />
} else if (status === HealthStatus.SUCCESS) {
statusCell = <CheckCircle2 className="size-4 shrink-0 text-muted-foreground/70" aria-hidden />
rightCell =
latency != null ? (
<span className="shrink-0 text-[12px] text-muted-foreground/80 tabular-nums">
<span className="text-(length:--font-size-body-xs) shrink-0 text-muted-foreground/80 tabular-nums">
{Math.round(latency)}ms
</span>
) : (
<span className="shrink-0 text-[12px] text-muted-foreground/80">
<span className="text-(length:--font-size-body-xs) shrink-0 text-muted-foreground/80">
{t('settings.models.check.passed')}
</span>
)
@@ -286,7 +286,7 @@ export default function HealthCheckDrawer({
errText !== '' ? (
<Tooltip
content={
<span className="block max-w-full whitespace-pre-wrap break-all text-left text-[12px] leading-snug">
<span className="text-(length:--font-size-body-xs) block max-w-full whitespace-pre-wrap break-all text-left leading-snug">
{errText}
</span>
}
@@ -294,12 +294,12 @@ export default function HealthCheckDrawer({
classNames={{
placeholder: 'block min-w-0 w-full max-w-full overflow-hidden'
}}>
<span className="block w-full min-w-0 cursor-default truncate text-end text-[12px] text-destructive/85">
<span className="text-(length:--font-size-body-xs) block w-full min-w-0 cursor-default truncate text-end text-destructive/85">
{errText}
</span>
</Tooltip>
) : (
<span className="shrink-0 text-[12px] text-destructive/85">
<span className="text-(length:--font-size-body-xs) shrink-0 text-destructive/85">
{t('settings.models.check.failed')}
</span>
)
@@ -316,11 +316,11 @@ export default function HealthCheckDrawer({
{Icon ? (
<Icon.Avatar size={22} />
) : (
<Avatar className="size-5.5 shrink-0 rounded-md text-[10px]">
<Avatar className="text-(length:--font-size-body-2xs) size-5.5 shrink-0 rounded-md">
<AvatarFallback className="rounded-md">{model.name?.[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
)}
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-foreground/85">
<span className="text-(length:--font-size-body-sm) min-w-0 flex-1 truncate font-mono text-foreground/85">
{model.name}
</span>
<div
@@ -388,7 +388,7 @@ export default function HealthCheckDrawer({
{keyCheckMode === 'single' && hasMultipleKeys ? (
<div className="space-y-3 rounded-xl border border-border-muted bg-muted/20 p-4">
<div className="font-medium text-[13px] text-foreground/85">
<div className="text-(length:--font-size-body-sm) font-medium text-foreground/85">
{t('settings.models.check.select_api_key')}
</div>
<RadioGroup
@@ -399,7 +399,9 @@ export default function HealthCheckDrawer({
key={`${key}-${index}`}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-2 py-1.5 hover:bg-accent/30">
<RadioGroupItem value={String(index)} size="sm" />
<span className="truncate font-mono text-[12px] text-foreground/70">{maskApiKey(key)}</span>
<span className="text-(length:--font-size-body-xs) truncate font-mono text-foreground/70">
{maskApiKey(key)}
</span>
</label>
))}
</RadioGroup>

View File

@@ -1,12 +1,13 @@
import {
Button,
DescriptionSwitch,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
SelectValue,
Switch,
Tooltip
} from '@cherrystudio/ui'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { useModelMutations } from '@renderer/hooks/useModel'
@@ -15,7 +16,7 @@ import { getDefaultGroupName } from '@renderer/utils/naming'
import { CURRENCY, type Currency, type EndpointType, type Model } from '@shared/data/types/model'
import { parseUniqueModelId } from '@shared/data/types/model'
import { isNewApiProvider } from '@shared/utils/provider'
import { ChevronDown, ChevronUp, SaveIcon } from 'lucide-react'
import { ChevronDown, ChevronUp, CircleHelp, SaveIcon } from 'lucide-react'
import type { FormEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -320,18 +321,20 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
endpointTypes
}}
showEndpointType={mode === 'new-api'}
horizontal
modelIdDisabled
modelIdAction={
<button
<Button
type="button"
variant="outline"
aria-label={t('message.copied')}
className={fieldClasses.iconButton}
className={fieldClasses.inputActionButton}
onClick={() => {
void navigator.clipboard.writeText(apiModelId)
window.toast.success(t('message.copied'))
}}>
<CopyIcon size={14} />
</button>
</Button>
}
endpointTypeError={endpointTypeTouched ? t('settings.models.add.endpoint_type.required') : undefined}
onModelIdChange={(value) => {
@@ -362,13 +365,18 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
{showMoreSettings && (
<ProviderSection className={drawerClasses.section}>
<div data-testid="provider-settings-model-more-settings" className="space-y-4">
<div className={drawerClasses.sectionCard}>
<ModelCapabilityToggles
selectedCaps={selectedCaps}
hasUserModified={hasUserModified}
onToggle={handleToggleCapability}
onReset={handleResetCapabilities}
/>
<div>
<div className="text-(length:--font-size-body-xs) mb-2 px-1 font-medium text-foreground">
{t('settings.models.add.section.capabilities')}
</div>
<div className={drawerClasses.sectionCard}>
<ModelCapabilityToggles
selectedCaps={selectedCaps}
hasUserModified={hasUserModified}
onToggle={handleToggleCapability}
onReset={handleResetCapabilities}
/>
</div>
</div>
<div className={drawerClasses.sectionCard}>
@@ -380,14 +388,21 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
onMaxInputTokensChange={setMaxInputTokens}
onMaxOutputTokensChange={setMaxOutputTokens}
/>
</div>
<div className={drawerClasses.sectionCard}>
<div className={drawerClasses.switchCard}>
<DescriptionSwitch
<div className="flex w-full items-center justify-between gap-3">
<div className={`flex min-w-0 items-center ${drawerClasses.fieldTitle}`}>
<label htmlFor="supports-text-delta-switch" className="cursor-pointer truncate">
{t('settings.models.add.supported_text_delta.label')}
</label>
<Tooltip content={t('settings.models.add.supported_text_delta.tooltip')} placement="top">
<CircleHelp
size={14}
className="ml-1.5 shrink-0 cursor-pointer text-foreground-muted [stroke-width:var(--icon-stroke)]"
/>
</Tooltip>
</div>
<Switch
id="supports-text-delta-switch"
size="sm"
label={t('settings.models.add.supported_text_delta.label')}
description={t('settings.models.add.supported_text_delta.tooltip')}
checked={supportsStreaming ?? false}
onCheckedChange={(checked) => {
setSupportsStreaming(checked)
@@ -397,9 +412,15 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
</div>
</div>
<div className={drawerClasses.sectionCard}>
<ProviderField title={t('models.price.currency')} titleClassName={drawerClasses.fieldTitle}>
<div className={drawerClasses.inlineRow}>
<div>
<div className="text-(length:--font-size-body-xs) mb-2 px-1 font-medium text-foreground">
{t('settings.models.add.section.pricing')}
</div>
<div className={drawerClasses.sectionCard}>
<ProviderField
title={t('models.price.currency')}
horizontal
titleClassName={drawerClasses.fieldTitle}>
<Select
value={currencySymbol}
onValueChange={(nextValue) => {
@@ -410,10 +431,10 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
setCurrencySymbol(nextValue)
autoSave({ currencySymbol: nextValue })
}}>
<SelectTrigger aria-label={t('models.price.currency')} className={drawerClasses.selectTrigger}>
<SelectTrigger aria-label={t('models.price.currency')}>
<SelectValue />
</SelectTrigger>
<SelectContent className={drawerClasses.selectContent}>
<SelectContent className="provider-settings-default-scope">
{MODEL_DRAWER_CURRENCY_SYMBOLS.map((symbol) => (
<SelectItem key={symbol} value={symbol}>
{symbol}
@@ -421,50 +442,58 @@ export default function EditModelDrawer({ providerId, open, model: modelProp, on
))}
</SelectContent>
</Select>
</div>
</ProviderField>
</ProviderField>
<ProviderField title={t('models.price.input')} titleClassName={drawerClasses.fieldTitle}>
<div className={drawerClasses.responsiveValueRow}>
<Input
type="number"
min="0"
step="0.01"
aria-label={t('models.price.input')}
value={inputPrice}
placeholder="0.00"
className={drawerClasses.input}
onChange={(event) => {
setInputPrice(event.target.value)
}}
onBlur={() => autoSave({ inputPrice })}
/>
<span className={drawerClasses.valueSuffix}>
{currentCurrency} / {t('models.price.million_tokens')}
</span>
</div>
</ProviderField>
<ProviderField
title={t('models.price.input')}
horizontal
controlClassName="w-48"
titleClassName={drawerClasses.fieldTitle}>
<div className={drawerClasses.responsiveValueRow}>
<Input
type="number"
min="0"
step="0.01"
aria-label={t('models.price.input')}
value={inputPrice}
placeholder="0.00"
className={drawerClasses.input}
onChange={(event) => {
setInputPrice(event.target.value)
}}
onBlur={() => autoSave({ inputPrice })}
/>
<span className={drawerClasses.valueSuffix}>
{currentCurrency} / {t('models.price.million_tokens')}
</span>
</div>
</ProviderField>
<ProviderField title={t('models.price.output')} titleClassName={drawerClasses.fieldTitle}>
<div className={drawerClasses.responsiveValueRow}>
<Input
type="number"
min="0"
step="0.01"
aria-label={t('models.price.output')}
value={outputPrice}
placeholder="0.00"
className={drawerClasses.input}
onChange={(event) => {
setOutputPrice(event.target.value)
}}
onBlur={() => autoSave({ outputPrice })}
/>
<span className={drawerClasses.valueSuffix}>
{currentCurrency} / {t('models.price.million_tokens')}
</span>
</div>
</ProviderField>
<ProviderField
title={t('models.price.output')}
horizontal
controlClassName="w-48"
titleClassName={drawerClasses.fieldTitle}>
<div className={drawerClasses.responsiveValueRow}>
<Input
type="number"
min="0"
step="0.01"
aria-label={t('models.price.output')}
value={outputPrice}
placeholder="0.00"
className={drawerClasses.input}
onChange={(event) => {
setOutputPrice(event.target.value)
}}
onBlur={() => autoSave({ outputPrice })}
/>
<span className={drawerClasses.valueSuffix}>
{currentCurrency} / {t('models.price.million_tokens')}
</span>
</div>
</ProviderField>
</div>
</div>
</div>
</ProviderSection>

View File

@@ -14,6 +14,7 @@ interface ModelBasicFieldsProps {
modelIdDisabled?: boolean
modelIdAction?: ReactNode
endpointTypeError?: string
horizontal?: boolean
onModelIdChange: (value: string) => void
onNameChange: (value: string) => void
onGroupChange: (value: string) => void
@@ -26,6 +27,7 @@ export function ModelBasicFields({
modelIdDisabled = false,
modelIdAction,
endpointTypeError,
horizontal = false,
onModelIdChange,
onNameChange,
onGroupChange,
@@ -38,8 +40,10 @@ export function ModelBasicFields({
<ProviderField
title={t('settings.models.add.model_id.label')}
titleClassName={drawerClasses.fieldTitle}
className={drawerClasses.field}>
<div className={drawerClasses.valueRow}>
className={drawerClasses.field}
horizontal={horizontal}
controlClassName={horizontal ? 'w-60' : undefined}>
<div className={cn(drawerClasses.valueRow, horizontal && 'w-full')}>
<Input
required
spellCheck={false}
@@ -58,7 +62,9 @@ export function ModelBasicFields({
<ProviderField
title={t('settings.models.add.model_name.label')}
titleClassName={drawerClasses.fieldTitle}
className={drawerClasses.field}>
className={drawerClasses.field}
horizontal={horizontal}
controlClassName={horizontal ? 'w-60' : undefined}>
<Input
spellCheck={false}
aria-label={t('settings.models.add.model_name.label')}
@@ -72,7 +78,9 @@ export function ModelBasicFields({
<ProviderField
title={t('settings.models.add.group_name.label')}
titleClassName={drawerClasses.fieldTitle}
className={drawerClasses.field}>
className={drawerClasses.field}
horizontal={horizontal}
controlClassName={horizontal ? 'w-60' : undefined}>
<Input
spellCheck={false}
aria-label={t('settings.models.add.group_name.label')}
@@ -88,6 +96,8 @@ export function ModelBasicFields({
title={t('settings.models.add.endpoint_type.label')}
titleClassName={drawerClasses.fieldTitle}
className={drawerClasses.field}
horizontal={horizontal}
controlClassName={horizontal ? 'w-60' : undefined}
help={endpointTypeError ? <div className={drawerClasses.errorText}>{endpointTypeError}</div> : null}>
<div data-testid="provider-settings-model-endpoint-type-field">
<ModelEndpointTypeChips value={values.endpointTypes ?? []} onChange={onEndpointTypesChange} />

View File

@@ -34,7 +34,7 @@ export function ModelCapabilityToggles({
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1 font-semibold text-foreground/90 text-sm leading-5">
<div className="text-(length:--font-size-body-xs) flex items-center gap-1 font-normal text-foreground leading-(--line-height-body-xs)">
{t('models.type.select')}
<WarnTooltip content={t('settings.moresetting.check.warn')} />
</div>

View File

@@ -26,8 +26,10 @@ export function ModelContextWindowFields({
<>
<ProviderField
title={t('settings.models.add.context_window.label')}
horizontal
className={drawerClasses.field}
titleClassName={drawerClasses.fieldTitle}
className={drawerClasses.field}>
controlClassName="w-32">
<Input
type="number"
min={1}
@@ -43,8 +45,10 @@ export function ModelContextWindowFields({
<ProviderField
title={t('settings.models.add.max_input_tokens.label')}
horizontal
className={drawerClasses.field}
titleClassName={drawerClasses.fieldTitle}
className={drawerClasses.field}>
controlClassName="w-32">
<Input
type="number"
min={1}
@@ -60,8 +64,10 @@ export function ModelContextWindowFields({
<ProviderField
title={t('settings.models.add.max_output_tokens.label')}
horizontal
className={drawerClasses.field}
titleClassName={drawerClasses.fieldTitle}
className={drawerClasses.field}>
controlClassName="w-32">
<Input
type="number"
min={1}

View File

@@ -1,4 +1,4 @@
import { Search, X } from 'lucide-react'
import { SearchInput } from '@cherrystudio/ui'
import type React from 'react'
import { useTranslation } from 'react-i18next'
@@ -55,26 +55,16 @@ const ModelListHeader: React.FC<ModelListHeaderProps> = ({
<div className={modelListClasses.titleRow}>
<div className="flex min-w-0 flex-1">
<div className={modelListClasses.titleWrap}>
<div className={modelListClasses.searchWrap}>
<Search className={modelListClasses.searchIcon} />
<input
type="text"
value={searchText}
placeholder={t('models.search.placeholder')}
disabled={isBusy}
onChange={(event) => setSearchText(event.target.value)}
className={modelListClasses.searchInput}
/>
{searchText ? (
<button
type="button"
onClick={() => setSearchText('')}
className={modelListClasses.searchClear}
aria-label={t('common.clear')}>
<X size={9} />
</button>
) : null}
</div>
<SearchInput
containerClassName={modelListClasses.searchWrap}
className={modelListClasses.searchInput}
value={searchText}
placeholder={t('models.search.placeholder')}
disabled={isBusy}
onChange={(event) => setSearchText(event.target.value)}
onClear={() => setSearchText('')}
clearLabel={t('common.clear')}
/>
{!hasNoModels ? (
<ModelListCapabilityChips
capabilityOptions={capabilityOptions}

View File

@@ -66,7 +66,7 @@ const ModelListItem: React.FC<ModelListItemProps> = ({ ref, model, disabled, onE
<button
type="button"
className={cn(
'inline-flex h-7 min-w-0 shrink items-center overflow-hidden text-ellipsis whitespace-nowrap text-left font-normal text-foreground/90 text-sm leading-none',
'text-(length:--font-size-body-md) inline-flex h-7 min-w-0 shrink items-center overflow-hidden text-ellipsis whitespace-nowrap text-left font-normal text-foreground/90 leading-none',
modelListClasses.rowNameCopyable
)}
onClick={handleEdit}>

View File

@@ -4,6 +4,13 @@ import { useTranslation } from 'react-i18next'
import ProviderSettingsDrawer from '../primitives/ProviderSettingsDrawer'
import { modelListClasses } from '../primitives/ProviderSettingsPrimitives'
import ModelListCapabilityChips from './ModelListCapabilityChips'
import {
getCapabilityModelCounts,
matchesCapabilityFilter,
MODEL_LIST_CAPABILITY_FILTERS,
type ModelListCapabilityFilter
} from './modelListDerivedState'
import ModelSyncPreviewPanel, { ModelSyncPreviewFooter } from './ModelSyncPreviewPanel'
import type { ModelSyncPreviewResponse } from './modelSyncPreviewTypes'
import { type ModelPullApplyPayload, useModelListSyncSelections } from './useModelListSyncSelections'
@@ -20,31 +27,50 @@ interface ModelListSyncDrawerProps {
export default function ModelListSyncDrawer({ open, preview, isApplying, onApply, onClose }: ModelListSyncDrawerProps) {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const [selectedCapabilityFilter, setSelectedCapabilityFilter] = useState<ModelListCapabilityFilter>('all')
const selections = useModelListSyncSelections(preview)
const searchActive = Boolean(searchText.trim())
const capabilityActive = selectedCapabilityFilter !== 'all'
const filterActive = searchActive || capabilityActive
const hasModels = !!preview && (preview.added.length > 0 || preview.missing.length > 0)
useEffect(() => {
setSearchText('')
setSelectedCapabilityFilter('all')
}, [open, preview])
// Capability counts span the full unfiltered preview (added + missing) so the chip badges
// reflect every model the sync touches, not just the search-narrowed subset.
const capabilityModelCounts = useMemo(
() => getCapabilityModelCounts(preview ? [...preview.added, ...preview.missing.map((item) => item.model)] : []),
[preview]
)
const filteredPreview = useMemo<ModelSyncPreviewResponse | null>(() => {
if (!preview || !searchActive) {
if (!preview || !filterActive) {
return preview
}
const added = filterProviderSettingModelsByKeywords(searchText, preview.added)
const visibleMissingModels = filterProviderSettingModelsByKeywords(
searchText,
preview.missing.map((item) => item.model)
const searchedAdded = searchActive
? filterProviderSettingModelsByKeywords(searchText, preview.added)
: preview.added
const added = searchedAdded.filter((model) => matchesCapabilityFilter(model, selectedCapabilityFilter))
const missingModels = preview.missing.map((item) => item.model)
const searchedMissing = searchActive
? filterProviderSettingModelsByKeywords(searchText, missingModels)
: missingModels
const visibleMissingIds = new Set(
searchedMissing
.filter((model) => matchesCapabilityFilter(model, selectedCapabilityFilter))
.map((model) => model.id)
)
const visibleMissingIds = new Set(visibleMissingModels.map((model) => model.id))
return {
added,
missing: preview.missing.filter((item) => visibleMissingIds.has(item.model.id))
}
}, [preview, searchActive, searchText])
}, [preview, filterActive, searchActive, searchText, selectedCapabilityFilter])
const handleApply = useCallback(() => {
const payload = selections.getApplyPayload()
@@ -75,32 +101,40 @@ export default function ModelListSyncDrawer({ open, preview, isApplying, onApply
{filteredPreview ? (
<>
{hasModels ? (
<div className={modelListClasses.searchWrap}>
<Search className={modelListClasses.searchIcon} />
<input
type="text"
value={searchText}
placeholder={t('models.search.placeholder')}
disabled={isApplying}
onChange={(event) => setSearchText(event.target.value)}
className={modelListClasses.searchInput}
<div className={modelListClasses.titleWrap}>
<div className={modelListClasses.searchWrap}>
<Search className={modelListClasses.searchIcon} />
<input
type="text"
value={searchText}
placeholder={t('models.search.placeholder')}
disabled={isApplying}
onChange={(event) => setSearchText(event.target.value)}
className={modelListClasses.searchInput}
/>
{searchText ? (
<button
type="button"
onClick={() => setSearchText('')}
className={modelListClasses.searchClear}
aria-label={t('common.clear')}>
<X size={9} />
</button>
) : null}
</div>
<ModelListCapabilityChips
capabilityOptions={MODEL_LIST_CAPABILITY_FILTERS}
selectedCapabilityFilter={selectedCapabilityFilter}
capabilityModelCounts={capabilityModelCounts}
onSelectCapabilityFilter={setSelectedCapabilityFilter}
/>
{searchText ? (
<button
type="button"
onClick={() => setSearchText('')}
className={modelListClasses.searchClear}
aria-label={t('common.clear')}>
<X size={9} />
</button>
) : null}
</div>
) : null}
<ModelSyncPreviewPanel
preview={filteredPreview}
selections={selections}
isApplying={isApplying}
searchActive={searchActive}
searchActive={filterActive}
/>
</>
) : null}

View File

@@ -136,7 +136,6 @@ export default function ModelSyncPreviewPanel({
</span>
<ModelGlyph model={model} />
<div className="min-w-0 flex-1">
<p className={modelSyncClasses.fetchRowTitle}>{model.name}</p>
<p className={modelSyncClasses.fetchRowId}>{modelIdLine(model.id, model.apiModelId)}</p>
</div>
<div className={modelSyncClasses.fetchCapabilityStrip}>
@@ -211,7 +210,6 @@ export default function ModelSyncPreviewPanel({
</span>
<ModelGlyph model={item.model} />
<div className="min-w-0 flex-1">
<p className={modelSyncClasses.fetchRowTitleStrike}>{item.model.name}</p>
<p className={modelSyncClasses.fetchRowIdStrike}>
{modelIdLine(item.model.id, item.model.apiModelId)}
</p>

View File

@@ -113,10 +113,12 @@ const PopupContainer: React.FC<Props> = ({ title, provider, resolve, batchModels
onCancel()
}
}}>
<DialogContent className="gap-5 rounded-2xl border-border-muted bg-popover p-5 sm:max-w-md">
<DialogContent className="provider-settings-default-scope gap-5 rounded-2xl border-(--color-border-fg-muted) bg-popover p-5 sm:max-w-md">
<DialogHeader className="gap-1.5 pr-6">
<DialogTitle className="text-foreground/90 text-sm leading-5">{title}</DialogTitle>
<DialogDescription className="text-muted-foreground/80 text-sm leading-5">
<DialogTitle className="text-(length:--font-size-body-md) text-foreground/90 leading-(--line-height-body-md)">
{title}
</DialogTitle>
<DialogDescription className="text-(length:--font-size-body-sm) text-muted-foreground/80 leading-(--line-height-body-sm)">
{t('settings.models.add.endpoint_type.tooltip')}
</DialogDescription>
</DialogHeader>

View File

@@ -131,32 +131,32 @@ describe('ModelListSyncDrawer', () => {
expect(searchInput).toBeInTheDocument()
expect(screen.getByTestId('drawer-content')).toHaveClass('w-[min(calc(100vw-24px),520px)]')
expect(screen.getByTestId('drawer-body')).toHaveClass('pt-0')
expect(screen.getByText('GPT 5')).toBeInTheDocument()
expect(screen.getByText('Claude Sonnet')).toBeInTheDocument()
expect(screen.getByText('Mistral Large')).toBeInTheDocument()
expect(screen.getByText('Legacy Model')).toBeInTheDocument()
expect(screen.getByText('gpt-5')).toBeInTheDocument()
expect(screen.getByText('claude-sonnet')).toBeInTheDocument()
expect(screen.getByText('mistral-large')).toBeInTheDocument()
expect(screen.getByText('legacy-model')).toBeInTheDocument()
fireEvent.change(searchInput, { target: { value: 'claude' } })
expect(screen.queryByText('GPT 5')).not.toBeInTheDocument()
expect(screen.getByText('Claude Sonnet')).toBeInTheDocument()
expect(screen.queryByText('Mistral Large')).not.toBeInTheDocument()
expect(screen.queryByText('Legacy Model')).not.toBeInTheDocument()
expect(screen.queryByText('gpt-5')).not.toBeInTheDocument()
expect(screen.getByText('claude-sonnet')).toBeInTheDocument()
expect(screen.queryByText('mistral-large')).not.toBeInTheDocument()
expect(screen.queryByText('legacy-model')).not.toBeInTheDocument()
})
it('clears pull-result search and restores rows', () => {
render(<ModelListSyncDrawer open preview={preview} isApplying={false} onApply={vi.fn()} onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText('models.search.placeholder'), { target: { value: 'legacy-model' } })
expect(screen.getByText('Legacy Model')).toBeInTheDocument()
expect(screen.queryByText('GPT 5')).not.toBeInTheDocument()
expect(screen.getByText('legacy-model')).toBeInTheDocument()
expect(screen.queryByText('gpt-5')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.clear' }))
expect(screen.getByPlaceholderText('models.search.placeholder')).toHaveValue('')
expect(screen.getByText('GPT 5')).toBeInTheDocument()
expect(screen.getByText('Claude Sonnet')).toBeInTheDocument()
expect(screen.getByText('Mistral Large')).toBeInTheDocument()
expect(screen.getByText('gpt-5')).toBeInTheDocument()
expect(screen.getByText('claude-sonnet')).toBeInTheDocument()
expect(screen.getByText('mistral-large')).toBeInTheDocument()
})
it('selects only visible added rows while preserving hidden selections', async () => {
@@ -170,7 +170,7 @@ describe('ModelListSyncDrawer', () => {
fireEvent.click(screen.getByRole('button', { name: 'settings.models.manage.fetch_deselect_all_add' }))
expect(screen.getByText('settings.models.manage.fetch_summary_add:0/3')).toBeInTheDocument()
fireEvent.click(screen.getByText('GPT 5'))
fireEvent.click(screen.getByText('gpt-5'))
expect(screen.getByText('settings.models.manage.fetch_summary_add:1/3')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('models.search.placeholder'), { target: { value: 'claude' } })

View File

@@ -365,7 +365,7 @@ export default function ProviderEditorDrawer({
)}
{duplicateSource && !duplicateNeedsBaseUrl(duplicateSource.authType) && (
<p className="text-muted-foreground/80 text-xs leading-[1.4]">
<p className="text-(length:--font-size-body-xs) text-muted-foreground/80 leading-[1.4]">
{t('settings.provider.duplicate.fill_after_create')}
</p>
)}
@@ -379,7 +379,7 @@ function DuplicateHeader({ source }: { source: Provider }) {
const presetId = source.presetProviderId
const label = presetId ? t(getProviderLabelKey(presetId)) : source.name
return (
<div className="flex items-center gap-2 rounded-lg border border-border-muted bg-muted/40 px-3 py-2">
<div className="flex items-center gap-2 rounded-lg border border-(--section-border) bg-muted/40 px-3 py-2">
<ProviderAvatar provider={{ id: presetId ?? source.id, name: label }} size={18} />
<span className="truncate text-foreground/85 text-sm">{label}</span>
</div>
@@ -606,7 +606,7 @@ function ApiKeyField({ value, onChange }: ApiKeyFieldProps) {
type="button"
aria-label={t(visible ? 'settings.provider.api_key.hide_key' : 'settings.provider.api_key.show_key')}
onClick={() => setVisible((v) => !v)}
className="-translate-y-1/2 absolute top-1/2 right-2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-accent/40 hover:text-foreground">
className="-translate-y-1/2 absolute top-1/2 right-2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-(--color-surface-fg-subtle) hover:text-foreground">
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>

View File

@@ -1,4 +1,4 @@
import { PageHeader } from '@cherrystudio/ui'
import { Button, PageHeader } from '@cherrystudio/ui'
import { useReorder } from '@data/hooks/useReorder'
import { useModels } from '@renderer/hooks/useModel'
import { useProviders } from '@renderer/hooks/useProvider'
@@ -33,12 +33,12 @@ export interface ProviderListProps {
export default function ProviderList({ selectedProviderId, filterModeHint, onSelectProvider }: ProviderListProps) {
const { t } = useTranslation()
const { providers } = useProviders()
const { models: allModels } = useModels()
const { applyReorderedList } = useReorder('/providers', { revalidateOnSuccess: false })
const { isSupported: isOvmsSupported } = useOvmsSupport()
const [filterMode, setFilterMode] = useState<ProviderFilterMode>(filterModeHint ?? 'all')
const [searchText, setSearchText] = useState('')
const { models: allModels } = useModels(undefined, { fetchEnabled: Boolean(searchText.trim()) })
const [dragging, setDragging] = useState(false)
const [contextProviderId, setContextProviderId] = useState<string | null>(null)
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})
@@ -263,18 +263,20 @@ export default function ProviderList({ selectedProviderId, filterModeHint, onSel
const handleAddAnother = useCallback((template: Provider) => startAddFrom(template), [startAddFrom])
return (
<aside className={`${providerListClasses.shell}`}>
<aside className={`provider-settings-default-scope ${providerListClasses.shell}`}>
<PageHeader
title={t('settings.provider.title')}
action={
<button
type="button"
<Button
variant="outline"
size="sm"
aria-label={t('settings.provider.add.title')}
disabled={dragging}
onClick={startAdd}
className={providerListClasses.headerAddButton}>
<Plus size={16} strokeWidth={2.5} />
</button>
className="hover:bg-[var(--color-surface-hover-soft)] [&_svg]:[stroke-width:var(--icon-stroke)]">
<Plus size={14} />
{t('common.add')}
</Button>
}
/>
<ProviderListSearchField
@@ -285,9 +287,8 @@ export default function ProviderList({ selectedProviderId, filterModeHint, onSel
<ProviderListHeaderFilterMenu
filterMode={filterMode}
disabled={dragging}
triggerClassName={providerListClasses.searchInlineAddButton}
triggerIconSize={13}
onFilterChange={setFilterMode}
className={providerListClasses.searchInlineAddButton}
/>
}
/>

View File

@@ -119,7 +119,7 @@ export default function ProviderListContent({
onReorder={onReorder}
onReorderError={onReorderError}
className="w-full"
gap="0.5rem"
gap="var(--provider-list-row-gap)"
restrictions={{ scrollableAncestor: true }}
renderItem={renderItem}
/>
@@ -187,7 +187,7 @@ export default function ProviderListContent({
// the source rect — otherwise dnd-kit stretches the compact header.
adjustScale={false}
className="w-full"
gap="0.5rem"
gap="var(--provider-list-row-gap)"
restrictions={{ scrollableAncestor: true }}
renderItem={(item, state) => {
if (item.kind === 'single') {

View File

@@ -98,7 +98,7 @@ export default function ProviderListGroup({
onReorder={onReorder}
onReorderError={onReorderError}
className="w-full"
gap="0.5rem"
gap="var(--provider-list-row-gap)"
restrictions={{ scrollableAncestor: true }}
renderItem={renderItem}
/>

View File

@@ -17,17 +17,15 @@ const FILTER_MENU_OPTIONS: { mode: ProviderFilterMode; labelKey: string }[] = [
interface ProviderListHeaderFilterMenuProps {
filterMode: ProviderFilterMode
disabled: boolean
triggerClassName?: string
triggerIconSize?: number
onFilterChange: (mode: ProviderFilterMode) => void
className?: string
}
export default function ProviderListHeaderFilterMenu({
filterMode,
disabled,
triggerClassName = providerListClasses.headerIconButton,
triggerIconSize = 14,
onFilterChange
onFilterChange,
className
}: ProviderListHeaderFilterMenuProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -40,14 +38,8 @@ export default function ProviderListHeaderFilterMenu({
type="button"
aria-label={t('settings.provider.filter.label')}
disabled={disabled}
className={cn('group', triggerClassName)}>
<Filter
size={triggerIconSize}
className={cn(
'shrink-0',
hasActiveFilter ? 'text-primary!' : 'text-muted-foreground/60 group-hover:text-muted-foreground/80'
)}
/>
className={cn(className ?? providerListClasses.headerIconButton, hasActiveFilter && 'bg-accent')}>
<Filter size={14} />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-fit min-w-32 rounded-xl p-1.5">

View File

@@ -2,6 +2,7 @@ import { CommandContextMenu, type CommandContextMenuExtraItem, CommandPopupMenu
import ModelNotesPopup from '@renderer/pages/settings/ProviderSettings/ModelNotesPopup'
import { providerListClasses } from '@renderer/pages/settings/ProviderSettings/primitives/ProviderSettingsPrimitives'
import { getFancyProviderName } from '@renderer/pages/settings/ProviderSettings/utils/providerDisplay'
import { cn } from '@renderer/utils/style'
import type { Provider } from '@shared/data/types/provider'
import { CopyPlus, Edit, Trash2, UserPen } from 'lucide-react'
import { useMemo } from 'react'
@@ -25,6 +26,14 @@ interface ProviderListItemWithContextMenuProps {
onSetListItemRef: (providerId: string, element: HTMLDivElement | null) => void
}
// CommandContextMenu/CommandPopupMenu's extra items have no per-item className, so the
// provider list's row-height/radius styling is applied via descendant selectors instead.
const menuContentClassName = cn(
providerListClasses.itemMenuContent,
'[&_[data-slot=context-menu-item]]:h-8 [&_[data-slot=context-menu-item]]:rounded-lg [&_[data-slot=context-menu-item]]:px-2.5',
'[&_[data-slot=dropdown-menu-item]]:h-8 [&_[data-slot=dropdown-menu-item]]:rounded-lg [&_[data-slot=dropdown-menu-item]]:px-2.5'
)
export default function ProviderListItemWithContextMenu({
provider,
selected,
@@ -40,66 +49,70 @@ export default function ProviderListItemWithContextMenu({
}: ProviderListItemWithContextMenuProps) {
const { t } = useTranslation()
const menuItems = useMemo<readonly CommandContextMenuExtraItem[]>(() => {
const menuItems = useMemo<CommandContextMenuExtraItem[]>(() => {
const items: CommandContextMenuExtraItem[] = []
if (showManagementActions) {
items.push({
type: 'item',
id: 'edit',
label: t('common.edit'),
icon: <Edit size={14} />,
icon: <Edit className="size-3.5 text-current" strokeWidth={1.6} />,
onSelect: onEdit
})
}
if (onDuplicate) {
items.push({
type: 'item',
id: 'duplicate',
label: t('settings.provider.duplicate.menu_label'),
icon: <CopyPlus size={14} />,
icon: <CopyPlus className="size-3.5 text-current" strokeWidth={1.6} />,
onSelect: onDuplicate
})
}
items.push({
type: 'item',
id: 'notes',
label: t('settings.provider.notes.title'),
icon: <UserPen size={14} />,
icon: <UserPen className="size-3.5 text-current" strokeWidth={1.6} />,
onSelect: () => ModelNotesPopup.show({ providerId: provider.id })
})
if (showManagementActions) {
items.push({
type: 'item',
id: 'delete',
label: t('common.delete'),
icon: <Trash2 size={14} />,
icon: <Trash2 className="size-3.5 text-current" strokeWidth={1.6} />,
destructive: true,
onSelect: onDelete
})
}
return items
}, [onDelete, onDuplicate, onEdit, provider.id, showManagementActions, t])
// Right-click stays uncontrolled — Radix handles cross-popup mutex naturally.
// The more-button popup remains controlled so the parent's single-row-active-at-a-time
// tracking (`contextProviderId`) keeps working across clicks between rows.
return (
<CommandContextMenu location="webcontents.context" extraItems={menuItems}>
<CommandContextMenu location="webcontents.context" extraItems={menuItems} contentClassName={menuContentClassName}>
<div className="w-full" ref={(element) => onSetListItemRef(provider.id, element)}>
<ProviderListItem
provider={{ ...provider, name: getFancyProviderName(provider) }}
selected={selected}
dragging={listState.dragging}
onClick={onSelect}
onOpenMenu={() => onContextOpenChange(true)}
// Opening is handled by CommandPopupMenu's own Radix trigger below; this only
// needs to be truthy so ProviderListItem renders the button (with stopPropagation).
onOpenMenu={() => {}}
renderMenuButton={(button) => (
<CommandPopupMenu
location="webcontents.context"
extraItems={menuItems}
open={contextOpen}
onOpenChange={onContextOpenChange}
align="end"
contentClassName={providerListClasses.itemMenuContent}>
contentClassName={menuContentClassName}
open={contextOpen}
onOpenChange={onContextOpenChange}>
{button}
</CommandPopupMenu>
)}

View File

@@ -1,5 +1,5 @@
import { SearchInput } from '@cherrystudio/ui'
import { providerListClasses } from '@renderer/pages/settings/ProviderSettings/primitives/ProviderSettingsPrimitives'
import { Search } from 'lucide-react'
import type { ChangeEvent, KeyboardEvent, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
@@ -21,23 +21,21 @@ export default function ProviderListSearchField({
return (
<div className={providerListClasses.searchRow}>
<div className={`${providerListClasses.searchWrap} min-w-0 flex-1`}>
<Search className={providerListClasses.searchIcon} />
<input
value={value}
placeholder={t('settings.provider.search')}
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(event.target.value)}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
event.stopPropagation()
onValueChange('')
}
}}
disabled={disabled}
className={providerListClasses.searchInput}
/>
{trailing}
</div>
<SearchInput
containerClassName={`${providerListClasses.searchWrap} min-w-0 flex-1`}
className={providerListClasses.searchInput}
value={value}
placeholder={t('settings.provider.search')}
disabled={disabled}
onChange={(event: ChangeEvent<HTMLInputElement>) => onValueChange(event.target.value)}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
event.stopPropagation()
onValueChange('')
}
}}
trailing={trailing}
/>
</div>
)
}