Compare commits

...

5 Commits

16 changed files with 792 additions and 233 deletions
+7 -3
View File
@@ -11,6 +11,7 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
@@ -177,8 +178,11 @@ export function VisualConfigEditor({
onChange,
}: VisualConfigEditorProps) {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const isMobile = useMediaQuery('(max-width: 768px)');
const isFloatingSidebar = useMediaQuery('(min-width: 1025px)');
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
const routingStrategyLabelId = useId();
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
const keepaliveInputId = useId();
@@ -371,7 +375,7 @@ export function VisualConfigEditor({
floatingElement.style.removeProperty('pointer-events');
};
if (isMobile || !isFloatingSidebar || !anchorElement || !workspaceElement) {
if (!shouldRenderFloatingSidebar || !anchorElement || !workspaceElement) {
clearFloatingStyles();
return undefined;
}
@@ -441,7 +445,7 @@ export function VisualConfigEditor({
contentScroller?.removeEventListener('scroll', requestPositionUpdate);
clearFloatingStyles();
};
}, [isFloatingSidebar, isMobile]);
}, [shouldRenderFloatingSidebar]);
const navContent = (
<div className={styles.navList}>
@@ -1042,7 +1046,7 @@ export function VisualConfigEditor({
</div>
</div>
{!isMobile && isFloatingSidebar && typeof document !== 'undefined'
{shouldRenderFloatingSidebar && typeof document !== 'undefined'
? createPortal(
<div ref={floatingSidebarRef} className={styles.floatingSidebarContainer}>
<div className={styles.floatingSidebarRail}>{navContent}</div>
+2 -5
View File
@@ -67,11 +67,7 @@
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
@@ -83,6 +79,7 @@
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.option {
+192 -56
View File
@@ -1,4 +1,14 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties
} from 'react';
import { createPortal } from 'react-dom';
import { IconChevronDown } from './icons';
import styles from './Select.module.scss';
@@ -21,6 +31,51 @@ interface SelectProps {
id?: string;
}
const VIEWPORT_MARGIN = 8;
const DROPDOWN_OFFSET = 6;
const DROPDOWN_MAX_HEIGHT = 240;
const DROPDOWN_Z_INDEX = 2010;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const resolveDropdownStyle = (element: HTMLElement): CSSProperties => {
const rect = element.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2));
const left = clamp(
rect.left,
VIEWPORT_MARGIN,
Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN)
);
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET;
const direction =
spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up';
const maxHeight = Math.max(
0,
Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove)
);
return direction === 'down'
? {
position: 'fixed',
top: rect.bottom + DROPDOWN_OFFSET,
left,
width,
maxHeight,
zIndex: DROPDOWN_Z_INDEX
}
: {
position: 'fixed',
bottom: viewportHeight - rect.top + DROPDOWN_OFFSET,
left,
width,
maxHeight,
zIndex: DROPDOWN_Z_INDEX
};
};
export function Select({
value,
options,
@@ -40,17 +95,78 @@ export function Select({
const [open, setOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const wrapRef = useRef<HTMLDivElement | null>(null);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
const isOpen = open && !disabled;
useEffect(() => {
if (!open || disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
const target = event.target as Node;
if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [disabled, open]);
const isOpen = open && !disabled;
const updateDropdownStyle = useCallback(() => {
if (!wrapRef.current) return;
setDropdownStyle(resolveDropdownStyle(wrapRef.current));
}, []);
const scheduleDropdownStyleUpdate = useCallback(() => {
if (typeof window === 'undefined') return;
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
}
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
updateDropdownStyle();
});
}, [updateDropdownStyle]);
useLayoutEffect(() => {
if (!isOpen) {
if (rafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
updateDropdownStyle();
const handleViewportChange = () => {
scheduleDropdownStyleUpdate();
};
const resizeObserver =
typeof ResizeObserver !== 'undefined' && wrapRef.current
? new ResizeObserver(() => {
scheduleDropdownStyleUpdate();
})
: null;
if (resizeObserver && wrapRef.current) {
resizeObserver.observe(wrapRef.current);
}
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
resizeObserver?.disconnect();
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]);
const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]);
const resolvedHighlightedIndex =
highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1;
@@ -136,60 +252,80 @@ export function Select({
[commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex]
);
useEffect(() => {
if (!isOpen || resolvedHighlightedIndex < 0) return;
const highlightedOption = document.getElementById(`${selectId}-option-${resolvedHighlightedIndex}`);
highlightedOption?.scrollIntoView({ block: 'nearest' });
}, [isOpen, resolvedHighlightedIndex, selectId]);
const dropdown =
isOpen && dropdownStyle
? (
<div
ref={dropdownRef}
className={styles.dropdown}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
style={dropdownStyle}
>
{options.map((opt, index) => {
const active = opt.value === value;
const highlighted = index === resolvedHighlightedIndex;
return (
<button
key={opt.value}
id={`${selectId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onKeyDown={handleKeyDown}
onClick={() => commitSelection(index)}
>
{opt.label}
</button>
);
})}
</div>
)
: null;
return (
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
id={selectId}
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={
isOpen && resolvedHighlightedIndex >= 0
? `${selectId}-option-${resolvedHighlightedIndex}`
: undefined
}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
<>
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{isOpen && (
<div className={styles.dropdown} id={listboxId} role="listbox" aria-label={ariaLabel}>
{options.map((opt, index) => {
const active = opt.value === value;
const highlighted = index === resolvedHighlightedIndex;
return (
<button
key={opt.value}
id={`${selectId}-option-${index}`}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''} ${highlighted ? styles.optionHighlighted : ''}`.trim()}
onMouseEnter={() => setHighlightedIndex(index)}
onKeyDown={handleKeyDown}
onClick={() => commitSelection(index)}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
<button
id={selectId}
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={isOpen ? listboxId : undefined}
aria-activedescendant={
isOpen && resolvedHighlightedIndex >= 0
? `${selectId}-option-${resolvedHighlightedIndex}`
: undefined
}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
</div>
{dropdown && (typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))}
</>
);
}
+1 -1
View File
@@ -44,7 +44,7 @@ export function SelectionCheckbox({
disabled={disabled}
/>
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
{label ? <span className={textClassName}>{label}</span> : null}
{label ? <div className={textClassName}>{label}</div> : null}
</label>
);
}
+21
View File
@@ -15,6 +15,7 @@ export type AuthFilesUiState = {
};
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
const AUTH_FILES_COMPACT_MODE_KEY = 'authFilesPage.compactMode';
const AUTH_FILES_SORT_MODE_SET = new Set<AuthFilesSortMode>(AUTH_FILES_SORT_MODES);
export const isAuthFilesSortMode = (value: unknown): value is AuthFilesSortMode =>
@@ -40,3 +41,23 @@ export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
// ignore
}
};
export const readPersistedAuthFilesCompactMode = (): boolean | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(AUTH_FILES_COMPACT_MODE_KEY);
if (raw === null) return null;
return JSON.parse(raw) === true;
} catch {
return null;
}
};
export const writePersistedAuthFilesCompactMode = (compactMode: boolean) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(AUTH_FILES_COMPACT_MODE_KEY, JSON.stringify(compactMode));
} catch {
// ignore
}
};
+3
View File
@@ -218,6 +218,9 @@
"gemini_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
"gemini_models_fetch_error": "Failed to fetch Gemini models",
"gemini_models_fetch_apply": "Add selected models",
"model_discovery_select_visible": "Select current list",
"model_discovery_clear_selection": "Clear selection",
"model_discovery_selected_count": "{{count}} selected",
"gemini_models_search_label": "Search models",
"gemini_models_search_placeholder": "Filter by name, alias, or description",
"gemini_models_search_empty": "No models match your search. Try a different keyword.",
+3
View File
@@ -218,6 +218,9 @@
"gemini_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
"gemini_models_fetch_error": "Не удалось получить модели Gemini",
"gemini_models_fetch_apply": "Добавить выбранные модели",
"model_discovery_select_visible": "Выбрать текущий список",
"model_discovery_clear_selection": "Очистить выбор",
"model_discovery_selected_count": "Выбрано: {{count}}",
"gemini_models_search_label": "Поиск моделей",
"gemini_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
"gemini_models_search_empty": "Нет подходящих моделей. Попробуйте другой запрос.",
+3
View File
@@ -218,6 +218,9 @@
"gemini_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
"gemini_models_fetch_error": "获取 Gemini 模型失败",
"gemini_models_fetch_apply": "添加所选模型",
"model_discovery_select_visible": "全选当前列表",
"model_discovery_clear_selection": "清空已选",
"model_discovery_selected_count": "已选 {{count}} 项",
"gemini_models_search_label": "搜索模型",
"gemini_models_search_placeholder": "按名称、别名或描述筛选",
"gemini_models_search_empty": "没有匹配的模型,请更换关键字试试。",
+90 -19
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { modelsApi } from '@/services/api';
@@ -48,6 +49,14 @@ export function AiProvidersClaudeModelsPage() {
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
});
}, [models, search]);
const visibleModelNames = useMemo(
() => filteredModels.map((model) => model.name),
[filteredModels]
);
const allVisibleSelected = useMemo(
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
[selected, visibleModelNames]
);
const fetchClaudeModelDiscovery = useCallback(async () => {
setFetching(true);
@@ -117,6 +126,22 @@ export function AiProvidersClaudeModelsPage() {
void fetchClaudeModelDiscovery();
}, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]);
useEffect(() => {
const availableNames = new Set(models.map((model) => model.name));
setSelected((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (availableNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [models]);
const handleBack = useCallback(() => {
navigate(-1);
}, [navigate]);
@@ -145,6 +170,18 @@ export function AiProvidersClaudeModelsPage() {
});
};
const handleSelectVisible = useCallback(() => {
setSelected((prev) => {
const next = new Set(prev);
visibleModelNames.forEach((name) => next.add(name));
return next;
});
}, [visibleModelNames]);
const handleClearSelection = useCallback(() => {
setSelected(new Set());
}, []);
const handleApply = () => {
const selectedModels = models.filter((model) => selected.has(model.name));
if (selectedModels.length) {
@@ -153,7 +190,7 @@ export function AiProvidersClaudeModelsPage() {
handleBack();
};
const canApply = !disableControls && !saving && !fetching;
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
return (
<SecondaryScreenShell
@@ -219,9 +256,42 @@ export function AiProvidersClaudeModelsPage() {
onChange={(e) => setSearch(e.target.value)}
disabled={fetching}
/>
{models.length > 0 && (
<div className={styles.modelDiscoveryToolbar}>
<div className={styles.modelDiscoveryToolbarActions}>
<Button
variant="secondary"
size="sm"
onClick={handleSelectVisible}
disabled={
disableControls ||
saving ||
fetching ||
filteredModels.length === 0 ||
allVisibleSelected
}
>
{t('ai_providers.model_discovery_select_visible')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
disabled={disableControls || saving || fetching || selected.size === 0}
>
{t('ai_providers.model_discovery_clear_selection')}
</Button>
</div>
<div className={styles.modelDiscoverySelectionSummary}>
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
</div>
</div>
)}
{error && <div className="error-box">{error}</div>}
{fetching ? (
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_loading')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.claude_models_fetch_loading')}
</div>
) : models.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_empty')}</div>
) : filteredModels.length === 0 ? (
@@ -231,29 +301,30 @@ export function AiProvidersClaudeModelsPage() {
{filteredModels.map((model) => {
const checked = selected.has(model.name);
return (
<label
<SelectionCheckbox
key={model.name}
checked={checked}
onChange={() => toggleSelection(model.name)}
disabled={disableControls || saving || fetching}
ariaLabel={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
labelClassName={styles.modelDiscoverySelectionLabel}
label={
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
}
/>
);
})}
</div>
+131 -29
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { Modal } from '@/components/ui/Modal';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
@@ -66,7 +67,9 @@ const buildCodexSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
websockets: Boolean(form.websockets),
@@ -95,7 +98,9 @@ export function AiProvidersCodexEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm()));
const [baselineSignature, setBaselineSignature] = useState(() =>
buildCodexSignature(buildEmptyForm())
);
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -119,7 +124,9 @@ export function AiProvidersCodexEditPage() {
const invalidIndex = editIndex !== null && !initialData;
const title =
editIndex !== null ? t('ai_providers.codex_edit_modal_title') : t('ai_providers.codex_add_modal_title');
editIndex !== null
? t('ai_providers.codex_edit_modal_title')
: t('ai_providers.codex_add_modal_title');
const handleBack = useCallback(() => {
const state = location.state as LocationState;
@@ -216,6 +223,16 @@ export function AiProvidersCodexEditPage() {
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
});
}, [discoveredModels, modelDiscoverySearch]);
const visibleDiscoveredModelNames = useMemo(
() => discoveredModelsFiltered.map((model) => model.name),
[discoveredModelsFiltered]
);
const allVisibleDiscoveredSelected = useMemo(
() =>
visibleDiscoveredModelNames.length > 0 &&
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
[modelDiscoverySelected, visibleDiscoveredModelNames]
);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
@@ -247,7 +264,10 @@ export function AiProvidersCodexEditPage() {
});
if (addedCount > 0) {
showNotification(t('ai_providers.codex_models_fetch_added', { count: addedCount }), 'success');
showNotification(
t('ai_providers.codex_models_fetch_added', { count: addedCount }),
'success'
);
}
},
[setForm, showNotification, t]
@@ -320,6 +340,22 @@ export function AiProvidersCodexEditPage() {
void fetchCodexModelDiscovery();
}, [fetchCodexModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
useEffect(() => {
const availableNames = new Set(discoveredModels.map((model) => model.name));
setModelDiscoverySelected((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (availableNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [discoveredModels]);
const toggleModelDiscoverySelection = (name: string) => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
@@ -332,8 +368,22 @@ export function AiProvidersCodexEditPage() {
});
};
const handleSelectVisibleDiscoveredModels = useCallback(() => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
visibleDiscoveredModelNames.forEach((name) => next.add(name));
return next;
});
}, [visibleDiscoveredModelNames]);
const handleClearDiscoveredModelSelection = useCallback(() => {
setModelDiscoverySelected(new Set());
}, []);
const handleApplyDiscoveredModels = () => {
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
const selectedModels = discoveredModels.filter((model) =>
modelDiscoverySelected.has(model.name)
);
if (selectedModels.length) {
mergeDiscoveredModels(selectedModels);
}
@@ -374,7 +424,9 @@ export function AiProvidersCodexEditPage() {
updateConfigValue('codex-api-key', nextList);
clearCache('codex-api-key');
showNotification(
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
editIndex !== null
? t('notification.codex_config_updated')
: t('notification.codex_config_added'),
'success'
);
allowNextNavigation();
@@ -407,7 +459,8 @@ export function AiProvidersCodexEditPage() {
!invalidIndexParam &&
!invalidIndex &&
Boolean((form.baseUrl ?? '').trim());
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
const canApplyModelDiscovery =
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
return (
<SecondaryScreenShell
@@ -514,7 +567,9 @@ export function AiProvidersCodexEditPage() {
<div className={styles.modelConfigSection}>
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>{t('ai_providers.codex_models_label')}</label>
<label className={styles.modelConfigTitle}>
{t('ai_providers.codex_models_label')}
</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
@@ -595,7 +650,9 @@ export function AiProvidersCodexEditPage() {
}
>
<div className={styles.openaiModelsContent}>
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_hint')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.codex_models_fetch_hint')}
</div>
<div className={styles.openaiModelsEndpointSection}>
<label className={styles.openaiModelsEndpointLabel}>
{t('ai_providers.codex_models_fetch_url_label')}
@@ -624,41 +681,86 @@ export function AiProvidersCodexEditPage() {
onChange={(e) => setModelDiscoverySearch(e.target.value)}
disabled={modelDiscoveryFetching}
/>
{discoveredModels.length > 0 && (
<div className={styles.modelDiscoveryToolbar}>
<div className={styles.modelDiscoveryToolbarActions}>
<Button
variant="secondary"
size="sm"
onClick={handleSelectVisibleDiscoveredModels}
disabled={
disableControls ||
saving ||
modelDiscoveryFetching ||
discoveredModelsFiltered.length === 0 ||
allVisibleDiscoveredSelected
}
>
{t('ai_providers.model_discovery_select_visible')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearDiscoveredModelSelection}
disabled={
disableControls ||
saving ||
modelDiscoveryFetching ||
modelDiscoverySelected.size === 0
}
>
{t('ai_providers.model_discovery_clear_selection')}
</Button>
</div>
<div className={styles.modelDiscoverySelectionSummary}>
{t('ai_providers.model_discovery_selected_count', {
count: modelDiscoverySelected.size,
})}
</div>
</div>
)}
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
{modelDiscoveryFetching ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_loading')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.codex_models_fetch_loading')}
</div>
) : discoveredModels.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_empty')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.codex_models_fetch_empty')}
</div>
) : discoveredModelsFiltered.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_search_empty')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.codex_models_search_empty')}
</div>
) : (
<div className={styles.modelDiscoveryList}>
{discoveredModelsFiltered.map((model) => {
const checked = modelDiscoverySelected.has(model.name);
return (
<label
<SelectionCheckbox
key={model.name}
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
disabled={disableControls || saving || modelDiscoveryFetching}
ariaLabel={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
labelClassName={styles.modelDiscoverySelectionLabel}
label={
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
}
/>
);
})}
</div>
+141 -32
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { Modal } from '@/components/ui/Modal';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
@@ -42,7 +43,9 @@ const parseIndexParam = (value: string | undefined) => {
};
const stripGeminiModelResourceName = (value: string) => {
return String(value ?? '').trim().replace(/^\/?models\//i, '');
return String(value ?? '')
.trim()
.replace(/^\/?models\//i, '');
};
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
@@ -61,7 +64,9 @@ const buildGeminiSignature = (form: GeminiFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
form.priority !== undefined && Number.isFinite(form.priority)
? Math.trunc(form.priority)
: null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
@@ -89,7 +94,9 @@ export function AiProvidersGeminiEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm()));
const [baselineSignature, setBaselineSignature] = useState(() =>
buildGeminiSignature(buildEmptyForm())
);
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
@@ -113,7 +120,9 @@ export function AiProvidersGeminiEditPage() {
const invalidIndex = editIndex !== null && !initialData;
const title =
editIndex !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title');
editIndex !== null
? t('ai_providers.gemini_edit_modal_title')
: t('ai_providers.gemini_add_modal_title');
const handleBack = useCallback(() => {
const state = location.state as LocationState;
@@ -196,6 +205,16 @@ export function AiProvidersGeminiEditPage() {
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
});
}, [discoveredModels, modelDiscoverySearch]);
const visibleDiscoveredModelNames = useMemo(
() => discoveredModelsFiltered.map((model) => model.name),
[discoveredModelsFiltered]
);
const allVisibleDiscoveredSelected = useMemo(
() =>
visibleDiscoveredModelNames.length > 0 &&
visibleDiscoveredModelNames.every((name) => modelDiscoverySelected.has(name)),
[modelDiscoverySelected, visibleDiscoveredModelNames]
);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
@@ -225,7 +244,10 @@ export function AiProvidersGeminiEditPage() {
});
if (addedCount > 0) {
showNotification(t('ai_providers.gemini_models_fetch_added', { count: addedCount }), 'success');
showNotification(
t('ai_providers.gemini_models_fetch_added', { count: addedCount }),
'success'
);
}
},
[setForm, showNotification, t]
@@ -306,6 +328,22 @@ export function AiProvidersGeminiEditPage() {
void fetchGeminiModelDiscovery();
}, [fetchGeminiModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
useEffect(() => {
const availableNames = new Set(discoveredModels.map((model) => model.name));
setModelDiscoverySelected((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (availableNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [discoveredModels]);
const toggleModelDiscoverySelection = (name: string) => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
@@ -318,8 +356,22 @@ export function AiProvidersGeminiEditPage() {
});
};
const handleSelectVisibleDiscoveredModels = useCallback(() => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
visibleDiscoveredModelNames.forEach((name) => next.add(name));
return next;
});
}, [visibleDiscoveredModelNames]);
const handleClearDiscoveredModelSelection = useCallback(() => {
setModelDiscoverySelected(new Set());
}, []);
const handleApplyDiscoveredModels = () => {
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
const selectedModels = discoveredModels.filter((model) =>
modelDiscoverySelected.has(model.name)
);
if (selectedModels.length) {
mergeDiscoveredModels(selectedModels);
}
@@ -374,7 +426,9 @@ export function AiProvidersGeminiEditPage() {
updateConfigValue('gemini-api-key', nextList);
clearCache('gemini-api-key');
showNotification(
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
editIndex !== null
? t('notification.gemini_key_updated')
: t('notification.gemini_key_added'),
'success'
);
allowNextNavigation();
@@ -400,8 +454,10 @@ export function AiProvidersGeminiEditPage() {
updateConfigValue,
]);
const canOpenModelDiscovery = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
const canOpenModelDiscovery =
!disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const canApplyModelDiscovery =
!disableControls && !saving && !modelDiscoveryFetching && modelDiscoverySelected.size > 0;
return (
<SecondaryScreenShell
@@ -501,7 +557,9 @@ export function AiProvidersGeminiEditPage() {
<div className={styles.modelConfigSection}>
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>{t('ai_providers.gemini_models_label')}</label>
<label className={styles.modelConfigTitle}>
{t('ai_providers.gemini_models_label')}
</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
@@ -572,14 +630,20 @@ export function AiProvidersGeminiEditPage() {
>
{t('common.cancel')}
</Button>
<Button size="sm" onClick={handleApplyDiscoveredModels} disabled={!canApplyModelDiscovery}>
<Button
size="sm"
onClick={handleApplyDiscoveredModels}
disabled={!canApplyModelDiscovery}
>
{t('ai_providers.gemini_models_fetch_apply')}
</Button>
</>
}
>
<div className={styles.openaiModelsContent}>
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_hint')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.gemini_models_fetch_hint')}
</div>
<div className={styles.openaiModelsEndpointSection}>
<label className={styles.openaiModelsEndpointLabel}>
{t('ai_providers.gemini_models_fetch_url_label')}
@@ -608,41 +672,86 @@ export function AiProvidersGeminiEditPage() {
onChange={(e) => setModelDiscoverySearch(e.target.value)}
disabled={modelDiscoveryFetching}
/>
{discoveredModels.length > 0 && (
<div className={styles.modelDiscoveryToolbar}>
<div className={styles.modelDiscoveryToolbarActions}>
<Button
variant="secondary"
size="sm"
onClick={handleSelectVisibleDiscoveredModels}
disabled={
disableControls ||
saving ||
modelDiscoveryFetching ||
discoveredModelsFiltered.length === 0 ||
allVisibleDiscoveredSelected
}
>
{t('ai_providers.model_discovery_select_visible')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearDiscoveredModelSelection}
disabled={
disableControls ||
saving ||
modelDiscoveryFetching ||
modelDiscoverySelected.size === 0
}
>
{t('ai_providers.model_discovery_clear_selection')}
</Button>
</div>
<div className={styles.modelDiscoverySelectionSummary}>
{t('ai_providers.model_discovery_selected_count', {
count: modelDiscoverySelected.size,
})}
</div>
</div>
)}
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
{modelDiscoveryFetching ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_loading')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.gemini_models_fetch_loading')}
</div>
) : discoveredModels.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_empty')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.gemini_models_fetch_empty')}
</div>
) : discoveredModelsFiltered.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_search_empty')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.gemini_models_search_empty')}
</div>
) : (
<div className={styles.modelDiscoveryList}>
{discoveredModelsFiltered.map((model) => {
const checked = modelDiscoverySelected.has(model.name);
return (
<label
<SelectionCheckbox
key={model.name}
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
disabled={disableControls || saving || modelDiscoveryFetching}
ariaLabel={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
labelClassName={styles.modelDiscoverySelectionLabel}
label={
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
}
/>
);
})}
</div>
+90 -19
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { modelsApi } from '@/services/api';
@@ -48,6 +49,14 @@ export function AiProvidersOpenAIModelsPage() {
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
});
}, [models, search]);
const visibleModelNames = useMemo(
() => filteredModels.map((model) => model.name),
[filteredModels]
);
const allVisibleSelected = useMemo(
() => visibleModelNames.length > 0 && visibleModelNames.every((name) => selected.has(name)),
[selected, visibleModelNames]
);
const fetchOpenaiModelDiscovery = useCallback(
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
@@ -98,6 +107,22 @@ export function AiProvidersOpenAIModelsPage() {
void fetchOpenaiModelDiscovery();
}, [fetchOpenaiModelDiscovery, form.baseUrl, initialLoading]);
useEffect(() => {
const availableNames = new Set(models.map((model) => model.name));
setSelected((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (availableNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [models]);
const handleBack = useCallback(() => {
navigate(-1);
}, [navigate]);
@@ -126,6 +151,18 @@ export function AiProvidersOpenAIModelsPage() {
});
};
const handleSelectVisible = useCallback(() => {
setSelected((prev) => {
const next = new Set(prev);
visibleModelNames.forEach((name) => next.add(name));
return next;
});
}, [visibleModelNames]);
const handleClearSelection = useCallback(() => {
setSelected(new Set());
}, []);
const handleApply = () => {
const selectedModels = models.filter((model) => selected.has(model.name));
if (selectedModels.length) {
@@ -134,7 +171,7 @@ export function AiProvidersOpenAIModelsPage() {
handleBack();
};
const canApply = !disableControls && !saving && !fetching;
const canApply = !disableControls && !saving && !fetching && selected.size > 0;
return (
<SecondaryScreenShell
@@ -200,9 +237,42 @@ export function AiProvidersOpenAIModelsPage() {
onChange={(e) => setSearch(e.target.value)}
disabled={fetching}
/>
{models.length > 0 && (
<div className={styles.modelDiscoveryToolbar}>
<div className={styles.modelDiscoveryToolbarActions}>
<Button
variant="secondary"
size="sm"
onClick={handleSelectVisible}
disabled={
disableControls ||
saving ||
fetching ||
filteredModels.length === 0 ||
allVisibleSelected
}
>
{t('ai_providers.model_discovery_select_visible')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
disabled={disableControls || saving || fetching || selected.size === 0}
>
{t('ai_providers.model_discovery_clear_selection')}
</Button>
</div>
<div className={styles.modelDiscoverySelectionSummary}>
{t('ai_providers.model_discovery_selected_count', { count: selected.size })}
</div>
</div>
)}
{error && <div className="error-box">{error}</div>}
{fetching ? (
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_loading')}</div>
<div className={styles.sectionHint}>
{t('ai_providers.openai_models_fetch_loading')}
</div>
) : models.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_empty')}</div>
) : filteredModels.length === 0 ? (
@@ -212,29 +282,30 @@ export function AiProvidersOpenAIModelsPage() {
{filteredModels.map((model) => {
const checked = selected.has(model.name);
return (
<label
<SelectionCheckbox
key={model.name}
checked={checked}
onChange={() => toggleSelection(model.name)}
disabled={disableControls || saving || fetching}
ariaLabel={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
labelClassName={styles.modelDiscoverySelectionLabel}
label={
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
}
/>
);
})}
</div>
+34 -3
View File
@@ -327,15 +327,18 @@
}
.modelDiscoveryRow {
display: flex;
display: inline-flex;
align-items: flex-start;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
transition:
background 0.15s ease,
border-color 0.15s ease;
input[type='checkbox'] {
margin-top: 2px;
@@ -348,6 +351,11 @@
}
}
.modelDiscoverySelectionLabel {
flex: 1;
min-width: 0;
}
.modelDiscoveryRowSelected {
border-color: var(--primary-color);
background: var(--bg-tertiary);
@@ -376,6 +384,27 @@
line-height: 1.4;
}
.modelDiscoveryToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
}
.modelDiscoveryToolbarActions {
display: flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
}
.modelDiscoverySelectionSummary {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.openaiTestButtonSuccess {
background-color: var(--success-badge-bg, #d1fae5);
border-color: var(--success-badge-border, #6ee7b7);
@@ -417,7 +446,9 @@
width: 100%;
height: 8px;
border-radius: 2px;
transition: transform 0.15s ease, opacity 0.15s ease;
transition:
transform 0.15s ease,
opacity 0.15s ease;
.statusBlockWrapper:hover &,
.statusBlockWrapper.statusBlockActive & {
+3 -27
View File
@@ -356,39 +356,15 @@
.fileGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
}
.fileGridQuotaManaged {
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr));
}
.fileGridCompact {
grid-template-columns: repeat(4, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
}
.antigravityGrid {
+62 -36
View File
@@ -53,7 +53,9 @@ import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFi
import {
isAuthFilesSortMode,
readAuthFilesUiState,
readPersistedAuthFilesCompactMode,
writeAuthFilesUiState,
writePersistedAuthFilesCompactMode,
type AuthFilesSortMode,
} from '@/features/authFiles/uiState';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
@@ -88,6 +90,7 @@ export function AuthFilesPage() {
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const [uiStateHydrated, setUiStateHydrated] = useState(false);
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
const previousSelectionCountRef = useRef(0);
@@ -176,46 +179,58 @@ export function AuthFilesPage() {
const pageSize = compactMode ? pageSizeByMode.compact : pageSizeByMode.regular;
useEffect(() => {
const persisted = readAuthFilesUiState();
if (!persisted) return;
const persistedCompactMode = readPersistedAuthFilesCompactMode();
if (typeof persistedCompactMode === 'boolean') {
setCompactMode(persistedCompactMode);
}
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
setFilter(persisted.filter);
}
if (typeof persisted.problemOnly === 'boolean') {
setProblemOnly(persisted.problemOnly);
}
if (typeof persisted.compactMode === 'boolean') {
setCompactMode(persisted.compactMode);
}
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
const legacyPageSize =
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
? clampCardPageSize(persisted.pageSize)
: null;
const regularPageSize =
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
? clampCardPageSize(persisted.regularPageSize)
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
const compactPageSize =
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
? clampCardPageSize(persisted.compactPageSize)
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
setPageSizeByMode({
regular: regularPageSize,
compact: compactPageSize,
});
if (isAuthFilesSortMode(persisted.sortMode)) {
setSortMode(persisted.sortMode);
const persisted = readAuthFilesUiState();
if (persisted) {
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
setFilter(persisted.filter);
}
if (typeof persisted.problemOnly === 'boolean') {
setProblemOnly(persisted.problemOnly);
}
if (
typeof persistedCompactMode !== 'boolean' &&
typeof persisted.compactMode === 'boolean'
) {
setCompactMode(persisted.compactMode);
}
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
const legacyPageSize =
typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)
? clampCardPageSize(persisted.pageSize)
: null;
const regularPageSize =
typeof persisted.regularPageSize === 'number' && Number.isFinite(persisted.regularPageSize)
? clampCardPageSize(persisted.regularPageSize)
: legacyPageSize ?? DEFAULT_REGULAR_PAGE_SIZE;
const compactPageSize =
typeof persisted.compactPageSize === 'number' && Number.isFinite(persisted.compactPageSize)
? clampCardPageSize(persisted.compactPageSize)
: legacyPageSize ?? DEFAULT_COMPACT_PAGE_SIZE;
setPageSizeByMode({
regular: regularPageSize,
compact: compactPageSize,
});
if (isAuthFilesSortMode(persisted.sortMode)) {
setSortMode(persisted.sortMode);
}
}
setUiStateHydrated(true);
}, []);
useEffect(() => {
if (!uiStateHydrated) return;
writeAuthFilesUiState({
filter,
problemOnly,
@@ -227,7 +242,18 @@ export function AuthFilesPage() {
compactPageSize: pageSizeByMode.compact,
sortMode,
});
}, [filter, problemOnly, compactMode, search, page, pageSize, pageSizeByMode, sortMode]);
writePersistedAuthFilesCompactMode(compactMode);
}, [
compactMode,
filter,
page,
pageSize,
pageSizeByMode,
problemOnly,
search,
sortMode,
uiStateHydrated,
]);
useEffect(() => {
setPageSizeInput(String(pageSize));
+9 -3
View File
@@ -6,6 +6,7 @@ import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
import { parse as parseYaml, parseDocument } from 'yaml';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
@@ -36,6 +37,8 @@ function readCommercialModeFromYaml(yamlContent: string): boolean {
export function ConfigPage() {
const { t } = useTranslation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const showNotification = useNotificationStore((state) => state.showNotification);
const showConfirmation = useNotificationStore((state) => state.showConfirmation);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -79,6 +82,7 @@ export function ConfigPage() {
const disableControls = connectionStatus !== 'connected';
const isDirty = dirty || visualDirty;
const shouldRenderFloatingActions = isCurrentLayer;
const hasVisualModeError = !!visualParseError;
const hasVisualValidationErrors =
activeTab === 'visual' &&
@@ -358,7 +362,7 @@ export function ConfigPage() {
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined' || !shouldRenderFloatingActions) return;
const actionsEl = floatingActionsRef.current;
if (!actionsEl) return;
@@ -379,7 +383,7 @@ export function ConfigPage() {
window.removeEventListener('resize', updatePadding);
document.documentElement.style.removeProperty('--config-action-bar-height');
};
}, []);
}, [shouldRenderFloatingActions]);
// CodeMirror extensions
const extensions = useMemo(
@@ -622,7 +626,9 @@ export function ConfigPage() {
</div>
</div>
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
{shouldRenderFloatingActions && typeof document !== 'undefined'
? createPortal(floatingActions, document.body)
: null}
<DiffModal
open={diffModalOpen}
original={serverYaml}