diff --git a/src/components/ModelSelectionPanel.tsx b/src/components/ModelSelectionPanel.tsx new file mode 100644 index 0000000..90a4c16 --- /dev/null +++ b/src/components/ModelSelectionPanel.tsx @@ -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(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 ( +
+
+

{t(selected ? 'apiAccess.modelDialog.selected' : 'apiAccess.modelDialog.unselected')}

+ {query ? `${visibleModels.length} / ${models.length}` : models.length} + +
+
+
+
+ {visibleModels.length ? visibleModels.map((model) => ( + + )) : ( +
+ {t(query ? 'apiAccess.modelDialog.noMatch' : loading ? 'apiAccess.modelDialog.fetching' : selected ? 'apiAccess.modelDialog.emptySelected' : 'apiAccess.modelDialog.emptyUnselected')} +
+ )} +
+
+ ); +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 4ec7e9f..881ef65 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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': 'モデルの取得に失敗しました', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 84d7a78..12b7499 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1240,6 +1240,20 @@ export const en: Record = { '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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index fe32d83..f048e05 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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': '获取模型失败', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index d718d52..373893f 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -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; }; -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>( - () => new Set(initialDraft.models.map((model) => model.name.toLowerCase())), + () => new Set(mergeModelOptions(initialDraft.models).map((model) => model.name.toLowerCase())), ); const modelCardRef = useRef(null); + const modelDialogRef = useRef(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('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('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({ {modelDiscoveryOpen ? ( -
event.currentTarget === event.target && setModelDiscoveryOpen(false)}> -
+
event.currentTarget === event.target && closeModelDiscovery()}> +

{t('apiAccess.modelDialog.title')}

{t(definition.labelKey)}
- +
-
-
diff --git a/src/styles.css b/src/styles.css index 0ea1429..e1ab1d8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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)); diff --git a/tests/fixtures/model-selection.html b/tests/fixtures/model-selection.html new file mode 100644 index 0000000..8e2a1da --- /dev/null +++ b/tests/fixtures/model-selection.html @@ -0,0 +1,5 @@ + + + Model selection fixture +
+ diff --git a/tests/fixtures/model-selection.tsx b/tests/fixtures/model-selection.tsx new file mode 100644 index 0000000..8a77dec --- /dev/null +++ b/tests/fixtures/model-selection.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { I18nProvider } from '../../src/i18n'; +import { ApiProviderDialog, type ProviderDraft } from '../../src/pages/ApiAccessPage'; +import '../../src/styles.css'; + +const params = new URLSearchParams(location.search); +localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'en'); +document.documentElement.dataset.theme = params.get('theme') ?? 'light'; +const fixture = window as typeof window & { + fixtureCatalog: { name: string }[]; + fixtureFailFetch: boolean; + fixtureHoldNext: boolean; + fixturePending: (() => void)[]; + fixtureSaved: ProviderDraft | null; +}; +fixture.fixtureCatalog = [ + ...Array.from({ length: 240 }, (_, i) => ({ name: `gpt-${String(i + 1).padStart(3, '0')}` })), + ...Array.from({ length: 120 }, (_, i) => ({ name: `claude-${String(i + 1).padStart(3, '0')}` })), + ...Array.from({ length: 40 }, (_, i) => ({ name: `deepseek-${String(i + 1).padStart(3, '0')}` })), + { name: 'GPT-001' }, +]; +fixture.fixtureFailFetch = false; +fixture.fixtureHoldNext = false; +fixture.fixturePending = []; +fixture.fixtureSaved = null; +mockIPC(async (cmd, rawArgs) => { + if (cmd === 'set_app_locale') return null; + const args = rawArgs as { request?: { path?: string } }; + if (cmd === 'management_request' && args.request?.path === '/api-call') { + const response = fixture.fixtureFailFetch + ? { status_code: 503, body: 'Fixture discovery failed' } + : { status_code: 200, body: { data: fixture.fixtureCatalog.slice() } }; + if (fixture.fixtureHoldNext) { + fixture.fixtureHoldNext = false; + await new Promise((resolve) => fixture.fixturePending.push(resolve)); + } + return response; + } + throw new Error(`Unhandled fixture command: ${cmd}`); +}); + +const scenario = params.get('scenario') ?? 'saved'; +const initialDraft: ProviderDraft = { + name: '', apiKey: 'fixture-key', remark: '', baseUrl: 'https://models.example.test', priority: '', + models: scenario === 'saved' ? [{ name: ' gpt-001 ', alias: 'Focus' }, { name: 'custom-local', alias: 'Local alias' }] : [], + excludedModelsText: scenario === 'excluded' ? 'gpt-*' : '', +}; +createRoot(document.getElementById('root')!).render( + + {}} + onSave={async (draft) => { fixture.fixtureSaved = draft; return { saved: true }; }} + /> + , +); diff --git a/tests/model-selection-ui.cjs b/tests/model-selection-ui.cjs new file mode 100644 index 0000000..9cc10b2 --- /dev/null +++ b/tests/model-selection-ui.cjs @@ -0,0 +1,141 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const { mkdirSync } = require('node:fs'); + +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] }); + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 820 } }); + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(30000); + await page.route('**/*', route => route.request().url().startsWith('http://127.0.0.1:1423/') ? route.continue() : route.abort()); + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + const dialog = page.getByRole('dialog', { name: 'Select Models' }); + const left = dialog.getByRole('region', { name: 'Unselected Models', exact: true }); + const right = dialog.getByRole('region', { name: 'Selected Models', exact: true }); + const rows = panel => panel.locator('.model-transfer-row'); + const search = panel => panel.getByRole('textbox'); + const ready = () => page.waitForFunction(() => { + const refresh = document.querySelector('.model-transfer-summary button'); + return refresh && !refresh.disabled; + }); + const counts = async (unselected, selected) => { + assert.equal(Number(await left.locator('.model-transfer-count').innerText()), unselected); + assert.equal(Number(await right.locator('.model-transfer-count').innerText()), selected); + }; + const open = async (scenario) => { + await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?scenario=${scenario}`, { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + }; + + await open('saved'); + await counts(399, 2); + await search(right).fill('FOCUS'); + assert.equal(await rows(right).count(), 1, 'Search matches saved aliases, ignoring case'); + await right.getByRole('button', { name: 'Remove Results', exact: true }).click(); + assert.equal(await rows(right).count(), 0); + await right.getByRole('button', { name: 'Clear search', exact: true }).click(); + await counts(400, 1); + await search(left).fill(' GPT-00 '); + assert.equal(await rows(left).count(), 9); + await left.getByRole('button', { name: 'Add Results', exact: true }).click(); + assert.equal(await rows(left).count(), 0); + assert.equal(await rows(right).count(), 10, 'A filter on one side does not filter the other side'); + await search(right).fill('gpt-00'); + await right.getByRole('button', { name: 'Remove Results', exact: true }).click(); + await right.getByRole('button', { name: 'Clear search', exact: true }).click(); + await left.getByRole('button', { name: 'Clear search', exact: true }).click(); + await counts(400, 1); + + await right.getByRole('button', { name: 'Remove All', exact: true }).click(); + await counts(401, 0); + assert.equal(await dialog.getByRole('button', { name: 'Apply Selection (0)', exact: true }).isDisabled(), true); + await left.getByRole('button', { name: 'Add gpt-001', exact: true }).focus(); + await page.keyboard.press('Enter'); + assert.equal(await left.getByRole('button', { name: 'Add gpt-002', exact: true }).evaluate(el => el === document.activeElement), true); + await counts(400, 1); + await page.evaluate(() => { window.fixtureCatalog.push({ name: 'new-on-refresh' }); }); + await dialog.getByRole('button', { name: 'Refresh', exact: true }).click(); + await ready(); + await counts(401, 1); + assert.equal(await left.getByRole('button', { name: 'Add new-on-refresh', exact: true }).count(), 1); + + await search(left).fill('no-such-model'); + await page.evaluate(() => { window.fixtureFailFetch = true; }); + await dialog.getByRole('button', { name: 'Refresh', exact: true }).click(); + await ready(); + assert.match(await dialog.getByRole('alert').innerText(), /Fixture discovery failed/); + assert.equal(await rows(right).count(), 1, 'Failed refresh keeps selected models'); + await left.getByRole('button', { name: 'Clear search', exact: true }).click(); + await counts(401, 1); + await page.evaluate(() => { window.fixtureFailFetch = false; }); + await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + const saved = await page.evaluate(() => window.fixtureSaved); + assert.deepEqual(saved.models, [{ name: 'gpt-001', alias: 'Focus' }]); + assert.ok(saved.excludedModelsText.includes('gpt-002')); + + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + await right.getByRole('button', { name: 'Remove All', exact: true }).click(); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + assert.equal(await rows(right).count(), 1, 'Cancelling discards pending selection edits'); + await dialog.getByRole('button', { name: 'Apply Selection (1)', exact: true }).focus(); + await page.keyboard.press('Tab'); + assert.equal(await dialog.getByRole('button', { name: 'Close', exact: true }).evaluate(el => el === document.activeElement), true); + await page.keyboard.press('Escape'); + assert.equal(await dialog.count(), 0); + assert.equal(await page.getByRole('button', { name: 'Fetch Models', exact: true }).evaluate(el => el === document.activeElement), true); + + await page.evaluate(() => { window.fixtureHoldNext = true; }); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await page.waitForFunction(() => window.fixturePending.length === 1); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.getByRole('textbox', { name: 'Base URL', exact: true }).fill('https://new.example.test'); + await page.evaluate(() => { window.fixtureCatalog = [{ name: 'fresh-model' }]; }); + await page.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + await ready(); + await counts(1, 1); + await page.evaluate(async () => { + window.fixturePending.shift()(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + await counts(1, 1); + assert.equal(await left.getByRole('button', { name: 'Add fresh-model', exact: true }).count(), 1, 'Cancelled response cannot replace the current list'); + + await open('excluded'); + await counts(240, 160); + await open('new'); + await counts(0, 400); + await right.getByRole('button', { name: 'Remove All', exact: true }).click(); + await dialog.getByRole('button', { name: 'Refresh', exact: true }).click(); + await ready(); + await counts(400, 0); + await left.getByRole('button', { name: 'Add All', exact: true }).click(); + await counts(0, 400); + assert.deepEqual(errors, []); + + mkdirSync('misc', { recursive: true }); + for (const [locale, theme, width, height] of [['zh-CN', 'light', 1280, 820], ['en', 'dark', 1280, 820], ['ja', 'light', 390, 740]]) { + await page.setViewportSize({ width, height }); + await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?locale=${locale}&theme=${theme}`); + await page.locator('.model-config-heading button').click(); + await ready(); + const popup = page.locator('.model-transfer-dialog'); + const rect = await popup.boundingBox(); + assert.ok(rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= width && rect.y + rect.height <= height); + assert.equal(await popup.evaluate(el => el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight), false, 'Dialog must not overflow'); + for (const panel of await popup.locator('.model-transfer-panel').all()) { + assert.equal(await panel.evaluate(el => el.scrollWidth > el.clientWidth), false, 'Panel must not overflow horizontally'); + } + await page.screenshot({ path: `misc/model-selection-${locale}-${theme}.png` }); + } + console.log('PASS: 400 models, independent searches, filtered transfers, empty selection recovery, keyboard focus, refresh errors, selection persistence, cancel, stale responses, exclusions, save, and responsive themes'); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; });