feat(api-access): replace model selector with a dual-pane layout

- Separate selected and unselected models with independent search and bulk actions
- Fix selection initialization and ignore stale discovery responses
- Improve keyboard navigation and responsive layouts
- Update translations and add model selection UI regression tests
This commit is contained in:
lzt404 committed 2026-09-15 02:39:03 +08:00
1 parent 2b41eab8df
commit 19a9b4fc09
9 files changed
+605 -84

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import { useId, useMemo, useRef, useState } from 'react';
import { ArrowRight, Search, X } from 'lucide-react';
import { useI18n } from '../i18n';
import type { ModelOption } from '../services/modelService';
type ModelSelectionPanelProps = {
models: ModelOption[];
selected: boolean;
loading: boolean;
onMove: (models: ModelOption[], selected: boolean) => void;
};
export function ModelSelectionPanel({ models, selected, loading, onMove }: ModelSelectionPanelProps) {
const { t } = useI18n();
const titleId = useId();
const searchRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const query = search.trim().toLowerCase();
const visibleModels = useMemo(() => models.filter((model) =>
`${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query),
), [models, query]);
const searchLabel = t(selected ? 'apiAccess.modelDialog.searchSelected' : 'apiAccess.modelDialog.searchUnselected');
return (
<section className="model-transfer-panel" aria-labelledby={titleId} aria-busy={loading}>
<div className="model-transfer-panel-heading">
<h3 id={titleId}>{t(selected ? 'apiAccess.modelDialog.selected' : 'apiAccess.modelDialog.unselected')}</h3>
<span className="model-transfer-count">{query ? `${visibleModels.length} / ${models.length}` : models.length}</span>
<button
type="button"
className="secondary-button compact-button"
onClick={() => onMove(visibleModels, !selected)}
disabled={loading || visibleModels.length === 0}
>
{t(selected
? query ? 'apiAccess.modelDialog.removeResults' : 'apiAccess.modelDialog.removeAll'
: query ? 'apiAccess.modelDialog.addResults' : 'apiAccess.modelDialog.addAll')}
</button>
</div>
<div className="model-transfer-search">
<Search size={15} aria-hidden="true" />
<input ref={searchRef} value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={searchLabel} aria-label={searchLabel} />
{search ? (
<button type="button" className="icon-button quiet" onClick={() => { setSearch(''); searchRef.current?.focus(); }} aria-label={t('apiAccess.modelDialog.clearSearch')}>
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
<div className="model-transfer-list">
{visibleModels.length ? visibleModels.map((model) => (
<button
type="button"
className="model-transfer-row"
key={model.name}
disabled={loading}
aria-label={t(selected ? 'apiAccess.modelDialog.removeModel' : 'apiAccess.modelDialog.addModel', { name: model.name })}
onClick={(event) => {
if (event.detail === 0) {
const next = event.currentTarget.nextElementSibling ?? event.currentTarget.previousElementSibling;
if (next instanceof HTMLElement) next.focus();
else searchRef.current?.focus();
}
onMove([model], !selected);
}}
>
<span><strong title={model.name}>{model.name}</strong>{model.alias ? <small title={model.alias}>{model.alias}</small> : null}</span>
{selected ? <X size={16} aria-hidden="true" /> : <ArrowRight size={16} aria-hidden="true" />}
</button>
)) : (
<div className="model-transfer-empty">
{t(query ? 'apiAccess.modelDialog.noMatch' : loading ? 'apiAccess.modelDialog.fetching' : selected ? 'apiAccess.modelDialog.emptySelected' : 'apiAccess.modelDialog.emptyUnselected')}
</div>
)}
</div>
</section>
);
}
+14
View File
@@ -1240,6 +1240,20 @@ export const jaOverrides = {
'apiAccess.modelDialog.summary': '{found} 件検出 · {selected} 件選択済み',
'apiAccess.modelDialog.deselectAll': 'すべて選択解除',
'apiAccess.modelDialog.selectVisible': '表示中をすべて選択',
'apiAccess.modelDialog.unselected': '未選択のモデル',
'apiAccess.modelDialog.selected': '選択済みのモデル',
'apiAccess.modelDialog.searchUnselected': '未選択のモデルを検索',
'apiAccess.modelDialog.searchSelected': '選択済みのモデルを検索',
'apiAccess.modelDialog.clearSearch': '検索をクリア',
'apiAccess.modelDialog.addAll': 'すべて追加',
'apiAccess.modelDialog.removeAll': 'すべて解除',
'apiAccess.modelDialog.addResults': '検索結果を追加',
'apiAccess.modelDialog.removeResults': '検索結果を解除',
'apiAccess.modelDialog.addModel': '{name} を追加',
'apiAccess.modelDialog.removeModel': '{name} を解除',
'apiAccess.modelDialog.emptySelected': 'まだモデルが選択されていません',
'apiAccess.modelDialog.emptyUnselected': '未選択のモデルはありません',
'apiAccess.modelDialog.chooseOne': 'モデルを1つ以上選択してください',
'common.clear': '消去',
'apiAccess.modelDialog.fetching': 'モデルを取得しています',
'apiAccess.modelDialog.fetchFailed': 'モデルの取得に失敗しました',
+14
View File
@@ -1240,6 +1240,20 @@ export const en: Record<MessageKey, string> = {
'apiAccess.modelDialog.summary': '{found} found · {selected} selected',
'apiAccess.modelDialog.deselectAll': 'Deselect All',
'apiAccess.modelDialog.selectVisible': 'Select Visible',
'apiAccess.modelDialog.unselected': 'Unselected Models',
'apiAccess.modelDialog.selected': 'Selected Models',
'apiAccess.modelDialog.searchUnselected': 'Search unselected models',
'apiAccess.modelDialog.searchSelected': 'Search selected models',
'apiAccess.modelDialog.clearSearch': 'Clear search',
'apiAccess.modelDialog.addAll': 'Add All',
'apiAccess.modelDialog.removeAll': 'Remove All',
'apiAccess.modelDialog.addResults': 'Add Results',
'apiAccess.modelDialog.removeResults': 'Remove Results',
'apiAccess.modelDialog.addModel': 'Add {name}',
'apiAccess.modelDialog.removeModel': 'Remove {name}',
'apiAccess.modelDialog.emptySelected': 'No models selected yet',
'apiAccess.modelDialog.emptyUnselected': 'No unselected models',
'apiAccess.modelDialog.chooseOne': 'Select at least one model',
'common.clear': 'Clear',
'apiAccess.modelDialog.fetching': 'Fetching models',
'apiAccess.modelDialog.fetchFailed': 'Failed to fetch models',
+14
View File
@@ -1239,6 +1239,20 @@ export const zhCN = {
'apiAccess.modelDialog.summary': '找到 {found} 个 · 已选择 {selected} 个',
'apiAccess.modelDialog.deselectAll': '取消全选',
'apiAccess.modelDialog.selectVisible': '全选当前',
'apiAccess.modelDialog.unselected': '未选模型',
'apiAccess.modelDialog.selected': '已选模型',
'apiAccess.modelDialog.searchUnselected': '搜索未选模型',
'apiAccess.modelDialog.searchSelected': '搜索已选模型',
'apiAccess.modelDialog.clearSearch': '清除搜索',
'apiAccess.modelDialog.addAll': '全部添加',
'apiAccess.modelDialog.removeAll': '全部移除',
'apiAccess.modelDialog.addResults': '添加搜索结果',
'apiAccess.modelDialog.removeResults': '移除搜索结果',
'apiAccess.modelDialog.addModel': '添加 {name}',
'apiAccess.modelDialog.removeModel': '移除 {name}',
'apiAccess.modelDialog.emptySelected': '暂无已选模型',
'apiAccess.modelDialog.emptyUnselected': '暂无未选模型',
'apiAccess.modelDialog.chooseOne': '请至少选择一个模型',
'common.clear': '清空',
'apiAccess.modelDialog.fetching': '正在获取模型',
'apiAccess.modelDialog.fetchFailed': '获取模型失败',
+86 -84
View File
@@ -1,5 +1,6 @@
import { createPortal } from 'react-dom';
import { useConfirmation } from '../components/ConfirmationDialog';
import { ModelSelectionPanel } from '../components/ModelSelectionPanel';
import {
type CSSProperties,
FormEvent,
@@ -28,7 +29,6 @@ import {
} from '@dnd-kit/sortable';
import { CSS as DndCss } from '@dnd-kit/utilities';
import {
Check,
Edit3,
Filter,
GripVertical,
@@ -1595,7 +1595,7 @@ type ApiProviderDialogProps = {
onSave: (draft: ProviderDraft, modelDiscoveryReady: boolean) => Promise<ProviderSaveResult>;
};
function ApiProviderDialog({
export function ApiProviderDialog({
activeCategory,
editingRow,
initialDraft,
@@ -1616,13 +1616,52 @@ function ApiProviderDialog({
() => activeCategory !== 'deepseek'
|| Boolean(editingRow && initialDraft.models.some((model) => model.name.trim())),
);
const [modelSearch, setModelSearch] = useState('');
const [thinkingLevelInput, setThinkingLevelInput] = useState('');
const [selectedModelNames, setSelectedModelNames] = useState<Set<string>>(
() => new Set(initialDraft.models.map((model) => model.name.toLowerCase())),
() => new Set(mergeModelOptions(initialDraft.models).map((model) => model.name.toLowerCase())),
);
const modelCardRef = useRef<HTMLDivElement>(null);
const modelDialogRef = useRef<HTMLElement>(null);
const discoverySelectionInitializedRef = useRef(false);
const discoveryRequestRef = useRef(0);
const closeModelDiscovery = useCallback(() => {
discoveryRequestRef.current += 1;
setModelLoading(false);
setModelDiscoveryOpen(false);
}, []);
useEffect(() => () => { discoveryRequestRef.current += 1; }, []);
useEffect(() => {
if (!modelDiscoveryOpen) return;
const previousFocus = document.activeElement;
const dialog = modelDialogRef.current;
dialog?.querySelector<HTMLInputElement>('input')?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeModelDiscovery();
} else if (event.key === 'Tab') {
const controls = Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)') ?? []);
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && (document.activeElement === first || !dialog?.contains(document.activeElement))) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && (document.activeElement === last || !dialog?.contains(document.activeElement))) {
event.preventDefault();
first?.focus();
}
}
};
document.addEventListener('keydown', onKeyDown, true);
return () => {
document.removeEventListener('keydown', onKeyDown, true);
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
};
}, [modelDiscoveryOpen, closeModelDiscovery]);
const modelOptions = useMemo(
() => mergeModelOptions(discoveredModels, draft.models),
@@ -1634,16 +1673,12 @@ function ApiProviderDialog({
[draft.models],
);
const visibleModelOptions = useMemo(() => {
const query = modelSearch.trim().toLowerCase();
if (!query) return modelOptions;
return modelOptions.filter((model) =>
`${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query),
);
}, [modelOptions, modelSearch]);
const allVisibleModelsSelected = visibleModelOptions.length > 0
&& visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase()));
const selectedModels = useMemo(() => modelOptions.filter((model) =>
selectedModelNames.has(model.name.toLowerCase()),
), [modelOptions, selectedModelNames]);
const unselectedModels = useMemo(() => modelOptions.filter((model) =>
!selectedModelNames.has(model.name.toLowerCase()),
), [modelOptions, selectedModelNames]);
const updateTextField = (
field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
@@ -1651,6 +1686,8 @@ function ApiProviderDialog({
) => {
setFormError('');
if (field === 'apiKey' || field === 'baseUrl' || field === 'headersText') {
discoveryRequestRef.current += 1;
setModelLoading(false);
setModelError('');
if (activeCategory === 'deepseek') setModelDiscoveryReady(false);
}
@@ -1716,6 +1753,7 @@ function ApiProviderDialog({
setModelError(t('apiAccess.error.enterBaseUrl'));
return;
}
const requestId = ++discoveryRequestRef.current;
setModelLoading(true);
setModelError('');
try {
@@ -1736,21 +1774,24 @@ function ApiProviderDialog({
editingRow?.authIndex,
parseProviderHeaders(draft.headersText ?? ''),
);
if (requestId !== discoveryRequestRef.current) return;
const models = applyProviderPreset(
activeCategory,
{ ...draft, models: fetchedModels },
).models;
const selectionMode = discoverySelectionInitializedRef.current ? 'refresh' : 'initial';
const initialized = discoverySelectionInitializedRef.current;
setDiscoveredModels(models);
setSelectedModelNames((current) =>
reconcileModelSelection(models, draft.models, current, selectionMode));
initialized
? reconcileModelSelection(models, draft.models, current, 'refresh')
: modelSelectionForDiscovery(activeSection, draft.models, models, draft.excludedModelsText ?? ''));
discoverySelectionInitializedRef.current = true;
if (activeCategory === 'deepseek') setModelDiscoveryReady(true);
if (!models.length) setModelError(t('apiAccess.error.noAvailableModels'));
} catch (requestError) {
setModelError(requestErrorMessage(requestError));
if (requestId === discoveryRequestRef.current) setModelError(requestErrorMessage(requestError));
} finally {
setModelLoading(false);
if (requestId === discoveryRequestRef.current) setModelLoading(false);
}
};
@@ -1761,42 +1802,30 @@ function ApiProviderDialog({
setModelError(t('apiAccess.error.baseBeforeModels'));
return;
}
setModelSearch('');
discoverySelectionInitializedRef.current = false;
setSelectedModelNames(new Set(draft.models.map((model) => model.name.toLowerCase())));
setSelectedModelNames(modelSelectionForDiscovery(activeSection, draft.models, discoveredModels, draft.excludedModelsText ?? ''));
setModelDiscoveryOpen(true);
void discoverModels();
};
const toggleModelSelection = (model: ModelOption) => {
const key = model.name.toLowerCase();
const moveModels = (models: ModelOption[], selected: boolean) => {
discoverySelectionInitializedRef.current = true;
setSelectedModelNames((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const toggleAllVisibleModels = () => {
setSelectedModelNames((current) => {
const next = new Set(current);
visibleModelOptions.forEach((model) => {
models.forEach((model) => {
const key = model.name.toLowerCase();
if (allVisibleModelsSelected) next.delete(key);
else next.add(key);
if (selected) next.add(key);
else next.delete(key);
});
return next;
});
};
const applyModelSelection = () => {
const models = modelOptions.filter((model) =>
selectedModelNames.has(model.name.toLowerCase()),
);
if (modelLoading || selectedModels.length === 0) return;
setDraft((current) => ({
...current,
models,
models: selectedModels,
excludedModelsText: activeSection === 'openai-compatibility'
? current.excludedModelsText
: exclusionsForModelSelection(
@@ -1805,7 +1834,7 @@ function ApiProviderDialog({
selectedModelNames,
),
}));
setModelDiscoveryOpen(false);
closeModelDiscovery();
};
const submit = async (event: FormEvent) => {
@@ -2025,63 +2054,36 @@ function ApiProviderDialog({
</div>
{modelDiscoveryOpen ? (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && setModelDiscoveryOpen(false)}>
<section className="model-discovery-dialog" role="dialog" aria-modal="true" aria-labelledby="model-discovery-title">
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closeModelDiscovery()}>
<section ref={modelDialogRef} className="model-discovery-dialog model-transfer-dialog" role="dialog" aria-modal="true" aria-labelledby="model-discovery-title">
<div className="model-discovery-header">
<div><h2 id="model-discovery-title">{t('apiAccess.modelDialog.title')}</h2><span>{t(definition.labelKey)}</span></div>
<button type="button" className="icon-button quiet" onClick={() => setModelDiscoveryOpen(false)} title={t('common.close')}><X size={18} /></button>
<button type="button" className="icon-button quiet" onClick={closeModelDiscovery} title={t('common.close')}><X size={18} /></button>
</div>
<div className="model-discovery-search">
<Search size={16} aria-hidden="true" />
<input value={modelSearch} onChange={(event) => setModelSearch(event.currentTarget.value)} placeholder={t('agents.model.search')} />
<div className="model-transfer-summary">
<span role="status">{t('apiAccess.modelDialog.summary', { found: modelOptions.length, selected: selectedModels.length })}</span>
<button type="button" className="secondary-button compact-button" onClick={() => void discoverModels()} disabled={modelLoading}>
<RefreshCw size={15} className={modelLoading ? 'spin' : ''} />{t('common.refresh')}
</button>
</div>
<div className="model-discovery-toolbar">
<span>{t('apiAccess.modelDialog.summary', { found: modelOptions.length, selected: selectedModelNames.size })}</span>
<div>
<button type="button" className="secondary-button compact-button" onClick={toggleAllVisibleModels} disabled={modelLoading || visibleModelOptions.length === 0}>{allVisibleModelsSelected ? t('apiAccess.modelDialog.deselectAll') : t('apiAccess.modelDialog.selectVisible')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => setSelectedModelNames(new Set())} disabled={modelLoading || selectedModelNames.size === 0}>{t('common.clear')}</button>
{modelError ? (
<div className="model-discovery-inline-error" role="alert">
<strong>{t('apiAccess.modelDialog.fetchFailed')}</strong>
<span title={modelError}>{modelError}</span>
</div>
) : null}
<div className="model-transfer-panels">
<ModelSelectionPanel models={unselectedModels} selected={false} loading={modelLoading} onMove={moveModels} />
<ModelSelectionPanel models={selectedModels} selected loading={modelLoading} onMove={moveModels} />
</div>
<div className="model-discovery-content">
{modelLoading ? (
<div className="model-discovery-message"><LoaderCircle size={20} className="spin" />{t('apiAccess.modelDialog.fetching')}</div>
) : modelError && modelOptions.length === 0 ? (
<div className="model-discovery-message error"><strong>{t('apiAccess.modelDialog.fetchFailed')}</strong><span>{modelError}</span></div>
) : visibleModelOptions.length === 0 ? (
<div className="model-discovery-message"><strong>{modelOptions.length ? t('apiAccess.modelDialog.noMatch') : t('apiAccess.modelDialog.none')}</strong><span>{modelOptions.length ? t('apiAccess.modelDialog.tryKeyword') : t('apiAccess.modelDialog.checkCredentials')}</span></div>
) : (
<div className="model-discovery-results">
{modelError ? (
<div className="model-discovery-inline-error" role="alert">
<strong>{t('apiAccess.modelDialog.fetchFailed')}</strong>
<span title={modelError}>{modelError}</span>
</div>
) : null}
<div className="model-discovery-list">
{visibleModelOptions.map((model) => {
const checked = selectedModelNames.has(model.name.toLowerCase());
return (
<label className={`model-discovery-row ${checked ? 'selected' : ''}`} key={model.name}>
<input type="checkbox" checked={checked} onChange={() => toggleModelSelection(model)} />
<span><strong title={model.name}>{model.name}</strong>{model.alias ? <small title={model.alias}>{model.alias}</small> : null}</span>
{checked ? <Check size={16} aria-hidden="true" /> : null}
</label>
);
})}
</div>
</div>
)}
</div>
<div className="model-discovery-actions">
<button type="button" className="secondary-button" onClick={() => setModelDiscoveryOpen(false)}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={applyModelSelection} disabled={modelLoading}>{t('apiAccess.modelDialog.apply', { count: selectedModelNames.size })}</button>
<div className="model-discovery-actions model-transfer-actions">
{selectedModels.length === 0 ? <span>{t('apiAccess.modelDialog.chooseOne')}</span> : null}
<button type="button" className="secondary-button" onClick={closeModelDiscovery}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={applyModelSelection} disabled={modelLoading || selectedModels.length === 0}>{t('apiAccess.modelDialog.apply', { count: selectedModels.length })}</button>
</div>
</section>
</div>
+193
View File
@@ -3643,7 +3643,9 @@ textarea:focus-visible {
.model-discovery-toolbar > div {
display: flex;
flex-wrap: wrap;
gap: 7px;
justify-content: flex-end;
}
.model-discovery-content {
@@ -3769,6 +3771,197 @@ textarea:focus-visible {
min-width: 112px;
}
.model-discovery-dialog.model-transfer-dialog {
display: flex;
flex-direction: column;
width: min(960px, 100%);
height: min(640px, calc(100dvh - 48px));
min-height: 0;
}
.model-transfer-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--theme-7a746c);
font-size: var(--font-size-label);
}
.model-transfer-summary > button {
width: auto;
flex: 0 0 auto;
}
.model-transfer-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
flex: 1;
min-height: 0;
}
.model-transfer-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid var(--theme-e2ddd3);
border-radius: 8px;
background: var(--theme-ffffff);
}
.model-transfer-panel-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px;
}
.model-transfer-panel-heading h3 {
margin: 0;
color: var(--theme-2d2a26);
font-size: var(--font-size-label);
}
.model-transfer-panel-heading button {
width: auto;
flex: 0 0 auto;
margin-left: auto;
}
.model-transfer-count {
border-radius: 5px;
padding: 2px 6px;
background: var(--theme-f4f2ec);
color: var(--theme-6d6760);
font-size: var(--font-size-meta);
font-variant-numeric: tabular-nums;
}
.model-transfer-search {
display: flex;
align-items: center;
gap: 8px;
margin: 0 10px 10px;
padding: 0 8px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 6px;
color: var(--theme-8a847c);
}
.model-transfer-search:focus-within {
outline: 2px solid var(--theme-9ca9df);
outline-offset: 1px;
}
.model-transfer-search input {
width: 100%;
min-width: 0;
height: 34px;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--theme-2d2a26);
}
.model-transfer-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
border-top: 1px solid var(--theme-eeece6);
}
.model-transfer-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 44px;
padding: 9px 12px;
border: 0;
border-bottom: 1px solid var(--theme-eeece6);
background: transparent;
color: var(--theme-6d6760);
text-align: left;
cursor: pointer;
}
.model-transfer-row:hover:not(:disabled) {
background: var(--theme-f4f2ec);
}
.model-transfer-row:focus-visible {
outline: 2px solid var(--theme-9ca9df);
outline-offset: -2px;
}
.model-transfer-row > span {
display: grid;
flex: 1;
min-width: 0;
gap: 2px;
}
.model-transfer-row strong,
.model-transfer-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-transfer-row strong {
color: var(--theme-2d2a26);
font-size: var(--font-size-label);
}
.model-transfer-row small {
color: var(--theme-8a847c);
font-size: var(--font-size-meta);
}
.model-transfer-empty {
display: grid;
place-content: center;
height: 100%;
padding: 16px;
color: var(--theme-7a746c);
font-size: var(--font-size-label);
text-align: center;
}
.model-transfer-actions {
flex-wrap: wrap;
}
.model-transfer-actions > span {
margin-right: auto;
color: var(--theme-7a746c);
font-size: var(--font-size-label);
}
@media (max-width: 600px) {
.model-discovery-dialog.model-transfer-dialog {
height: calc(100dvh - 24px);
padding: 12px;
}
.model-transfer-panels {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.model-transfer-actions > span {
width: 100%;
}
}
.model-discovery-dialog.auth-model-dialog {
grid-template-rows: auto 38px auto minmax(0, 1fr) auto auto;
height: min(720px, calc(100vh - 32px));