fix: improve UI accessibility and responsive layouts
No files matched your search
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 158 KiB |
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,52 +543,95 @@ 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-tabs" role="tablist" aria-label={t('usage.pageLabel')}>
|
||||
<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">
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@import '../styles.css';
|
||||
@import './feature-pages.css';
|
||||
@import './ui-refinements.css';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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> }[] = [];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||