From c0c79b0bb6c7ba9f0a2d4360be4f2cb7bc4974b3 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 28 Sep 2026 14:26:05 +0800 Subject: [PATCH] Optimize UI display issues Fixes #321 --- package.json | 1 + src/pages/ApiAccessPage.tsx | 5 +- src/styles.css | 15 ++-- tests/api-provider-dialog-layout-ui.cjs | 115 ++++++++++++++++++++++++ tests/fixtures/provider-duplicates.tsx | 17 ++-- tests/model-selection-ui.cjs | 2 +- 6 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 tests/api-provider-dialog-layout-ui.cjs diff --git a/package.json b/package.json index c9fc636..2dc051b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "check": "tsc --noEmit", "preview": "vite preview", "test:ui:accessibility": "node tests/app-shell-accessibility-ui.cjs", + "test:ui:api-provider-dialog": "node tests/api-provider-dialog-layout-ui.cjs", "screenshots": "node scripts/capture-readme-screenshots.cjs", "tauri": "tauri" }, diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index eb24818..9dd8364 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -11,6 +11,7 @@ import { useRef, useState, } from 'react'; +import { createPortal } from 'react-dom'; import { invoke } from '@tauri-apps/api/core'; import { closestCenter, @@ -2049,7 +2050,7 @@ export function ApiProviderDialog({ ? t('apiAccess.models.autoHint') : t('apiAccess.models.allHint'); - return ( + const content = ( <>
event.currentTarget === event.target && !busy && onClose()}>
void submit(event)}> @@ -2257,4 +2258,6 @@ export function ApiProviderDialog({ ) : null} ); + // Keep both dialogs outside the page's query container, which can contain fixed elements in WebKit. + return typeof document === 'undefined' ? content : createPortal(content, document.body); } diff --git a/src/styles.css b/src/styles.css index 607c8cf..d35d45a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5108,17 +5108,16 @@ textarea:focus-visible { min-height: 360px; } -.real-provider-workbench + .config-dialog-backdrop .management-dialog { - grid-template-rows: auto repeat(5, minmax(52px, auto)) auto; - height: auto; - max-height: min(720px, calc(100vh - 48px)); - overflow: auto; -} - -.real-provider-workbench + .config-dialog-backdrop .api-provider-dialog { +.config-dialog.api-provider-dialog { grid-template-rows: auto; grid-auto-rows: auto; width: min(560px, 100%); + height: auto; + min-height: 0; + max-height: min(720px, calc(100vh - 48px)); + max-height: min(720px, calc(100dvh - 48px)); + overflow: auto; + overscroll-behavior: contain; gap: 10px; padding: 16px 18px; scrollbar-gutter: stable; diff --git a/tests/api-provider-dialog-layout-ui.cjs b/tests/api-provider-dialog-layout-ui.cjs new file mode 100644 index 0000000..cdad1b8 --- /dev/null +++ b/tests/api-provider-dialog-layout-ui.cjs @@ -0,0 +1,115 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +(async () => { + const { createServer } = await import('vite'); + const react = (await import('@vitejs/plugin-react')).default; + const server = await createServer({ + configFile: false, + root: path.resolve(__dirname, '..'), + plugins: [react()], + optimizeDeps: { entries: ['tests/fixtures/provider-duplicates.html'] }, + logLevel: 'error', + server: { host: '127.0.0.1', port: 1421, strictPort: false, watch: null }, + }); + let browser; + try { + await server.listen(); + const base = `http://127.0.0.1:${server.httpServer.address().port}`; + await (await fetch(base)).text(); + const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined); + browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] }); + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + page.setDefaultTimeout(10000); + const errors = []; + page.on('pageerror', (error) => errors.push(String(error))); + await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort()); + await page.goto(`${base}/tests/fixtures/provider-duplicates.html`, { waitUntil: 'domcontentloaded' }); + await page.locator('.provider-category-panel button').filter({ hasText: 'Claude' }).click(); + await page.locator('.real-provider-row').waitFor(); + await page.evaluate(() => { + window.providerFixture.records[0].models = Array.from({ length: 30 }, (_, index) => ({ name: `model-${index}` })); + }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click(); + const form = page.getByRole('dialog', { name: 'Edit API Connection', exact: true }); + assert.equal(await form.locator('.model-config-entry').count(), 30); + + // Older WebKit makes an inline-size query container a fixed-position containing block. + // Explicit layout containment reproduces that behavior on current browser engines. + await page.addStyleTag({ content: '.content { contain: layout; min-height: 1400px; }' }); + + const assertViewportOverlay = async (selector) => { + const bounds = await page.locator(selector).evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }); + const viewport = page.viewportSize(); + assert.deepEqual(bounds, { x: 0, y: 0, ...viewport }, `${selector} must cover the viewport independently of page containment`); + }; + const assertDialogControls = async (dialog) => { + const bounds = await dialog.boundingBox(); + const viewport = page.viewportSize(); + assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= viewport.width + 1 + && bounds.y + bounds.height <= viewport.height + 1, 'Dialog must fit inside the window'); + for (const button of ['Close', 'Cancel', 'Save']) { + const control = dialog.getByRole('button', { name: button, exact: true }); + const visible = await control.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return rect.top >= 0 && rect.bottom <= innerHeight + && element.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)); + }); + assert.equal(visible, true, `${button} must remain visible and unobstructed`); + } + const dimensions = await dialog.evaluate((element) => ({ client: element.clientWidth, scroll: element.scrollWidth })); + assert.ok(dimensions.scroll <= dimensions.client + 1, 'Dialog must not overflow horizontally'); + }; + + for (const viewport of [{ width: 1280, height: 800 }, { width: 1024, height: 600 }, { width: 640, height: 600 }]) { + await page.setViewportSize(viewport); + await assertViewportOverlay('.config-dialog-backdrop'); + await assertDialogControls(form); + await form.getByLabel('Remark', { exact: true }).fill('Small-window edit'); + await form.locator('.provider-advanced-settings summary').click(); + const headers = form.getByLabel(/Custom Headers/); + await headers.fill('X-Layout-Test: reachable'); + await assertDialogControls(form); + const scroll = await form.evaluate((element) => ({ top: element.scrollTop, max: element.scrollHeight - element.clientHeight })); + assert.ok(scroll.top > 0 && scroll.max > 0, 'Advanced settings must be reachable by scrolling inside the dialog'); + await form.locator('.provider-advanced-settings summary').click(); + } + + await form.getByRole('button', { name: 'Fetch Models', exact: true }).click(); + const models = page.getByRole('dialog', { name: 'Select Models', exact: true }); + await models.waitFor(); + await assertViewportOverlay('.model-discovery-backdrop'); + for (let index = 0; index < 5; index += 1) { + await page.keyboard.press('Tab'); + assert.equal(await models.evaluate((element) => element.contains(document.activeElement)), true); + } + await page.keyboard.press('Escape'); + await models.waitFor({ state: 'detached' }); + assert.equal(await form.getByRole('button', { name: 'Fetch Models', exact: true }).evaluate((element) => element === document.activeElement), true); + await assertDialogControls(form); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await form.waitFor({ state: 'detached' }); + const record = await page.evaluate(() => window.providerFixture.records[0]); + assert.equal(record.headers['X-Layout-Test'], 'reachable'); + assert.equal(record.models.length, 30); + assert.equal(await page.evaluate(() => document.body.style.overflow), ''); + + await page.locator('.provider-category-panel button').filter({ hasText: 'OpenAI' }).click(); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + const add = page.getByRole('dialog', { name: 'Add API Connection', exact: true }); + await assertViewportOverlay('.config-dialog-backdrop'); + await assertDialogControls(add); + await add.getByRole('button', { name: 'Cancel', exact: true }).click(); + await add.waitFor({ state: 'detached' }); + assert.deepEqual(errors, []); + console.log('PASS: #321 provider dialogs fit small windows, escape page containment, scroll long forms, preserve nested focus, and save edits.'); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/fixtures/provider-duplicates.tsx b/tests/fixtures/provider-duplicates.tsx index 6bc2d28..aa2f19d 100644 --- a/tests/fixtures/provider-duplicates.tsx +++ b/tests/fixtures/provider-duplicates.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { mockIPC } from '@tauri-apps/api/mocks'; import { I18nProvider } from '../../src/i18n'; import { ApiAccessPage, providerRemarkIdentity, type ApiAccessRemarkLocator, type ProviderSection } from '../../src/pages/ApiAccessPage'; +import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../../src/services/managementApi'; import '../../src/styles/index.css'; localStorage.setItem('easy-cli-proxy-api.locale', 'en'); @@ -35,22 +36,24 @@ mockIPC((cmd, rawArgs) => { if (cmd === 'management_request') { const { request } = rawArgs as { request: { method: string; path: string; body: Record[] } }; const section = request.path.slice(1); + if (request.method === 'POST' && section === 'requests/api-call') { + return { status_code: 200, body: { data: [{ id: 'discovered-model' }] } }; + } if (request.method === 'GET') { - if (section === 'config') return { 'claude-api-key': structuredClone(fixture.providerFixture.records) }; - return { [section]: section === 'claude-api-key' - ? fixture.providerFixture.records.map((record, index) => ({ ...record, 'auth-index': `auth-${index}` })) - : [] }; + const groups = groupLegacyProviderRecords('claude', fixture.providerFixture.records); + if (section === 'config') return { 'api-keys': { claude: groups } }; + return section === 'config/api-keys/claude' ? groups : []; } fixture.providerFixture.writes.push({ method: request.method, body: structuredClone(request.body) }); - if (request.method !== 'PUT' || section !== 'claude-api-key') throw new Error('Unexpected provider mutation'); - fixture.providerFixture.records = structuredClone(request.body); + if (request.method !== 'PUT' || section !== 'config/api-keys/claude') throw new Error('Unexpected provider mutation'); + fixture.providerFixture.records = flattenV8ProviderGroups('claude', structuredClone(request.body)); return { status: 'ok' }; } throw new Error(`Unhandled fixture command: ${cmd}`); }); createRoot(document.getElementById('root')!).render( -
+
, ); diff --git a/tests/model-selection-ui.cjs b/tests/model-selection-ui.cjs index 56c8d3a..1148bcf 100644 --- a/tests/model-selection-ui.cjs +++ b/tests/model-selection-ui.cjs @@ -185,7 +185,7 @@ const { mkdirSync } = require('node:fs'); 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').press('Enter'); + await page.locator('.model-config-heading button').click(); await ready(); const popup = page.locator('.model-transfer-dialog'); const rect = await popup.boundingBox();