fix: improve UI accessibility and responsive layouts

This commit is contained in:
lzt404 committed 2026-09-27 16:33:50 +08:00
1 parent cf40abe7ee
commit 89bdbc7aea
56 files changed
+4563 -3814

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 158 KiB

+2
View File
@@ -8,6 +8,8 @@
"build": "tsc && vite build",
"check": "tsc --noEmit",
"preview": "vite preview",
"test:ui:accessibility": "node tests/app-shell-accessibility-ui.cjs",
"screenshots": "node scripts/capture-readme-screenshots.cjs",
"tauri": "tauri"
},
"dependencies": {
+84
View File
@@ -0,0 +1,84 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const fs = require('node:fs/promises');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const locales = [
{ directory: 'zh-CN', value: 'zh-CN' },
{ directory: 'en', value: 'en' },
{ directory: 'jp', value: 'ja' },
];
const pages = [
{ file: '1.png', navigationIndex: 0 },
{ file: '2.png', navigationIndex: 2 },
{ file: '3.png', navigationIndex: 1 },
{ file: '4.png', navigationIndex: 4 },
{ file: '5.png', navigationIndex: 3 },
];
(async () => {
const { createServer } = await import('vite');
let baseUrl = 'http://127.0.0.1:1420/';
let server;
let browser;
try {
try {
const response = await fetch(baseUrl);
if (!response.ok) throw new Error(String(response.status));
} catch {
const react = (await import('@vitejs/plugin-react')).default;
server = await createServer({
configFile: false,
root,
plugins: [react()],
logLevel: 'error',
server: { host: '127.0.0.1', port: 1420, strictPort: false },
});
await server.listen();
const address = server.httpServer.address();
if (typeof address === 'object' && address) {
baseUrl = `http://127.0.0.1:${address.port}/`;
}
const response = await fetch(baseUrl);
if (!response.ok) throw new Error(`Vite server failed at ${baseUrl}`);
await response.text();
}
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
browser = await chromium.launch(channel ? { channel, headless: true } : { headless: true });
for (const locale of locales) {
const context = await browser.newContext({
viewport: { width: 1396, height: 900 },
deviceScaleFactor: 1,
colorScheme: 'light',
});
const page = await context.newPage();
await page.goto(`${baseUrl}?mock=running`, { waitUntil: 'commit' });
await page.locator('.app-shell').waitFor();
if (locale.value !== 'zh-CN') {
await page.locator('.sidebar-language-trigger').click();
await page.locator(`#sidebar-language-list [role="option"] span[lang="${locale.value}"]`).click();
}
await page.locator('#browser-mock-toolbar').evaluate((element) => { element.style.display = 'none'; });
const outputDirectory = path.join(root, 'docs', 'screenshots', locale.directory);
await fs.mkdir(outputDirectory, { recursive: true });
for (const target of pages) {
await page.locator('.nav-section button').nth(target.navigationIndex).click();
await page.waitForTimeout(350);
await page.screenshot({
path: path.join(outputDirectory, target.file),
animations: 'disabled',
});
}
await context.close();
}
console.log('Updated README screenshots for zh-CN, en and ja.');
} finally {
if (browser) await browser.close();
if (server) await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+35 -17
View File
@@ -1,5 +1,5 @@
import { MessageNotice } from './appNotice';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import {
@@ -35,6 +35,7 @@ import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate';
import { appUpdateIndicatorState } from './appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from './navigation';
import { useThemePreference } from './theme';
import { useDialogFocusTrap } from './components/useDialogFocusTrap';
const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG';
@@ -131,7 +132,13 @@ function AppContent() {
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [theme, setTheme] = useThemePreference();
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
const closeDialogRef = useRef<HTMLElement>(null);
const closeDialogRef = useDialogFocusTrap<HTMLElement>({
active: Boolean(windowsClosePrompt),
onEscape: windowsClosePrompt?.resolvingAction
? undefined
: () => setWindowsClosePrompt(null),
preventEscape: Boolean(windowsClosePrompt?.resolvingAction),
});
const languageMenuRef = useRef<HTMLDivElement>(null);
const languageButtonRef = useRef<HTMLButtonElement>(null);
const { status } = useCoreRuntime();
@@ -218,16 +225,21 @@ function AppContent() {
};
}, []);
useEffect(() => {
if (!windowsClosePrompt || windowsClosePrompt.resolvingAction) {
return;
}
const frame = window.requestAnimationFrame(() => {
closeDialogRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [windowsClosePrompt]);
const handleLanguageListKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const options = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'));
if (!options.length) return;
const current = options.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? options.length - 1
: event.key === 'ArrowDown'
? (Math.max(current, -1) + 1) % options.length
: (current <= 0 ? options.length : current) - 1;
event.preventDefault();
options[next]?.focus();
};
const select = (pageId: PageId) => {
if (!canOpenAppPage(pageId, coreReady)) {
@@ -383,6 +395,15 @@ function AppContent() {
aria-expanded={languageMenuOpen}
aria-controls="sidebar-language-list"
onClick={() => setLanguageMenuOpen((open) => !open)}
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
setLanguageMenuOpen(true);
window.requestAnimationFrame(() => {
const options = languageMenuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
options?.[event.key === 'ArrowUp' ? options.length - 1 : 0]?.focus();
});
}}
>
<Languages size={16} aria-hidden="true" />
<span lang={selectedLanguage.value}>{selectedLanguage.nativeLabel}</span>
@@ -398,6 +419,7 @@ function AppContent() {
className="sidebar-language-list"
role="listbox"
aria-label={t('app.language')}
onKeyDown={handleLanguageListKeyDown}
>
{languageOptions.map((option) => {
const selected = option.value === locale;
@@ -411,6 +433,7 @@ function AppContent() {
onClick={() => {
setLocale(option.value);
setLanguageMenuOpen(false);
window.requestAnimationFrame(() => languageButtonRef.current?.focus());
}}
>
<span lang={option.value}>{option.nativeLabel}</span>
@@ -466,11 +489,6 @@ function AppContent() {
aria-modal="true"
aria-labelledby="close-dialog-title"
aria-describedby="close-dialog-description"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
}
}}
>
<button
type="button"
+7
View File
@@ -13,6 +13,7 @@ import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { Download, RefreshCw } from 'lucide-react';
import { getCurrentLocale, translate, useI18n, type AppLocale } from './i18n';
import { useDialogFocusTrap } from './components/useDialogFocusTrap';
export type AppUpdateInfo = {
currentVersion: string;
@@ -197,6 +198,11 @@ export function AppUpdateDialog() {
install,
cancel,
} = useAppUpdate();
const dialogRef = useDialogFocusTrap<HTMLElement>({
active: confirmOpen || task.running,
onEscape: confirmOpen ? dismissConfirm : undefined,
preventEscape: task.running,
});
if (!confirmOpen && !task.running) return null;
@@ -210,6 +216,7 @@ export function AppUpdateDialog() {
return (
<div className="install-dialog-backdrop app-update-dialog-backdrop">
<section
ref={dialogRef}
className="install-dialog app-update-dialog"
role="dialog"
aria-modal="true"
+4 -2
View File
@@ -4,6 +4,7 @@ import { Check, Copy, LoaderCircle, Search, X } from 'lucide-react';
import { useI18n } from '../i18n';
import { managementApi } from '../services/managementApi';
import { oauthModelsFromPayload, type OAuthModelDefinition } from '../services/oauthModels';
import { useDialogFocusTrap } from './useDialogFocusTrap';
type AuthFileModelsDialogProps = {
name: string;
@@ -17,6 +18,7 @@ export function AuthFileModelsDialog({ name, onClose }: AuthFileModelsDialogProp
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [copied, setCopied] = useState('');
const dialogRef = useDialogFocusTrap<HTMLElement>({ onEscape: onClose });
useEffect(() => {
let cancelled = false;
@@ -45,14 +47,14 @@ export function AuthFileModelsDialog({ name, onClose }: AuthFileModelsDialogProp
return (
<div className="model-discovery-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}>
<section className="model-discovery-dialog auth-model-dialog auth-model-view-dialog" role="dialog" aria-modal="true" aria-labelledby="auth-model-view-title" onKeyDown={(event) => { if (event.key === 'Escape') onClose(); }}>
<section ref={dialogRef} className="model-discovery-dialog auth-model-dialog auth-model-view-dialog" role="dialog" aria-modal="true" aria-labelledby="auth-model-view-title">
<div className="model-discovery-header">
<div>
<h2 id="auth-model-view-title">{t('authFiles.models.viewTitle')}</h2>
<span className="auth-model-target">{name}</span>
<span>{t('authFiles.models.viewDescription')}</span>
</div>
<button type="button" className="icon-button quiet" onClick={onClose} title={t('common.close')}><X size={18} /></button>
<button type="button" className="icon-button quiet" onClick={onClose} title={t('common.close')} aria-label={t('common.close')}><X size={18} aria-hidden="true" /></button>
</div>
<div className="model-discovery-search">
<Search size={16} aria-hidden="true" />
+1 -1
View File
@@ -60,7 +60,7 @@ export function ConfirmationDialog({ title, message, confirmText, warning, detai
<section ref={dialogRef} className="config-dialog app-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId}>
<div className="config-dialog-heading">
<div><AlertTriangle size={20} aria-hidden="true" /><h2 id={titleId}>{title}</h2></div>
<button type="button" className="icon-button quiet" onClick={() => onDecision(false)} title={t('common.close')}><X size={18} /></button>
<button type="button" className="icon-button quiet" onClick={() => onDecision(false)} title={t('common.close')} aria-label={t('common.close')}><X size={18} aria-hidden="true" /></button>
</div>
<p id={descriptionId}>{message}</p>
{details?.length ? <dl className="app-confirm-details">{details.map((detail) => <div key={detail.label}><dt>{detail.label}</dt><dd>{detail.value}</dd></div>)}</dl> : null}
+25
View File
@@ -0,0 +1,25 @@
import type { KeyboardEvent } from 'react';
const horizontalKeys = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
export function handleHorizontalTabKey<T extends string>(
event: KeyboardEvent<HTMLButtonElement>,
tabs: readonly T[],
current: T,
activate: (tab: T) => void,
focusTab: (tab: T) => HTMLElement | null,
) {
if (!horizontalKeys.has(event.key) || tabs.length === 0) return;
const index = Math.max(0, tabs.indexOf(current));
const nextIndex = event.key === 'Home'
? 0
: event.key === 'End'
? tabs.length - 1
: event.key === 'ArrowRight'
? (index + 1) % tabs.length
: (index - 1 + tabs.length) % tabs.length;
const next = tabs[nextIndex];
event.preventDefault();
activate(next);
window.requestAnimationFrame(() => focusTab(next)?.focus());
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useRef, type RefObject } from 'react';
const FOCUSABLE_SELECTOR = [
'button:not(:disabled)',
'[href]',
'input:not(:disabled)',
'select:not(:disabled)',
'textarea:not(:disabled)',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
let bodyLockCount = 0;
let bodyOverflowBeforeLock = '';
function lockBodyScroll() {
if (bodyLockCount === 0) {
bodyOverflowBeforeLock = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
bodyLockCount += 1;
return () => {
bodyLockCount = Math.max(0, bodyLockCount - 1);
if (bodyLockCount === 0) document.body.style.overflow = bodyOverflowBeforeLock;
};
}
function visible(element: HTMLElement) {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0;
}
function focusableElements(container: HTMLElement) {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(visible);
}
function topModal() {
const dialogs = Array.from(document.querySelectorAll<HTMLElement>(
'dialog[open], [role="dialog"][aria-modal="true"], [role="alertdialog"][aria-modal="true"]',
)).filter(visible);
return dialogs[dialogs.length - 1] ?? null;
}
type DialogFocusOptions = {
active?: boolean;
onEscape?: () => void;
initialFocusRef?: RefObject<HTMLElement | null>;
preventEscape?: boolean;
};
export function useDialogFocusTrap<T extends HTMLElement>({
active = true,
onEscape,
initialFocusRef,
preventEscape = false,
}: DialogFocusOptions = {}) {
const dialogRef = useRef<T>(null);
const escapeRef = useRef(onEscape);
const initialFocus = useRef(initialFocusRef);
const preventEscapeRef = useRef(preventEscape);
const wasActiveRef = useRef(false);
const previousFocusRef = useRef<HTMLElement | null>(null);
if (active && !wasActiveRef.current && typeof document !== 'undefined') {
previousFocusRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}
wasActiveRef.current = active;
escapeRef.current = onEscape;
initialFocus.current = initialFocusRef;
preventEscapeRef.current = preventEscape;
useEffect(() => {
if (!active) return undefined;
const dialog = dialogRef.current;
if (!dialog) return undefined;
const previousFocus = previousFocusRef.current
?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
const unlockBodyScroll = lockBodyScroll();
const frame = window.requestAnimationFrame(() => {
const requested = initialFocus.current?.current;
const autoFocus = dialog.querySelector<HTMLElement>('[autofocus]');
const first = focusableElements(dialog)[0];
const target = requested && visible(requested) ? requested : autoFocus ?? first ?? dialog;
if (!dialog.hasAttribute('tabindex') && target === dialog) dialog.tabIndex = -1;
target.focus();
});
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (topModal() !== dialog) return;
if (event.key === 'Escape') {
if (!escapeRef.current && !preventEscapeRef.current) return;
event.preventDefault();
event.stopPropagation();
escapeRef.current?.();
return;
}
if (event.key !== 'Tab') return;
const controls = focusableElements(dialog);
if (controls.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = controls[0];
const last = controls[controls.length - 1];
const focusInside = dialog.contains(document.activeElement);
if (event.shiftKey && (!focusInside || document.activeElement === first)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (!focusInside || document.activeElement === last)) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown, true);
return () => {
window.cancelAnimationFrame(frame);
document.removeEventListener('keydown', handleKeyDown, true);
unlockBodyScroll();
if (previousFocus?.isConnected) previousFocus.focus();
previousFocusRef.current = null;
};
}, [active]);
return dialogRef;
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { AppErrorBoundary } from './AppErrorBoundary';
import App from './App';
import { I18nProvider } from './i18n';
import { initializeTheme } from './theme';
import './styles.css';
import './styles/index.css';
async function bootstrap() {
if (import.meta.env.DEV && !isTauri()) {
+1 -1
View File
@@ -458,7 +458,7 @@ function createState(scenario: BrowserMockScenario) {
tps: 58.4,
tpsSampleCount: 284,
averageLatencyMs: 1_284,
cacheHitRate: 38.6,
cacheHitRate: 0.386,
estimatedCost: 3.8421,
pricedRequests: 296,
timeline,
+33 -6
View File
@@ -71,6 +71,7 @@ import { CodexModelCatalogDialog } from './CodexModelCatalogDialog';
import { DeepSeekHarnessCatalogDialog } from './DeepSeekHarnessCatalogDialog';
import { AgentConfigBackupDialog } from './AgentConfigBackupDialog';
import { AgentConfigManagementPanel, AgentConfigurationFeedback, AgentRunControls } from './AgentControls';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
type AgentClientId =
| 'claude-code'
@@ -710,12 +711,13 @@ function AgentModelPicker({
searchRef.current?.focus();
}}
title={t('agents.model.clearSearch')}
aria-label={t('agents.model.clearSearch')}
>
<X size={14} />
<X size={14} aria-hidden="true" />
</button>
) : null}
{onRefresh ? <button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title={t('agents.model.refresh')}>
<RefreshCw size={14} className={loading ? 'spin' : ''} />
{onRefresh ? <button type="button" className="icon-button quiet" onClick={onRefresh} disabled={loading} title={t('agents.model.refresh')} aria-label={t('agents.model.refresh')}>
<RefreshCw size={14} className={loading ? 'spin' : ''} aria-hidden="true" />
</button> : null}
</div> : null}
@@ -889,6 +891,30 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const modelRequestRef = useRef(0);
const piUpdateRequestRef = useRef(0);
const claudeModelMappingsDirtyRef = useRef(claudeModelMappingsDirtyCache);
const launchDirectoryDialogRef = useDialogFocusTrap<HTMLElement>({
active: launchDirectoryDialogOpen,
onEscape: busy
? undefined
: () => {
setLaunchDirectoryDialogOpen(false);
setLaunchDirectoryTarget(null);
},
preventEscape: busy,
});
const defaultDialogRef = useDialogFocusTrap<HTMLElement>({
active: defaultConfirmOpen,
onEscape: busy ? undefined : () => setDefaultConfirmOpen(false),
preventEscape: busy,
});
const clearDialogRef = useDialogFocusTrap<HTMLElement>({
active: clearConfirmOpen,
onEscape: busy ? undefined : () => setClearConfirmOpen(false),
preventEscape: busy,
});
const oauthRequiredDialogRef = useDialogFocusTrap<HTMLElement>({
active: Boolean(oauthLoginRequiredAction),
onEscape: () => setOauthLoginRequiredAction(null),
});
const setClaudeModelMappingsDraftByClient = useCallback((
update: (
@@ -2498,6 +2524,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
}
}}>
<section
ref={launchDirectoryDialogRef}
className="config-dialog agent-launch-directory-dialog"
role="dialog"
aria-modal="true"
@@ -2724,7 +2751,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{defaultConfirmOpen ? (
<div className="config-dialog-backdrop">
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-default-title">
<section ref={defaultDialogRef} className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-default-title">
<div className="config-dialog-heading">
<div><AlertTriangle size={19} /><h2 id="agent-default-title">{t('agents.default.title')}</h2></div>
</div>
@@ -2748,7 +2775,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{clearConfirmOpen ? (
<div className="config-dialog-backdrop">
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-clear-title">
<section ref={clearDialogRef} className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-clear-title">
<div className="config-dialog-heading">
<div><AlertTriangle size={19} /><h2 id="agent-clear-title">{selected === 'codex' ? t('agents.clear.title') : t('agents.clearIntegration.title', { name: activeDefinition.name })}</h2></div>
</div>
@@ -2769,7 +2796,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{oauthLoginRequiredAction ? (
<div className="config-dialog-backdrop">
<section className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-oauth-login-required-title">
<section ref={oauthRequiredDialogRef} className="config-dialog agent-restore-dialog" role="alertdialog" aria-modal="true" aria-labelledby="agent-oauth-login-required-title">
<div className="config-dialog-heading">
<div><AlertTriangle size={19} /><h2 id="agent-oauth-login-required-title">{t('agents.oauthLoginRequired.title')}</h2></div>
</div>
+22 -43
View File
@@ -1,5 +1,6 @@
import { useConfirmation } from '../components/ConfirmationDialog';
import { ModelSelectionPanel } from '../components/ModelSelectionPanel';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import {
type CSSProperties,
FormEvent,
@@ -1513,11 +1514,11 @@ export function ApiAccessPage() {
<span className="switch-track" />
</span>
</label>
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title={t('common.edit')}>
<Edit3 size={16} />
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title={t('common.edit')} aria-label={t('common.edit')}>
<Edit3 size={16} aria-hidden="true" />
</button>
<button type="button" className="icon-button danger" onClick={() => void deleteRow(row)} disabled={busy} title={t('common.delete')}>
<Trash2 size={16} />
<button type="button" className="icon-button danger" onClick={() => void deleteRow(row)} disabled={busy} title={t('common.delete')} aria-label={t('common.delete')}>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
</SortableProviderRow>
@@ -1571,6 +1572,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
const [results, setResults] = useState<Record<string, ProviderModelHealthState>>({});
const [checkingAll, setCheckingAll] = useState(false);
const healthControllerRef = useRef<AbortController | null>(null);
const dialogRef = useDialogFocusTrap<HTMLElement>({ onEscape: onClose });
useEffect(() => {
const controller = new AbortController();
@@ -1663,14 +1665,14 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
return (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && onClose()}>
<section className="model-discovery-dialog provider-health-dialog" role="dialog" aria-modal="true" aria-labelledby="provider-health-title">
<section ref={dialogRef} className="model-discovery-dialog provider-health-dialog" role="dialog" aria-modal="true" aria-labelledby="provider-health-title">
<div className="model-discovery-header">
<div>
<h2 id="provider-health-title">{t('apiAccess.health.title')}</h2>
<span>{t('apiAccess.health.description', { provider: row.remark || row.name })}</span>
</div>
<button type="button" className="icon-button quiet" onClick={onClose} title={t('common.close')}>
<X size={18} />
<button type="button" className="icon-button quiet" onClick={onClose} title={t('common.close')} aria-label={t('common.close')}>
<X size={18} aria-hidden="true" />
</button>
</div>
@@ -1802,7 +1804,6 @@ export function ApiProviderDialog({
() => 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);
@@ -1811,39 +1812,17 @@ export function ApiProviderDialog({
setModelLoading(false);
setModelDiscoveryOpen(false);
}, []);
const providerDialogRef = useDialogFocusTrap<HTMLFormElement>({
onEscape: busy ? undefined : onClose,
preventEscape: busy,
});
const modelDialogRef = useDialogFocusTrap<HTMLElement>({
active: modelDiscoveryOpen,
onEscape: closeModelDiscovery,
});
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), [tabindex="0"]') ?? []);
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),
[discoveredModels, draft.models],
@@ -2073,14 +2052,14 @@ export function ApiProviderDialog({
return (
<>
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !busy && onClose()}>
<form className="config-dialog management-dialog api-provider-dialog" onSubmit={(event) => void submit(event)}>
<form ref={providerDialogRef} className="config-dialog management-dialog api-provider-dialog" role="dialog" aria-modal="true" aria-labelledby="api-provider-dialog-title" onSubmit={(event) => void submit(event)}>
<div className="config-dialog-heading">
<div>
<Plus size={19} aria-hidden="true" />
<h2>{editingRow ? t('apiAccess.dialog.edit') : t('apiAccess.dialog.add')}</h2>
<h2 id="api-provider-dialog-title">{editingRow ? t('apiAccess.dialog.edit') : t('apiAccess.dialog.add')}</h2>
</div>
<button type="button" className="icon-button quiet" onClick={onClose} disabled={busy} title={t('common.close')}>
<X size={18} />
<button type="button" className="icon-button quiet" onClick={onClose} disabled={busy} title={t('common.close')} aria-label={t('common.close')}>
<X size={18} aria-hidden="true" />
</button>
</div>
<label><span>{t('apiAccess.field.remark')}</span><input autoFocus={definition.openAi} value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
@@ -2249,7 +2228,7 @@ export function ApiProviderDialog({
<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={closeModelDiscovery} title={t('common.close')}><X size={18} /></button>
<button type="button" className="icon-button quiet" onClick={closeModelDiscovery} title={t('common.close')} aria-label={t('common.close')}><X size={18} aria-hidden="true" /></button>
</div>
<div className="model-transfer-summary">
+10 -4
View File
@@ -9,6 +9,7 @@ import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
import { AuthFileRequestStatus } from '../components/AuthFileRequestStatus';
import { AuthFileHealthStatus } from '../components/AuthFileHealthStatus';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import {
Check,
Copy,
@@ -189,6 +190,11 @@ export function AuthFileManagementPage() {
setOauthModelTarget(null);
setOauthModelSettings(null);
};
const oauthModelsDialogRef = useDialogFocusTrap<HTMLElement>({
active: Boolean(oauthModelTarget),
onEscape: oauthModelSaving ? undefined : closeOauthModels,
preventEscape: oauthModelSaving,
});
const openOauthModelSettings = async (target: OAuthModelTarget) => {
if (oauthModelSaveRef.current) return;
@@ -445,9 +451,9 @@ export function AuthFileManagementPage() {
<footer className="auth-card-actions">
<button type="button" className="secondary-button compact-button" onClick={() => setSettingsName(name)} disabled={busy || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? 'authFiles.settings.title' : 'authFiles.fileOnly')}><Settings2 size={14} />{t('authFiles.settings.button')}</button>
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')}>{copied === name ? <Check size={15} /> : <Copy size={15} />}</button>
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')} aria-label={t('authFiles.copyName')}>{copied === name ? <Check size={15} aria-hidden="true" /> : <Copy size={15} aria-hidden="true" />}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy || !isOAuthCredentialFile(file)} title={isOAuthCredentialFile(file) ? undefined : t('authFiles.fileOnly')}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')}><Trash2 size={15} /></button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')} aria-label={t('common.delete')}><Trash2 size={15} aria-hidden="true" /></button>
</footer>
</article>
);
@@ -467,7 +473,7 @@ export function AuthFileManagementPage() {
{oauthModelTarget ? (
<div className="model-discovery-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !oauthModelSaving && closeOauthModels()}>
<section className="model-discovery-dialog auth-model-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title" onKeyDown={(event) => { if (event.key === 'Escape') closeOauthModels(); }}>
<section ref={oauthModelsDialogRef} className="model-discovery-dialog auth-model-dialog" role="dialog" aria-modal="true" aria-labelledby="oauth-model-title">
<div className="model-discovery-header">
<div>
<h2 id="oauth-model-title">{t(oauthModelTarget.scope === 'credential' ? 'authFiles.models.title' : 'authFiles.models.globalButton')}</h2>
@@ -486,7 +492,7 @@ export function AuthFileManagementPage() {
)}
<span>{t(oauthModelTarget.scope === 'credential' ? 'authFiles.models.description' : 'authFiles.models.globalDescription', { provider: oauthModelTarget.label })}</span>
</div>
<button type="button" className="icon-button quiet" onClick={closeOauthModels} disabled={oauthModelSaving} title={t('common.close')}><X size={18} /></button>
<button type="button" className="icon-button quiet" onClick={closeOauthModels} disabled={oauthModelSaving} title={t('common.close')} aria-label={t('common.close')}><X size={18} aria-hidden="true" /></button>
</div>
<div className="model-discovery-search">
+11 -11
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Check, LoaderCircle, RefreshCw, RotateCcw, Search, X } from 'lucide-react';
import { useI18n } from '../i18n';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import {
cloneCodexModelConfiguration,
codexContextSourceHint,
@@ -123,14 +124,13 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
else onClose();
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (discardConfirmOpen) setDiscardConfirmOpen(false);
else requestClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
const dialogRef = useDialogFocusTrap<HTMLElement>({
onEscape: requestClose,
preventEscape: saving,
});
const discardDialogRef = useDialogFocusTrap<HTMLDivElement>({
active: discardConfirmOpen,
onEscape: () => setDiscardConfirmOpen(false),
});
const restoreModel = () => {
@@ -191,14 +191,14 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
<div className="config-dialog-backdrop codex-catalog-backdrop" onMouseDown={(event) => {
if (event.currentTarget === event.target) requestClose();
}}>
<section className="config-dialog codex-catalog-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-catalog-title">
<section ref={dialogRef} className="config-dialog codex-catalog-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-catalog-title">
<header className="config-dialog-heading codex-catalog-heading">
<div>
<h2 id="codex-catalog-title">{t('agents.catalog.title')}</h2>
<p>{t('agents.catalog.subtitle')}</p>
</div>
<button type="button" className="icon-button quiet" onClick={requestClose} disabled={saving} aria-label={t('common.close')}>
<X size={18} />
<X size={18} aria-hidden="true" />
</button>
</header>
@@ -310,7 +310,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
{discardConfirmOpen ? (
<div className="codex-catalog-confirm">
<div role="alertdialog" aria-modal="true">
<div ref={discardDialogRef} role="alertdialog" aria-modal="true">
<strong>{t('agents.catalog.unsaved')}</strong>
<span>{t('agents.catalog.discardHint')}</span>
<div>
+13 -2
View File
@@ -16,6 +16,7 @@ import {
X,
} from 'lucide-react';
import { useI18n } from '../i18n';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import {
codexSessionPageCounts,
retainVisibleCodexSessionIds,
@@ -71,6 +72,16 @@ export function CodexSessionsPanel() {
const mountedRef = useRef(false);
const loadRequestRef = useRef(0);
const initialOffsetRef = useRef(sessionViewCache.offset);
const deleteDialogRef = useDialogFocusTrap<HTMLElement>({
active: Boolean(deleteConfirmation),
onEscape: operation === 'delete' ? undefined : () => setDeleteConfirmation(null),
preventEscape: operation === 'delete',
});
const cleanupDialogRef = useDialogFocusTrap<HTMLElement>({
active: Boolean(cleanupPreview),
onEscape: operation === 'cleanup' ? undefined : () => setCleanupPreview(null),
preventEscape: operation === 'cleanup',
});
useEffect(() => {
mountedRef.current = true;
@@ -482,7 +493,7 @@ export function CodexSessionsPanel() {
{deleteConfirmation ? (
<div className="config-dialog-backdrop">
<section className="config-dialog codex-session-dialog" role="alertdialog" aria-modal="true" aria-labelledby="codex-session-delete-title">
<section ref={deleteDialogRef} className="config-dialog codex-session-dialog" role="alertdialog" aria-modal="true" aria-labelledby="codex-session-delete-title">
<div className="config-dialog-heading"><div><TriangleAlert size={19} /><h2 id="codex-session-delete-title">{deleteConfirmation.title}</h2></div></div>
<p>{deleteConfirmation.description}</p>
<div className="config-dialog-actions two-actions">
@@ -495,7 +506,7 @@ export function CodexSessionsPanel() {
{cleanupPreview ? (
<div className="config-dialog-backdrop">
<section className="config-dialog codex-session-cleanup-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-session-cleanup-title">
<section ref={cleanupDialogRef} className="config-dialog codex-session-cleanup-dialog" role="dialog" aria-modal="true" aria-labelledby="codex-session-cleanup-title">
<div className="config-dialog-heading">
<div><TriangleAlert size={19} /><h2 id="codex-session-cleanup-title">{t('agents.sessions.cleanupTitle')}</h2></div>
<button type="button" className="codex-session-dialog-close" aria-label={t('common.close')} onClick={() => setCleanupPreview(null)}><X size={16} /></button>
+57 -22
View File
@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useRef, useState } from 'react';
import { FormEvent, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open } from '@tauri-apps/plugin-dialog';
@@ -36,6 +36,8 @@ import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { webUiManagementUrl } from '../services/clientAccess';
import { ThinkingAliasesPage } from './ThinkingAliasesPage';
import { SensitiveWordsPage } from './SensitiveWordsPage';
import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
type CoreConfigSettings = {
apiKeys: CoreApiKey[];
@@ -80,6 +82,7 @@ type ConfigAction =
| 'software'
| null;
type ConfigSubpage = 'general' | 'network' | 'routing' | 'software' | 'aliases' | 'sensitive-words';
const CONFIG_SUBPAGES: readonly ConfigSubpage[] = ['general', 'network', 'routing', 'aliases', 'software', 'sensitive-words'];
type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray';
type NetworkDraftField =
| 'port'
@@ -955,9 +958,35 @@ export function ConfigPanelPage() {
const keyMutationBusy = busyAction === 'add-key' || busyAction === 'update-key';
const managementSecretBusy = busyAction === 'management-secret';
const loggingSettingsBusy = busyAction === 'logging';
const addDialogRef = useDialogFocusTrap<HTMLFormElement>({
active: addDialogOpen,
onEscape: keyMutationBusy ? undefined : closeAddDialog,
preventEscape: keyMutationBusy,
});
const deleteDialogRef = useDialogFocusTrap<HTMLDivElement>({
active: deleteIndex !== null,
onEscape: busyAction === 'delete-key' ? undefined : () => setDeleteIndex(null),
preventEscape: busyAction === 'delete-key',
});
const activateConfigSubpage = (subpage: ConfigSubpage) => {
if (subpage === 'sensitive-words') setSensitiveWordsVisited(true);
setActiveSubpage(subpage);
};
const handleConfigTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, subpage: ConfigSubpage) => {
handleHorizontalTabKey(
event,
CONFIG_SUBPAGES,
subpage,
activateConfigSubpage,
(next) => document.getElementById(`config-subpage-tab-${next}`),
);
};
return (
<section className="page config-page">
<header className="management-header config-page-header">
<div><h1>{t('app.nav.config')}</h1></div>
</header>
<div className="agent-subpage-tabs config-subpage-tabs" role="tablist" aria-label={t('config.tabs.label')}>
<button
type="button"
@@ -965,9 +994,10 @@ export function ConfigPanelPage() {
role="tab"
className={activeSubpage === 'general' ? 'active' : ''}
aria-selected={activeSubpage === 'general'}
aria-controls="config-subpage-panel-general"
aria-controls="config-subpage-panel"
tabIndex={activeSubpage === 'general' ? 0 : -1}
onClick={() => setActiveSubpage('general')}
onClick={() => activateConfigSubpage('general')}
onKeyDown={(event) => handleConfigTabKeyDown(event, 'general')}
>
{t('config.tabs.general')}
</button>
@@ -977,9 +1007,10 @@ export function ConfigPanelPage() {
role="tab"
className={activeSubpage === 'network' ? 'active' : ''}
aria-selected={activeSubpage === 'network'}
aria-controls="config-subpage-panel-network"
aria-controls="config-subpage-panel"
tabIndex={activeSubpage === 'network' ? 0 : -1}
onClick={() => setActiveSubpage('network')}
onClick={() => activateConfigSubpage('network')}
onKeyDown={(event) => handleConfigTabKeyDown(event, 'network')}
>
{t('config.tabs.network')}
</button>
@@ -989,9 +1020,10 @@ export function ConfigPanelPage() {
role="tab"
className={activeSubpage === 'routing' ? 'active' : ''}
aria-selected={activeSubpage === 'routing'}
aria-controls="config-subpage-panel-routing"
aria-controls="config-subpage-panel"
tabIndex={activeSubpage === 'routing' ? 0 : -1}
onClick={() => setActiveSubpage('routing')}
onClick={() => activateConfigSubpage('routing')}
onKeyDown={(event) => handleConfigTabKeyDown(event, 'routing')}
>
{t('config.tabs.routing')}
</button>
@@ -1001,9 +1033,10 @@ export function ConfigPanelPage() {
role="tab"
className={activeSubpage === 'aliases' ? 'active' : ''}
aria-selected={activeSubpage === 'aliases'}
aria-controls="config-subpage-panel-aliases"
aria-controls="config-subpage-panel"
tabIndex={activeSubpage === 'aliases' ? 0 : -1}
onClick={() => setActiveSubpage('aliases')}
onClick={() => activateConfigSubpage('aliases')}
onKeyDown={(event) => handleConfigTabKeyDown(event, 'aliases')}
>
{t('app.nav.thinkingAliases')}
</button>
@@ -1013,9 +1046,10 @@ export function ConfigPanelPage() {
role="tab"
className={activeSubpage === 'software' ? 'active' : ''}
aria-selected={activeSubpage === 'software'}
aria-controls="config-subpage-panel-software"
aria-controls="config-subpage-panel"
tabIndex={activeSubpage === 'software' ? 0 : -1}
onClick={() => setActiveSubpage('software')}
onClick={() => activateConfigSubpage('software')}
onKeyDown={(event) => handleConfigTabKeyDown(event, 'software')}
>
{t('config.tabs.software')}
</button>
@@ -1025,12 +1059,10 @@ export function ConfigPanelPage() {
role="tab"
className={activeSubpage === 'sensitive-words' ? 'active' : ''}
aria-selected={activeSubpage === 'sensitive-words'}
aria-controls="config-subpage-panel-sensitive-words"
aria-controls="config-subpage-panel"
tabIndex={activeSubpage === 'sensitive-words' ? 0 : -1}
onClick={() => {
setSensitiveWordsVisited(true);
setActiveSubpage('sensitive-words');
}}
onClick={() => activateConfigSubpage('sensitive-words')}
onKeyDown={(event) => handleConfigTabKeyDown(event, 'sensitive-words')}
>
{t('config.tabs.sensitiveWords')}
</button>
@@ -1039,7 +1071,7 @@ export function ConfigPanelPage() {
{activeSubpage === 'general' ? (
<div
className="config-subpage-panel config-general-subpage"
id="config-subpage-panel-general"
id="config-subpage-panel"
role="tabpanel"
aria-labelledby="config-subpage-tab-general"
>
@@ -1447,7 +1479,7 @@ export function ConfigPanelPage() {
) : activeSubpage === 'network' || activeSubpage === 'routing' ? (
<div
className="config-subpage-panel config-network-subpage"
id={`config-subpage-panel-${activeSubpage}`}
id="config-subpage-panel"
role="tabpanel"
aria-labelledby={`config-subpage-tab-${activeSubpage}`}
>
@@ -1962,7 +1994,7 @@ export function ConfigPanelPage() {
) : activeSubpage === 'software' ? (
<div
className="config-subpage-panel"
id="config-subpage-panel-software"
id="config-subpage-panel"
role="tabpanel"
aria-labelledby="config-subpage-tab-software"
>
@@ -2130,20 +2162,21 @@ export function ConfigPanelPage() {
) : activeSubpage === 'aliases' ? (
<div
className="config-subpage-panel"
id="config-subpage-panel-aliases"
id="config-subpage-panel"
role="tabpanel"
aria-labelledby="config-subpage-tab-aliases"
>
<ThinkingAliasesPage />
<ThinkingAliasesPage embedded />
</div>
) : null}
{sensitiveWordsVisited ? (
<div
className="config-subpage-panel"
id="config-subpage-panel-sensitive-words"
id={activeSubpage === 'sensitive-words' ? 'config-subpage-panel' : undefined}
role="tabpanel"
aria-labelledby="config-subpage-tab-sensitive-words"
aria-hidden={activeSubpage === 'sensitive-words' ? undefined : true}
style={{ display: activeSubpage === 'sensitive-words' ? undefined : 'none' }}
>
<SensitiveWordsPage />
@@ -2155,6 +2188,7 @@ export function ConfigPanelPage() {
if (event.currentTarget === event.target) closeAddDialog();
}}>
<form
ref={addDialogRef}
className="config-dialog"
role="dialog"
aria-modal="true"
@@ -2260,6 +2294,7 @@ export function ConfigPanelPage() {
}
}}>
<div
ref={deleteDialogRef}
className={`config-dialog config-delete-dialog ${deletingLastKey ? 'has-warning' : ''}`}
role="alertdialog"
aria-modal="true"
+10 -21
View File
@@ -1,8 +1,9 @@
import { MessageNotice } from '../appNotice';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { LoaderCircle, RefreshCw, RotateCcw, Search, Settings2, X } from 'lucide-react';
import { useI18n } from '../i18n';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import type { MessageKey } from '../i18n/resources';
import {
harnessContextDefault, harnessDraft, harnessReasoningLevels, harnessSchema, isHarnessRecord, parseHarnessDraft, sameHarnessDraft, updateHarnessDraft,
@@ -99,7 +100,6 @@ export function DeepSeekHarnessCatalogDialog({ onClose, onSaved }: { onClose: ()
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [confirmation, setConfirmation] = useState<'close' | 'reload' | null>(null);
const dialogRef = useRef<HTMLElement>(null);
const install = (next: HarnessEditorSnapshot) => {
setSnapshot(next);
setDrafts(Object.fromEntries(next.models.map(m => [m.id, harnessDraft(m.configuration, 'model')])));
@@ -118,24 +118,13 @@ export function DeepSeekHarnessCatalogDialog({ onClose, onSaved }: { onClose: ()
const dirty = !!snapshot && (!sameHarnessDraft(provider, harnessDraft(snapshot.provider, 'provider'))
|| snapshot.models.some(m => !sameHarnessDraft(drafts[m.id] ?? {}, harnessDraft(m.configuration, 'model'))));
const close = () => { if (!saving) { if (dirty) setConfirmation('close'); else onClose(); } };
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.querySelector<HTMLElement>('button')?.focus();
return () => previous?.focus();
}, []);
useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') { event.stopPropagation(); if (confirmation) setConfirmation(null); else close(); }
if (event.key === 'Tab') {
const scope = confirmation ? dialogRef.current?.querySelector('[role="alertdialog"]') : dialogRef.current;
const targets = [...(scope?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]') ?? [])].filter(el => el.getClientRects().length);
const first = targets[0], last = targets[targets.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
const dialogRef = useDialogFocusTrap<HTMLElement>({
onEscape: saving ? undefined : close,
preventEscape: saving,
});
const confirmationDialogRef = useDialogFocusTrap<HTMLDivElement>({
active: Boolean(confirmation),
onEscape: () => setConfirmation(null),
});
const active = snapshot?.models.find(m => m.id === selected);
const api = provider.api || 'openai-completions';
@@ -187,7 +176,7 @@ export function DeepSeekHarnessCatalogDialog({ onClose, onSaved }: { onClose: ()
<button className="secondary-button" disabled={loading || saving || !snapshot} onClick={() => { setDrafts(Object.fromEntries(snapshot!.models.map(m => [m.id, {}]))); setProvider({}); changed(); }}>{t('agents.catalog.resetAll')}</button>
<button className="secondary-button" disabled={saving} onClick={close}>{t('common.cancel')}</button><button className="primary-button" disabled={!dirty || loading || saving} onClick={() => void save()}>{saving ? <LoaderCircle size={16} className="spin" /> : null}{t(saving ? 'common.saving' : 'common.save')}</button>
</div></footer>
{confirmation ? <div className="codex-catalog-confirm"><div role="alertdialog" aria-modal="true" aria-label={t('agents.catalog.unsaved')}><strong>{t('agents.catalog.unsaved')}</strong><span>{t('agents.catalog.discardHint')}</span><div><button autoFocus className="secondary-button" onClick={() => setConfirmation(null)}>{t('agents.catalog.keepEditing')}</button><button className="danger-button" onClick={() => { if (confirmation === 'close') onClose(); else { setConfirmation(null); void load(); } }}>{t('agents.catalog.discard')}</button></div></div></div> : null}
{confirmation ? <div className="codex-catalog-confirm"><div ref={confirmationDialogRef} role="alertdialog" aria-modal="true" aria-label={t('agents.catalog.unsaved')}><strong>{t('agents.catalog.unsaved')}</strong><span>{t('agents.catalog.discardHint')}</span><div><button autoFocus className="secondary-button" onClick={() => setConfirmation(null)}>{t('agents.catalog.keepEditing')}</button><button className="danger-button" onClick={() => { if (confirmation === 'close') onClose(); else { setConfirmation(null); void load(); } }}>{t('agents.catalog.discard')}</button></div></div></div> : null}
</section>
</div>;
}
+69 -18
View File
@@ -4,6 +4,7 @@ import {
useLayoutEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { invoke } from "@tauri-apps/api/core";
import {
@@ -166,6 +167,26 @@ export function EasyModePage({
const guideTooltipRef = useRef<HTMLElement | null>(null);
const [guideCardPosition, setGuideCardPosition] = useState<{ top: number; left: number } | null>(null);
const [langMenuOpen, setLangMenuOpen] = useState(false);
const langMenuRef = useRef<HTMLDivElement>(null);
const langButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!langMenuOpen) return undefined;
const closeFromOutside = (event: PointerEvent) => {
if (!langMenuRef.current?.contains(event.target as Node)) setLangMenuOpen(false);
};
const closeFromKeyboard = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Escape') return;
setLangMenuOpen(false);
langButtonRef.current?.focus();
};
document.addEventListener('pointerdown', closeFromOutside);
document.addEventListener('keydown', closeFromKeyboard);
return () => {
document.removeEventListener('pointerdown', closeFromOutside);
document.removeEventListener('keydown', closeFromKeyboard);
};
}, [langMenuOpen]);
const refreshSourceStatus = useCallback(async () => {
try {
@@ -618,9 +639,25 @@ export function EasyModePage({
};
const currentActiveLang = languageOptions.find((opt) => opt.value === (locale || currentLocale)) || languageOptions[0];
const handleLanguageListKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
const options = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'));
if (!options.length) return;
const current = options.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? options.length - 1
: event.key === 'ArrowDown'
? (Math.max(current, -1) + 1) % options.length
: (current <= 0 ? options.length : current) - 1;
event.preventDefault();
options[next]?.focus();
};
return (
<section className="page simple-mode-page simple-mode-expanded">
<h1 className="sr-only">{t('app.nav.easy')}</h1>
{guideActive ? <div className="guide-dimmed-overlay" /> : null}
<header className="simple-mode-topbar">
@@ -683,28 +720,45 @@ export function EasyModePage({
</div>
) : null}
<div className="simple-mode-lang-dropdown">
<div ref={langMenuRef} className="simple-mode-lang-dropdown">
<button
ref={langButtonRef}
type="button"
className="simple-mode-lang-btn"
onClick={() => setLangMenuOpen(!langMenuOpen)}
title={t("easyMode.language.switch")}
aria-label={t("easyMode.language.switch")}
aria-haspopup="listbox"
aria-expanded={langMenuOpen}
aria-controls="simple-mode-language-list"
onKeyDown={(event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
setLangMenuOpen(true);
window.requestAnimationFrame(() => {
const options = langMenuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
options?.[event.key === 'ArrowUp' ? options.length - 1 : 0]?.focus();
});
}}
>
<Languages size={15} />
<span>{currentActiveLang.nativeLabel}</span>
<ChevronDown size={13} />
</button>
{langMenuOpen ? (
<div className="simple-mode-lang-menu">
<div id="simple-mode-language-list" className="simple-mode-lang-menu" role="listbox" aria-label={t("easyMode.language.switch")} onKeyDown={handleLanguageListKeyDown}>
{languageOptions.map((opt) => (
<button
key={opt.value}
type="button"
className={opt.value === (locale || currentLocale) ? "selected" : ""}
role="option"
aria-selected={opt.value === (locale || currentLocale)}
onClick={() => {
if (setLocale) setLocale(opt.value);
else setI18nLocale(opt.value);
setLangMenuOpen(false);
window.requestAnimationFrame(() => langButtonRef.current?.focus());
}}
>
<span>{opt.nativeLabel}</span>
@@ -717,7 +771,7 @@ export function EasyModePage({
<button
type="button"
className="secondary-button simple-mode-exit-btn simple-mode-highlight-button"
className="secondary-button simple-mode-exit-btn"
title={t("easyMode.exitTitle")}
onClick={() => onExit?.()}
>
@@ -732,14 +786,13 @@ export function EasyModePage({
<strong>{setupStepStatus}</strong>
</div>
<div className="simple-mode-step-status-track">
<div
<button
type="button"
className={`simple-mode-step-status-item${activeStep === 1 ? " active" : " complete"}`}
aria-current={activeStep === 1 ? "step" : undefined}
onClick={() => {
if (guideActive) return;
setActiveStep(1);
}}
style={{ cursor: guideActive ? "default" : "pointer" }}
aria-pressed={activeStep === 1}
disabled={guideActive}
onClick={() => setActiveStep(1)}
>
<span className="simple-mode-step-status-number">
{activeStep > 1 ? <Check size={14} aria-hidden="true" /> : "1"}
@@ -748,24 +801,22 @@ export function EasyModePage({
<strong>{t("easyMode.overview.providerTitle")}</strong>
<small>{t("easyMode.steps.step1Description")}</small>
</span>
</div>
</button>
<span className={`simple-mode-step-status-connector${activeStep > 1 ? " complete" : ""}`} aria-hidden="true" />
<div
<button
type="button"
className={`simple-mode-step-status-item${activeStep === 2 ? " active" : ""}`}
aria-current={activeStep === 2 ? "step" : undefined}
onClick={() => {
if (!guideActive && hasConnectedSource) {
setActiveStep(2);
}
}}
style={{ cursor: !guideActive && hasConnectedSource ? "pointer" : "default" }}
aria-pressed={activeStep === 2}
disabled={guideActive || !hasConnectedSource}
onClick={() => setActiveStep(2)}
>
<span className="simple-mode-step-status-number">2</span>
<span className="simple-mode-step-status-copy">
<strong>{t("easyMode.overview.agentTitle")}</strong>
<small>{t("easyMode.steps.step2Description")}</small>
</span>
</div>
</button>
</div>
</nav>
+4 -9
View File
@@ -215,6 +215,9 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
return (
<section className="page kernel-page home-page">
<header className="management-header home-page-header">
<div><h1>{t('app.nav.home')}</h1></div>
</header>
<div className="kernel-layout home-layout">
<div className="panel control-panel">
<div className="panel-heading">
@@ -349,15 +352,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
<div className="client-api-values">
<div className="client-api-value-row">
<span>{t('kernel.access.apiUrl')}</span>
<code
title={profile.baseUrl}
onClick={() =>
void copyApiValue(
profile.baseUrl,
`${profile.id}:base`,
)
}
>
<code title={profile.baseUrl}>
{profile.baseUrl}
</code>
<button
+15 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
Check,
@@ -30,6 +30,7 @@ import {
import { AuthFileManagementPage } from './AuthFileManagementPage';
import { QuotaPage } from './QuotaPage';
import { validateDevinCallback } from '../services/devinOAuth';
import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation';
type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai' | 'devin';
type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error';
@@ -115,6 +116,16 @@ const cachedOAuthProviderStates = (): Partial<Record<OAuthProviderId, OAuthProvi
export function OAuthManagementPage() {
const { t } = useI18n();
const [activeSubpage, setActiveSubpage] = useState<OAuthSubpage>('login');
const subpageIds = oauthSubpages.map((subpage) => subpage.id);
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, subpage: OAuthSubpage) => {
handleHorizontalTabKey(
event,
subpageIds,
subpage,
setActiveSubpage,
(next) => document.getElementById(`oauth-subpage-tab-${next}`),
);
};
return (
<section className="page oauth-management-page">
@@ -133,9 +144,10 @@ export function OAuthManagementPage() {
role="tab"
className={active ? 'active' : ''}
aria-selected={active}
aria-controls={`oauth-subpage-panel-${subpage.id}`}
aria-controls="oauth-subpage-panel"
tabIndex={active ? 0 : -1}
onClick={() => setActiveSubpage(subpage.id)}
onKeyDown={(event) => handleTabKeyDown(event, subpage.id)}
>
{t(subpage.labelKey)}
</button>
@@ -145,7 +157,7 @@ export function OAuthManagementPage() {
<div
className="oauth-subpage-panel"
id={`oauth-subpage-panel-${activeSubpage}`}
id="oauth-subpage-panel"
role="tabpanel"
aria-labelledby={`oauth-subpage-tab-${activeSubpage}`}
>
+1 -1
View File
@@ -186,7 +186,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
<div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ' · ' + quota.plan : ''}</span></div>
<div className="quota-card-actions">
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button>
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')} aria-label={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} aria-hidden="true" /></button>
</div>
</div>
<QuotaActionFeedback quota={quota} name={name} />
+13 -34
View File
@@ -24,6 +24,7 @@ import {
Zap,
} from 'lucide-react';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
type PresetThinkingEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
@@ -187,7 +188,7 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
thinkingAliasProviderDetail(source.kind, source.provider)
);
export function ThinkingAliasesPage() {
export function ThinkingAliasesPage({ embedded = false }: { embedded?: boolean } = {}) {
const { askConfirmation, confirmationDialog } = useConfirmation();
const { t } = useI18n();
const [thinkingEntries, setThinkingEntries] = useState<ThinkingAliasEntry[]>([]);
@@ -207,7 +208,6 @@ export function ThinkingAliasesPage() {
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [activeSourceIndex, setActiveSourceIndex] = useState(0);
const modelPickerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<HTMLElement>(null);
const generatedAliasRef = useRef('');
const [loading, setLoading] = useState(true);
const [busyAlias, setBusyAlias] = useState('');
@@ -483,6 +483,12 @@ export function ThinkingAliasesPage() {
resetEditor();
};
const editorRef = useDialogFocusTrap<HTMLElement>({
active: editorOpen,
onEscape: busyAlias ? undefined : closeEditor,
preventEscape: Boolean(busyAlias),
});
const addAlias = () => {
resetEditor();
setError('');
@@ -517,36 +523,6 @@ export function ThinkingAliasesPage() {
}
};
useEffect(() => {
if (!editorOpen) return;
const previousFocus = document.activeElement;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
editorRef.current?.querySelector<HTMLInputElement>('input')?.focus();
const trapFocus = (event: globalThis.KeyboardEvent) => {
if (event.key !== 'Tab') return;
const controls = Array.from(editorRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), [tabindex="0"]',
) ?? []);
const first = controls[0];
const last = controls[controls.length - 1];
const outside = !editorRef.current?.contains(document.activeElement);
if (event.shiftKey && (outside || document.activeElement === first)) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && (outside || document.activeElement === last)) {
event.preventDefault();
first?.focus();
}
};
document.addEventListener('keydown', trapFocus);
return () => {
document.removeEventListener('keydown', trapFocus);
document.body.style.overflow = previousOverflow;
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
};
}, [editorOpen]);
const deleteAlias = async (entry: AliasListEntry) => {
if (!await askConfirmation({ title: t('common.delete'), message: t('aliases.deleteConfirm', { alias: entry.alias }), confirmText: t('common.delete'), variant: 'danger' })) return;
setBusyAlias(entry.alias);
@@ -583,7 +559,9 @@ export function ThinkingAliasesPage() {
<header className="management-header">
<div>
<h1>{t('app.nav.thinkingAliases')}</h1>
{embedded
? <h2>{t('app.nav.thinkingAliases')}</h2>
: <h1>{t('app.nav.thinkingAliases')}</h1>}
</div>
<div className="management-heading-actions">
<span className="muted-summary">{entries.length}</span>
@@ -608,7 +586,6 @@ export function ThinkingAliasesPage() {
role="dialog"
aria-modal="true"
aria-labelledby="thinking-alias-editor-title"
onKeyDown={(event) => { if (event.key === 'Escape') closeEditor(); }}
>
<div className="thinking-alias-panel-heading">
<span><GitFork size={18} /></span>
@@ -631,6 +608,7 @@ export function ThinkingAliasesPage() {
<input
id="thinking-model-search"
role="combobox"
autoFocus
aria-autocomplete="list"
aria-expanded={modelPickerOpen}
aria-controls="thinking-model-options"
@@ -884,6 +862,7 @@ export function ThinkingAliasesPage() {
onClick={() => void deleteAlias(entry)}
disabled={Boolean(busyAlias)}
title={t('aliases.delete', { alias: entry.alias })}
aria-label={t('aliases.delete', { alias: entry.alias })}
>
{busyAction === 'delete' && busyAlias === entry.alias
? <LoaderCircle size={15} className="spin" />
+100 -7
View File
@@ -25,6 +25,8 @@ import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import type { MessageKey } from '../i18n/resources';
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
import { formatUsageNumber } from '../services/usageNumber';
import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import {
OTHER_TREND_MODEL_KEY,
buildUsageTrendSeries,
@@ -46,6 +48,8 @@ import { usageViewScopeKey } from '../services/usageViewScope';
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing' | 'data-management';
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
const USAGE_TABS: readonly UsageTab[] = ['overview', 'analysis', 'events', 'pricing', 'data-management'];
type CollectorStatus = {
state: 'waiting-core' | 'collecting' | 'error';
message: string;
@@ -539,53 +543,96 @@ export function UsageRecordsPage() {
(activeTab === 'events' && !events) ||
(activeTab === 'pricing' && !pricing))));
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, tab: UsageTab) => {
handleHorizontalTabKey(
event,
USAGE_TABS,
tab,
setActiveTab,
(next) => document.getElementById(`usage-tab-${next}`),
);
};
return (
<section className="page management-page usage-records-page">
{error ? <MessageNotice message={error} onDismiss={() => setError('')} /> : null}
<div className="usage-topbar">
<div className="usage-page-navigation">
<h1>{t('usage.title')}</h1>
<div className="usage-tabs" role="tablist" aria-label={t('usage.pageLabel')}>
<button
type="button"
id="usage-tab-overview"
role="tab"
className={activeTab === 'overview' ? 'active' : ''}
aria-selected={activeTab === 'overview'}
aria-controls="usage-tab-panel"
tabIndex={activeTab === 'overview' ? 0 : -1}
onClick={() => setActiveTab('overview')}
onKeyDown={(event) => handleTabKeyDown(event, 'overview')}
>
<BarChart3 size={15} />
<span>{t('usage.tab.overview')}</span>
</button>
<button
type="button"
id="usage-tab-analysis"
role="tab"
className={activeTab === 'analysis' ? 'active' : ''}
aria-selected={activeTab === 'analysis'}
aria-controls="usage-tab-panel"
tabIndex={activeTab === 'analysis' ? 0 : -1}
onClick={() => setActiveTab('analysis')}
onKeyDown={(event) => handleTabKeyDown(event, 'analysis')}
>
<Activity size={15} />
<span>{t('usage.tab.analysis')}</span>
</button>
<button
type="button"
id="usage-tab-events"
role="tab"
className={activeTab === 'events' ? 'active' : ''}
aria-selected={activeTab === 'events'}
aria-controls="usage-tab-panel"
tabIndex={activeTab === 'events' ? 0 : -1}
onClick={() => setActiveTab('events')}
onKeyDown={(event) => handleTabKeyDown(event, 'events')}
>
<List size={15} />
<span>{t('usage.tab.events')}</span>
</button>
<button
type="button"
id="usage-tab-pricing"
role="tab"
className={activeTab === 'pricing' ? 'active' : ''}
aria-selected={activeTab === 'pricing'}
aria-controls="usage-tab-panel"
tabIndex={activeTab === 'pricing' ? 0 : -1}
onClick={() => setActiveTab('pricing')}
onKeyDown={(event) => handleTabKeyDown(event, 'pricing')}
>
<CircleDollarSign size={15} />
<span>{t('usage.tab.pricing')}</span>
</button>
<button
type="button"
id="usage-tab-data-management"
role="tab"
className={activeTab === 'data-management' ? 'active' : ''}
aria-selected={activeTab === 'data-management'}
aria-controls="usage-tab-panel"
tabIndex={activeTab === 'data-management' ? 0 : -1}
onClick={() => setActiveTab('data-management')}
onKeyDown={(event) => handleTabKeyDown(event, 'data-management')}
>
<Wrench size={15} />
<span>{t('usage.tab.dataManagement')}</span>
</button>
</div>
</div>
<div className="usage-topbar-actions">
<div className={`usage-collector-state ${collectorTone}`} title={status?.message}>
@@ -612,6 +659,13 @@ export function UsageRecordsPage() {
</div>
</div>
<div
className="usage-tab-panel"
id="usage-tab-panel"
role="tabpanel"
aria-labelledby={`usage-tab-${activeTab}`}
>
{activeTab !== 'data-management' ? (
<section className="panel usage-filter-panel">
<div className="usage-filter-row">
<div className="usage-filter-group">
@@ -745,6 +799,7 @@ export function UsageRecordsPage() {
</div>
) : null}
</section>
) : null}
{showInitialLoading ? (
<div className="usage-initial-loading">
@@ -770,6 +825,7 @@ export function UsageRecordsPage() {
<PricingView pricing={pricing} query={buildQueries().query} onChanged={() => loadData(true)} />
) : null}
{activeTab === 'data-management' ? <UsageDataManagementView /> : null}
</div>
</section>
);
}
@@ -1935,7 +1991,10 @@ function EventsView({
const [draftVisibleColumnKeys, setDraftVisibleColumnKeys] = useState<EventColumnKey[]>(visibleColumnKeys);
const [resizingCol, setResizingCol] = useState<EventColumnKey | null>(null);
const columnDialogRef = useRef<HTMLElement | null>(null);
const columnDialogRef = useDialogFocusTrap<HTMLElement>({
active: columnSettingsOpen,
onEscape: () => setColumnSettingsOpen(false),
});
const tableWrapRef = useRef<HTMLDivElement | null>(null);
const visibleColumnKeySet = new Set(visibleColumnKeys);
@@ -1943,10 +2002,6 @@ function EventsView({
const isCustomized = EVENT_COLUMNS.some((col) => widths[col.key] !== col.defaultWidth);
const noRemarkLabel = t('usage.key.noRemark');
useEffect(() => {
if (columnSettingsOpen) columnDialogRef.current?.focus();
}, [columnSettingsOpen]);
const resetAllWidths = () => {
const defaults: Record<EventColumnKey, number> = {} as any;
for (const col of EVENT_COLUMNS) {
@@ -2002,6 +2057,33 @@ function EventsView({
});
};
const persistColumnWidth = (key: EventColumnKey, width: number) => {
setWidths((current) => {
const next = { ...current, [key]: width };
try {
localStorage.setItem(EVENT_COL_WIDTHS_STORAGE_KEY, JSON.stringify(next));
} catch {}
return next;
});
};
const handleResizeKeyDown = (key: EventColumnKey, event: KeyboardEvent<HTMLDivElement>) => {
const column = EVENT_COLUMNS.find((item) => item.key === key);
if (!column) return;
const current = widths[key] ?? column.defaultWidth;
const step = event.shiftKey ? 25 : 10;
const next = event.key === 'Home'
? column.defaultWidth
: event.key === 'ArrowLeft'
? Math.max(column.minWidth, current - step)
: event.key === 'ArrowRight'
? Math.min(800, current + step)
: null;
if (next === null) return;
event.preventDefault();
persistColumnWidth(key, next);
};
const handleResizeStart = (key: EventColumnKey, e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
@@ -2159,8 +2241,16 @@ function EventsView({
</div>
<div
className={`usage-col-resizer ${resizingCol === col.key ? 'active' : ''}`}
role="separator"
tabIndex={0}
aria-label={`${label}: ${t('usage.events.resizeHint')}`}
aria-orientation="vertical"
aria-valuemin={col.minWidth}
aria-valuemax={800}
aria-valuenow={widths[col.key]}
onPointerDown={(e) => handleResizeStart(col.key, e)}
onDoubleClick={(e) => resetSingleColumn(col.key, e)}
onKeyDown={(event) => handleResizeKeyDown(col.key, event)}
title={t('usage.events.resizeHint')}
/>
</th>
@@ -2216,6 +2306,7 @@ function EventsView({
className="icon-button quiet"
onClick={() => setColumnSettingsOpen(false)}
title={t('common.close')}
aria-label={t('common.close')}
>
<X size={17} />
</button>
@@ -2638,18 +2729,20 @@ function PricingView({
type="button"
className="icon-button"
title={t('common.edit')}
aria-label={`${t('common.edit')}: ${row.model}`}
onClick={() => setDraft(priceDraftFor(row.model, row.price))}
>
<Pencil size={14} />
<Pencil size={14} aria-hidden="true" />
</button>
{row.price?.source === 'manual' ? (
<button
type="button"
className="icon-button danger"
title={t('common.delete')}
aria-label={`${t('common.delete')}: ${row.model}`}
onClick={() => void deletePrice(row.model)}
>
<Trash2 size={14} />
<Trash2 size={14} aria-hidden="true" />
</button>
) : null}
</div>
+27 -40
View File
@@ -17,6 +17,7 @@ import { useAppUpdate } from '../appUpdate';
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { createVersionManagementVisitTracker } from '../services/versionManagementVisits';
import { AppReleaseNotes } from '../components/AppReleaseNotes';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
export type CoreInstallResult = {
version: string;
@@ -113,7 +114,6 @@ export function VersionManagementPage() {
const feedback = useAppNotice();
const { showNotice } = feedback;
const installDialogRef = useRef<HTMLDivElement>(null);
const customMirrorInputRef = useRef<HTMLInputElement>(null);
const completedInstallKeyRef = useRef('');
const manualInstallInProgressRef = useRef(false);
@@ -395,45 +395,6 @@ export function VersionManagementPage() {
};
}, []);
useEffect(() => {
if (!installDialogOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
installDialogRef.current?.focus();
const preventEscapeClose = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
}
};
document.addEventListener('keydown', preventEscapeClose);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', preventEscapeClose);
};
}, [installDialogOpen]);
useEffect(() => {
if (!customMirrorDialogOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.setTimeout(() => customMirrorInputRef.current?.focus(), 0);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !versionSourceSaving) {
setCustomMirrorDialogOpen(false);
}
};
document.addEventListener('keydown', closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', closeOnEscape);
};
}, [customMirrorDialogOpen, versionSourceSaving]);
const latestVersion = latest?.version ?? '';
const currentVersion = coreStatus?.currentVersion ?? '';
const coreInstalled = Boolean(coreStatus?.installed);
@@ -523,9 +484,33 @@ export function VersionManagementPage() {
: t('common.close');
const installDialogActionDisabled = (installing || progress?.running) && (cancellingInstall || !progress?.cancellable);
const customMirrorDialogRef = useDialogFocusTrap<HTMLFormElement>({
active: customMirrorDialogOpen,
initialFocusRef: customMirrorInputRef,
onEscape: versionSourceSaving
? undefined
: () => {
setCustomMirrorDialogOpen(false);
setCustomMirrorDraft('');
setVersionSourceError('');
},
preventEscape: versionSourceSaving,
});
const confirmUpdateDialogRef = useDialogFocusTrap<HTMLElement>({
active: confirmUpdateOpen,
onEscape: () => setConfirmUpdateOpen(false),
});
const installDialogRef = useDialogFocusTrap<HTMLDivElement>({
active: installDialogOpen && Boolean(progress),
onEscape: installing || progress?.running ? undefined : closeInstallDialog,
preventEscape: Boolean(installing || progress?.running),
});
return (
<section className="page management-page version-management-page">
<header className="management-header version-page-header">
<div><h1>{t('app.nav.versions')}</h1></div>
</header>
<MessageNotice message={versionSourceError} onDismiss={() => setVersionSourceError('')} />
<section className="panel version-list">
<div className="version-source-row" aria-label={t('kernel.versions.downloadSource')}>
@@ -724,6 +709,7 @@ export function VersionManagementPage() {
{customMirrorDialogOpen ? (
<div className="install-dialog-backdrop custom-mirror-dialog-backdrop">
<form
ref={customMirrorDialogRef}
className="install-dialog app-update-dialog custom-mirror-dialog"
role="dialog"
aria-modal="true"
@@ -797,6 +783,7 @@ export function VersionManagementPage() {
{confirmUpdateOpen ? (
<div className="install-dialog-backdrop app-update-dialog-backdrop">
<section
ref={confirmUpdateDialogRef}
className="install-dialog app-update-dialog"
role="dialog"
aria-modal="true"
-3560
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
@import '../styles.css';
@import './feature-pages.css';
@import './ui-refinements.css';
+238
View File
@@ -0,0 +1,238 @@
.home-page-header,
.config-page-header,
.version-page-header {
min-height: 40px;
}
.home-page-header {
grid-column: 1 / -1;
}
@container workspace (min-width: 840px) {
.home-page {
grid-template-rows: auto minmax(0, 1fr);
grid-auto-rows: unset;
}
.home-page > .home-layout,
.home-page > .client-api-panel {
grid-row: 2;
}
}
.usage-page-navigation {
display: flex;
min-width: 0;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.usage-page-navigation h1 {
margin: 0;
color: var(--text-primary);
font-size: var(--font-size-page-title);
line-height: var(--line-height-tight);
white-space: nowrap;
}
.usage-tab-panel {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
}
.usage-tab-panel > .usage-overview-layout,
.usage-tab-panel > .usage-analysis-grid,
.usage-tab-panel > .usage-events-panel,
.usage-tab-panel > .usage-pricing-panel,
.usage-tab-panel > .usage-data-management-panel,
.usage-tab-panel > .usage-initial-loading {
min-height: 0;
flex: 1 1 auto;
}
.usage-col-resizer:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: -2px;
}
.client-api-card-heading {
width: 144px;
flex-basis: 144px;
}
.client-api-card-heading .client-api-format-tag,
.client-api-card-heading span {
font-size: var(--font-size-caption);
}
.client-api-value-row code {
cursor: text;
}
.simple-mode-step-status-item {
width: 100%;
background: transparent;
font: inherit;
text-align: left;
}
.simple-mode-step-status-item:not(:disabled):hover {
border-color: color-mix(in srgb, var(--ui-accent) 32%, var(--theme-e3e1db));
background: var(--theme-f8f5ee);
}
.simple-mode-step-status-item:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.simple-mode-step-status-item.active:disabled {
opacity: 1;
}
@media (max-width: 900px) {
.app-shell:not(.app-shell-easy-mode) {
grid-template-columns: minmax(0, 1fr);
}
.app-shell:not(.app-shell-easy-mode) .sidebar {
position: static;
display: grid;
grid-template-columns: minmax(190px, 1fr) auto;
grid-template-rows: auto auto;
align-items: center;
gap: 10px 14px;
width: 100%;
height: auto;
padding: 12px 14px;
}
.sidebar-brand {
grid-column: 1;
grid-row: 1;
min-height: 44px;
margin: 0;
}
.nav-section {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
min-width: 0;
overflow-x: auto;
padding: 0 0 3px;
scrollbar-width: thin;
scroll-snap-type: x proximity;
}
.nav-section button {
width: auto;
min-width: 120px;
grid-template-columns: 17px minmax(0, 1fr) auto;
scroll-snap-align: start;
text-align: left;
}
.sidebar-bottom {
grid-column: 2;
grid-row: 1;
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin: 0;
padding: 0;
border: 0;
}
.sidebar-easy-entry,
.sidebar-language,
.sidebar-contact {
width: auto;
flex: none;
}
.sidebar-easy-entry {
min-height: 36px;
padding-inline: 9px;
}
.sidebar-theme-selector {
width: auto;
flex: none;
}
.sidebar-theme-selector button {
min-height: 28px;
padding-inline: 7px;
}
.sidebar-language-trigger {
min-width: 104px;
min-height: 36px;
}
.sidebar-language-list {
right: 0;
bottom: auto;
left: auto;
top: calc(100% + 8px);
width: 180px;
box-shadow: 0 10px 28px rgba(45, 42, 38, 0.16);
}
.sidebar-contact {
display: grid;
width: 36px;
min-height: 36px;
grid-template-columns: 1fr;
padding: 0;
place-items: center;
}
.sidebar-contact span,
.sidebar-contact > svg:last-child {
display: none;
}
.content {
padding: 18px;
}
}
@media (max-width: 700px) {
.app-shell:not(.app-shell-easy-mode) .sidebar {
grid-template-columns: minmax(150px, 1fr) auto;
}
.sidebar-brand div > span {
display: none;
}
.sidebar-brand {
grid-template-columns: 38px minmax(0, 1fr);
}
.brand-mark {
width: 38px;
height: 38px;
}
.usage-page-navigation,
.usage-tabs,
.usage-topbar-actions {
width: 100%;
}
.usage-page-navigation {
align-items: stretch;
flex-direction: column;
gap: 8px;
}
}
+76
View File
@@ -0,0 +1,76 @@
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 root = path.resolve(__dirname, '..');
const server = await createServer({ configFile: false, root, plugins: [react()], logLevel: 'error', server: { host: '127.0.0.1', port: 1421, strictPort: false } });
let browser;
try {
await server.listen();
const address = server.httpServer.address();
const base = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 1421}/`;
const response = await fetch(base);
assert.equal(response.ok, true, `Vite server failed at ${base}`);
await response.text();
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
browser = await chromium.launch(channel ? { channel, headless: true } : { headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(`${base}?mock=running`, { waitUntil: 'commit' });
await page.locator('.app-shell').waitFor();
assert.equal(await page.getByRole('heading', { level: 1, name: '首页' }).count(), 1);
await page.getByRole('button', { name: '使用记录' }).click();
const tabs = page.getByRole('tab');
assert.equal(await tabs.count(), 5);
assert.deepEqual(await tabs.evaluateAll((elements) => elements
.map((element) => element.getAttribute('aria-controls'))
.filter((id) => !id || !document.getElementById(id))), []);
await tabs.first().focus();
await page.keyboard.press('ArrowRight');
assert.equal(await tabs.nth(1).getAttribute('aria-selected'), 'true');
await page.getByRole('tab', { name: /数据管理/ }).click();
assert.equal(await page.locator('.usage-filter-panel').count(), 0);
await page.getByRole('button', { name: '高级功能' }).click();
assert.equal(await page.locator('h1').count(), 1);
const configTabs = page.locator('.config-subpage-tabs').getByRole('tab');
assert.deepEqual(await configTabs.evaluateAll((elements) => elements
.map((element) => element.getAttribute('aria-controls'))
.filter((id) => !id || !document.getElementById(id))), []);
const addKey = page.getByRole('button', { name: /新增鉴权密钥/ }).first();
await addKey.focus();
await addKey.click();
const dialog = page.getByRole('dialog');
await dialog.waitFor();
for (let index = 0; index < 8; index += 1) {
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => Boolean(document.activeElement?.closest('[role="dialog"]'))), true);
}
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'detached' });
await page.waitForFunction(() => document.activeElement?.getAttribute('aria-label') === '新增鉴权密钥');
assert.equal(await addKey.evaluate((element) => document.activeElement === element), true);
await page.setViewportSize({ width: 640, height: 600 });
await page.getByRole('button', { name: '首页' }).click();
const sidebarHeight = await page.locator('.sidebar').evaluate((element) => element.getBoundingClientRect().height);
assert.ok(sidebarHeight < 150, `compact sidebar is too tall: ${sidebarHeight}px`);
await page.locator('.sidebar-easy-entry').click();
const steps = page.locator('.simple-mode-step-status-item');
assert.equal(await steps.count(), 2);
assert.equal(await steps.first().evaluate((element) => element.tagName), 'BUTTON');
console.log('PASS: page hierarchy, tabs, contextual filters, modal focus and compact shell.');
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+8
View File
@@ -63,6 +63,14 @@ describe('browser mock runtime', () => {
});
});
test('uses normalized ratios for percentage-based usage metrics', async () => {
const runtime = createBrowserMockRuntime('running');
expect(await runtime.invoke('get_usage_overview')).toMatchObject({
successRate: 96.47,
cacheHitRate: 0.386,
});
});
test('provides an explicit error scenario', async () => {
const runtime = createBrowserMockRuntime('error');
await expect(runtime.invoke('get_core_status')).rejects.toThrow('Browser Mock error scenario');
+1 -1
View File
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { AgentsPage } from '../../src/pages/AgentsPage';
import { I18nProvider } from '../../src/i18n';
import '../../src/styles.css';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
if(params.has('reset-selections'))localStorage.removeItem('cpa-gui.agent-model-selections.v1');
localStorage.setItem('easy-cli-proxy-api.locale',params.get('locale') || 'zh-CN');
+1 -1
View File
@@ -5,7 +5,7 @@ import { I18nProvider } from '../../src/i18n';
import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage';
import { QuotaPage } from '../../src/pages/QuotaPage';
import { updateQuotaCache } from '../../src/services/quotaCache';
import '../../src/styles.css';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
+1 -1
View File
@@ -5,7 +5,7 @@ import { OAuthLoginPage } from '../../src/pages/ManagementPages';
import { QuotaPage } from '../../src/pages/QuotaPage';
import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage';
import { EasyModePage } from '../../src/pages/EasyModePage';
import '../../src/styles.css';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
const view = params.get('view');
+1 -1
View File
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { ThinkingAliasesPage } from '../../src/pages/ThinkingAliasesPage';
import '../../src/styles.css';
import '../../src/styles/index.css';
localStorage.setItem('easy-cli-proxy-api.locale', 'en');
const calls: { cmd: string; args: Record<string, unknown> }[] = [];
+1 -1
View File
@@ -3,7 +3,7 @@ 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';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
const scenario = params.get('scenario') ?? 'saved';
+1 -1
View File
@@ -7,7 +7,7 @@ import { UsageRecordsPage } from '../../src/pages/UsageRecordsPage';
import { AgentConfigurationFeedback } from '../../src/pages/AgentControls';
import { useConfirmation } from '../../src/components/ConfirmationDialog';
import { QuotaActionFeedback } from '../../src/components/QuotaActionFeedback';
import '../../src/styles.css';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') ?? 'zh-CN');
+1 -1
View File
@@ -3,7 +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 '../../src/styles.css';
import '../../src/styles/index.css';
localStorage.setItem('easy-cli-proxy-api.locale', 'en');
const fixture = window as typeof window & {
+1 -1
View File
@@ -6,7 +6,7 @@ import App from '../../src/App';
import { I18nProvider } from '../../src/i18n';
import { initializeTheme } from '../../src/theme';
import type { AppTheme } from '../../src/themeController';
import '../../src/styles.css';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
const platform = params.get('platform') || 'windows';
+1 -1
View File
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { UsageRecordsPage } from '../../src/pages/UsageRecordsPage';
import '../../src/styles.css';
import '../../src/styles/index.css';
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-TW');
+5 -1
View File
@@ -2,7 +2,11 @@ import { describe, expect, it } from 'bun:test';
import { readFileSync } from 'node:fs';
import postcss from 'postcss';
const stylesheet = postcss.parse(readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8'));
const stylesheet = postcss.parse([
'../src/styles.css',
'../src/styles/feature-pages.css',
'../src/styles/ui-refinements.css',
].map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n'));
const aliases: Record<string, string> = {
'--theme-4b4742': '--text-secondary',
'--theme-59544d': '--text-secondary',
+9 -1
View File
@@ -15,8 +15,16 @@ const technicalText = new Set([
'headers',
'ms',
'note',
'Models.dev',
'LiteLLM',
]);
const technicalPlaceholders = new Set([
'1h',
'sk-...',
'gpt-5.6-terra',
'https://...',
'socks5://127.0.0.1:1080',
]);
const technicalPlaceholders = new Set(['1h', 'sk-...', 'gpt-5.6-terra', 'https://...']);
function componentFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {