Add API error monitoring tests and error context snapshots for various browsers

- Created error context snapshots for Firefox, WebKit, and Chromium to capture UI state during API error monitoring.
- Implemented e2e tests for API error detection, including console errors, failed API requests, and proxy errors.
- Added functionality to ignore specific API patterns in monitoring assertions.
- Ensured tests validate the monitoring system's ability to detect and report errors effectively.
This commit is contained in:
catlog22
2026-01-31 00:15:59 +08:00
parent f1324a0bc8
commit a0f81f8841
66 changed files with 3112 additions and 3175 deletions

View File

@@ -135,7 +135,7 @@ export function Sidebar({
mobileOpen && 'fixed left-0 top-14 flex translate-x-0 z-50 h-[calc(100vh-56px)] w-64 shadow-lg'
)}
role="navigation"
aria-label={formatMessage({ id: 'header.brand' })}
aria-label={formatMessage({ id: 'navigation.header.brand' })}
>
<nav className="flex-1 py-3 overflow-y-auto">
<ul className="space-y-1 px-2">

View File

@@ -0,0 +1,272 @@
// ========================================
// CliStreamPanel Component
// ========================================
// Floating panel for CLI execution details with streaming output
import * as React from 'react';
import { useIntl } from 'react-intl';
import { Terminal, Clock, Calendar, Hash } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { StreamingOutput } from './StreamingOutput';
import { useCliExecutionDetail } from '@/hooks/useCliExecution';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import type { CliOutputLine } from '@/stores/cliStreamStore';
// ========== Stable Selectors ==========
// Create selector factory to avoid infinite re-renders
// The selector function itself is stable, preventing unnecessary re-renders
const createOutputsSelector = (executionId: string) => (state: ReturnType<typeof useCliStreamStore.getState>) =>
state.outputs[executionId];
export interface CliStreamPanelProps {
/** Execution ID to display */
executionId: string;
/** Source directory path */
sourceDir?: string;
/** Whether panel is open */
open: boolean;
/** Called when open state changes */
onOpenChange: (open: boolean) => void;
}
type TabValue = 'prompt' | 'output' | 'details';
/**
* Format duration to human readable string
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
/**
* Get badge variant for tool name
*/
function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info'> = {
gemini: 'info',
codex: 'success',
qwen: 'warning',
};
return variants[tool] || 'secondary';
}
/**
* CliStreamPanel component - Display CLI execution details in floating panel
*
* @remarks
* Shows execution details with three tabs:
* - Prompt: View the conversation prompts
* - Output: Real-time streaming output
* - Details: Execution metadata (tool, mode, duration, etc.)
*
* @example
* ```tsx
* <CliStreamPanel
* executionId="exec-123"
* sourceDir="/path/to/project"
* open={isOpen}
* onOpenChange={setIsOpen}
* />
* ```
*/
export function CliStreamPanel({
executionId,
sourceDir: _sourceDir,
open,
onOpenChange,
}: CliStreamPanelProps) {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = React.useState<TabValue>('output');
// Fetch execution details
const { data: execution, isLoading, error } = useCliExecutionDetail(
open ? executionId : null,
{ enabled: open }
);
// Get streaming outputs from store using stable selector
// Use selector factory to prevent infinite re-renders
const selectOutputs = React.useMemo(
() => createOutputsSelector(executionId),
[executionId]
);
const outputs = useCliStreamStore(selectOutputs) || [];
// Build output lines from conversation (historical) + streaming (real-time)
const allOutputs: CliOutputLine[] = React.useMemo(() => {
const historical: CliOutputLine[] = [];
// Add historical output from conversation turns
if (execution?.turns) {
for (const turn of execution.turns) {
if (turn.output?.stdout) {
historical.push({
type: 'stdout',
content: turn.output.stdout,
timestamp: new Date(turn.timestamp).getTime(),
});
}
if (turn.output?.stderr) {
historical.push({
type: 'stderr',
content: turn.output.stderr,
timestamp: new Date(turn.timestamp).getTime(),
});
}
}
}
// Combine historical + streaming
return [...historical, ...outputs];
}, [execution, outputs]);
// Calculate total duration
const totalDuration = React.useMemo(() => {
if (!execution?.turns) return 0;
return execution.turns.reduce((sum, t) => sum + t.duration_ms, 0);
}, [execution]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
{formatMessage({ id: 'cli.executionDetails' })}
</DialogTitle>
{/* Execution info badges */}
{execution && (
<div className="flex items-center gap-2">
<Badge variant={getToolVariant(execution.tool)}>
{execution.tool.toUpperCase()}
</Badge>
{execution.mode && (
<Badge variant="secondary">{execution.mode}</Badge>
)}
<span className="text-sm text-muted-foreground">
{formatDuration(totalDuration)}
</span>
</div>
)}
</div>
</DialogHeader>
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
) : error ? (
<div className="flex-1 flex items-center justify-center text-destructive">
Failed to load execution details
</div>
) : execution ? (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
className="flex-1 flex flex-col"
>
<div className="px-6 pt-4">
<TabsList>
<TabsTrigger value="prompt">
{formatMessage({ id: 'cli.tabs.prompt' })}
</TabsTrigger>
<TabsTrigger value="output">
{formatMessage({ id: 'cli.tabs.output' })}
</TabsTrigger>
<TabsTrigger value="details">
{formatMessage({ id: 'cli.tabs.details' })}
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-hidden px-6 pb-6">
<TabsContent
value="prompt"
className="mt-4 h-full overflow-y-auto m-0"
>
<div className="p-4 bg-muted rounded-lg max-h-[50vh] overflow-y-auto">
<pre className="text-sm whitespace-pre-wrap">
{execution.turns.map((turn, i) => (
<div key={i} className="mb-4">
<div className="text-xs text-muted-foreground mb-1">
Turn {turn.turn}
</div>
<div>{turn.prompt}</div>
</div>
))}
</pre>
</div>
</TabsContent>
<TabsContent
value="output"
className="mt-4 h-full m-0"
>
<div className="h-[50vh] border border-border rounded-lg overflow-hidden">
<StreamingOutput
outputs={allOutputs}
isStreaming={outputs.length > 0}
/>
</div>
</TabsContent>
<TabsContent
value="details"
className="mt-4 h-full overflow-y-auto m-0"
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Tool:</span>
<Badge variant={getToolVariant(execution.tool)}>
{execution.tool}
</Badge>
</div>
<div className="flex items-center gap-2">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Mode:</span>
<span>{execution.mode || 'N/A'}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Duration:</span>
<span>{formatDuration(totalDuration)}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Created:</span>
<span>
{new Date(execution.created_at).toLocaleString()}
</span>
</div>
</div>
<div className="text-sm text-muted-foreground">
ID: {execution.id}
</div>
<div className="text-sm text-muted-foreground">
Turns: {execution.turn_count}
</div>
</div>
</TabsContent>
</div>
</Tabs>
) : null}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,140 @@
// ========================================
// StreamingOutput Component
// ========================================
// Real-time streaming output display with auto-scroll
import * as React from 'react';
import { useRef, useCallback, useEffect } from 'react';
import { ArrowDownToLine } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import type { CliOutputLine } from '@/stores/cliStreamStore';
export interface StreamingOutputProps {
/** Output lines to display */
outputs: CliOutputLine[];
/** Whether new output is being streamed */
isStreaming?: boolean;
/** Enable auto-scroll (default: true) */
autoScroll?: boolean;
/** Optional className */
className?: string;
}
/**
* Get CSS class for log type coloring
*/
const getLogTypeColor = (type: CliOutputLine['type']) => {
const colors = {
stdout: 'text-foreground',
stderr: 'text-destructive',
metadata: 'text-warning',
thought: 'text-info',
system: 'text-muted-foreground',
tool_call: 'text-purple-500',
};
return colors[type] || colors.stdout;
};
/**
* StreamingOutput component - Display real-time streaming logs
*
* @remarks
* Displays CLI output lines with timestamps and type labels.
* Auto-scrolls to bottom when new output arrives, with user scroll detection.
*
* @example
* ```tsx
* <StreamingOutput
* outputs={outputLines}
* isStreaming={isActive}
* />
* ```
*/
export function StreamingOutput({
outputs,
isStreaming = false,
autoScroll = true,
className,
}: StreamingOutputProps) {
const logsContainerRef = useRef<HTMLDivElement>(null);
const logsEndRef = useRef<HTMLDivElement>(null);
const [isUserScrolling, setIsUserScrolling] = React.useState(false);
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (autoScroll && !isUserScrolling && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [outputs, autoScroll, isUserScrolling]);
// Handle scroll to detect user scrolling
const handleScroll = useCallback(() => {
if (!logsContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsUserScrolling(!isAtBottom);
}, []);
// Scroll to bottom handler
const scrollToBottom = useCallback(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
setIsUserScrolling(false);
}, []);
return (
<div className={cn('flex-1 flex flex-col relative', className)}>
{/* Logs container */}
<div
ref={logsContainerRef}
className="flex-1 overflow-y-auto p-3 font-mono text-xs"
onScroll={handleScroll}
>
{outputs.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
{isStreaming
? 'Waiting for output...'
: 'No output available'}
</div>
) : (
<div className="space-y-1">
{outputs.map((line, index) => (
<div key={index} className="flex gap-2">
<span className="text-muted-foreground shrink-0">
{new Date(line.timestamp).toLocaleTimeString()}
</span>
<span
className={cn(
'uppercase w-20 shrink-0',
getLogTypeColor(line.type)
)}
>
[{line.type}]
</span>
<span className="text-foreground break-all">
{line.content}
</span>
</div>
))}
<div ref={logsEndRef} />
</div>
)}
</div>
{/* Scroll to bottom button */}
{isUserScrolling && outputs.length > 0 && (
<Button
size="sm"
variant="secondary"
className="absolute bottom-3 right-3"
onClick={scrollToBottom}
>
<ArrowDownToLine className="h-4 w-4 mr-1" />
Scroll to bottom
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,336 @@
// ========================================
// TaskDrawer Component
// ========================================
// Right-side task detail drawer with Overview/Flowchart/Files tabs
import * as React from 'react';
import { useIntl } from 'react-intl';
import { X, FileText, GitBranch, Folder, CheckCircle, Circle, Loader2, XCircle } from 'lucide-react';
import { Flowchart } from './Flowchart';
import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/Tabs';
import type { LiteTask, FlowControl } from '@/lib/api';
import type { TaskData } from '@/types/store';
// ========== Types ==========
export interface TaskDrawerProps {
task: LiteTask | TaskData | null;
isOpen: boolean;
onClose: () => void;
}
type TabValue = 'overview' | 'flowchart' | 'files';
// ========== Helper: Unified Task Access ==========
/**
* Normalize task data to common interface
*/
function getTaskId(task: LiteTask | TaskData): string {
if ('task_id' in task && task.task_id) return task.task_id;
if ('id' in task) return task.id;
return 'N/A';
}
function getTaskTitle(task: LiteTask | TaskData): string {
return task.title || 'Untitled Task';
}
function getTaskDescription(task: LiteTask | TaskData): string | undefined {
return task.description;
}
function getTaskStatus(task: LiteTask | TaskData): string {
return task.status;
}
function getFlowControl(task: LiteTask | TaskData): FlowControl | undefined {
if ('flow_control' in task) return task.flow_control;
return undefined;
}
// Status configuration
const taskStatusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' | null; icon: React.ComponentType<{ className?: string }> }> = {
pending: {
label: 'sessionDetail.taskDrawer.status.pending',
variant: 'secondary',
icon: Circle,
},
in_progress: {
label: 'sessionDetail.taskDrawer.status.inProgress',
variant: 'warning',
icon: Loader2,
},
completed: {
label: 'sessionDetail.taskDrawer.status.completed',
variant: 'success',
icon: CheckCircle,
},
blocked: {
label: 'sessionDetail.taskDrawer.status.blocked',
variant: 'destructive',
icon: XCircle,
},
skipped: {
label: 'sessionDetail.taskDrawer.status.skipped',
variant: 'default',
icon: Circle,
},
failed: {
label: 'sessionDetail.taskDrawer.status.failed',
variant: 'destructive',
icon: XCircle,
},
};
// ========== Component ==========
export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = React.useState<TabValue>('overview');
// Reset to overview when task changes
React.useEffect(() => {
if (task) {
setActiveTab('overview');
}
}, [task]);
// ESC key to close
React.useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
if (!task || !isOpen) {
return null;
}
const taskId = getTaskId(task);
const taskTitle = getTaskTitle(task);
const taskDescription = getTaskDescription(task);
const taskStatus = getTaskStatus(task);
const flowControl = getFlowControl(task);
const statusConfig = taskStatusConfig[taskStatus] || taskStatusConfig.pending;
const StatusIcon = statusConfig.icon;
const hasFlowchart = !!flowControl?.implementation_approach && flowControl.implementation_approach.length > 0;
const hasFiles = !!flowControl?.target_files && flowControl.target_files.length > 0;
return (
<>
{/* Overlay */}
<div
className={`fixed inset-0 bg-black/40 transition-opacity z-40 ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={`fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
style={{ minWidth: '400px', maxWidth: '800px' }}
>
{/* Header */}
<div className="flex items-start justify-between p-6 border-b border-border bg-card">
<div className="flex-1 min-w-0 mr-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-mono text-muted-foreground">{taskId}</span>
<Badge variant={statusConfig.variant} className="gap-1">
<StatusIcon className="h-3 w-3" />
{formatMessage({ id: statusConfig.label })}
</Badge>
</div>
<h2 id="drawer-title" className="text-lg font-semibold text-foreground">
{taskTitle}
</h2>
</div>
<Button variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
<X className="h-5 w-5" />
<span className="sr-only">{formatMessage({ id: 'common.actions.close' })}</span>
</Button>
</div>
{/* Tabs Navigation */}
<div className="px-6 pt-4 bg-card">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)} className="w-full">
<TabsList className="w-full">
<TabsTrigger value="overview" className="flex-1">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.taskDrawer.tabs.overview' })}
</TabsTrigger>
{hasFlowchart && (
<TabsTrigger value="flowchart" className="flex-1">
<GitBranch className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.taskDrawer.tabs.flowchart' })}
</TabsTrigger>
)}
<TabsTrigger value="files" className="flex-1">
<Folder className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.taskDrawer.tabs.files' })}
</TabsTrigger>
</TabsList>
{/* Tab Content (scrollable) */}
<div className="overflow-y-auto pr-2" style={{ height: 'calc(100vh - 200px)' }}>
{/* Overview Tab */}
<TabsContent value="overview" className="mt-4 pb-6 focus-visible:outline-none">
<div className="space-y-6">
{/* Description */}
{taskDescription && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-2">
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.description' })}
</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{taskDescription}
</p>
</div>
)}
{/* Pre-analysis Steps */}
{flowControl?.pre_analysis && flowControl.pre_analysis.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.preAnalysis' })}
</h3>
<div className="space-y-3">
{flowControl.pre_analysis.map((step, index) => (
<div key={index} className="p-3 bg-secondary rounded-md">
<div className="flex items-start gap-2">
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
{index + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">{step.step}</p>
<p className="text-xs text-muted-foreground mt-1">{step.action}</p>
{step.commands && step.commands.length > 0 && (
<div className="mt-2">
<code className="text-xs bg-background px-2 py-1 rounded border">
{step.commands.join('; ')}
</code>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Implementation Steps */}
{flowControl?.implementation_approach && flowControl.implementation_approach.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.implementationSteps' })}
</h3>
<div className="space-y-3">
{flowControl.implementation_approach.map((step, index) => (
<div key={index} className="p-3 bg-secondary rounded-md">
<div className="flex items-start gap-2">
<span className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-accent text-accent-foreground text-xs font-medium">
{step.step || index + 1}
</span>
<div className="flex-1 min-w-0">
{step.title && (
<p className="text-sm font-medium text-foreground">{step.title}</p>
)}
{step.description && (
<p className="text-xs text-muted-foreground mt-1">{step.description}</p>
)}
{step.modification_points && step.modification_points.length > 0 && (
<div className="mt-2">
<p className="text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.modificationPoints' })}:
</p>
<ul className="text-xs space-y-1">
{step.modification_points.map((point, i) => (
<li key={i} className="text-muted-foreground"> {point}</li>
))}
</ul>
</div>
)}
{step.depends_on && step.depends_on.length > 0 && (
<p className="text-xs text-muted-foreground mt-2">
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.dependsOn' })}: Step {step.depends_on.join(', ')}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{!taskDescription &&
(!flowControl?.pre_analysis || flowControl.pre_analysis.length === 0) &&
(!flowControl?.implementation_approach || flowControl.implementation_approach.length === 0) && (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.taskDrawer.overview.empty' })}
</p>
</div>
)}
</div>
</TabsContent>
{/* Flowchart Tab */}
{hasFlowchart && (
<TabsContent value="flowchart" className="mt-4 pb-6">
<div className="bg-secondary rounded-lg p-4 border border-border">
<Flowchart flowControl={flowControl!} />
</div>
</TabsContent>
)}
{/* Files Tab */}
<TabsContent value="files" className="mt-4 pb-6">
{hasFiles ? (
<div className="space-y-2">
{flowControl!.target_files!.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 bg-secondary rounded-md border border-border hover:bg-secondary/80 transition-colors"
>
<Folder className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<code className="text-xs text-foreground flex-1 min-w-0 truncate">
{file}
</code>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Folder className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'sessionDetail.taskDrawer.files.empty' })}
</p>
</div>
)}
</TabsContent>
</div>
</Tabs>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { useTheme } from '@/hooks/useTheme';
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
import type { ColorScheme, ThemeMode } from '@/lib/theme';
/**
* Theme Selector Component
* Allows users to select from 4 color schemes (blue/green/orange/purple)
* and 2 theme modes (light/dark)
*
* Features:
* - 8 total theme combinations
* - Keyboard navigation support (Arrow keys)
* - ARIA labels for accessibility
* - Visual feedback for selected theme
* - System dark mode detection
*/
export function ThemeSelector() {
const { colorScheme, resolvedTheme, setColorScheme, setTheme } = useTheme();
// Resolved mode is either 'light' or 'dark'
const mode: ThemeMode = resolvedTheme;
const handleSchemeSelect = (scheme: ColorScheme) => {
setColorScheme(scheme);
};
const handleModeSelect = (newMode: ThemeMode) => {
setTheme(newMode);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const currentIndex = COLOR_SCHEMES.findIndex(s => s.id === colorScheme);
const nextIndex = (currentIndex + 1) % COLOR_SCHEMES.length;
handleSchemeSelect(COLOR_SCHEMES[nextIndex].id);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const currentIndex = COLOR_SCHEMES.findIndex(s => s.id === colorScheme);
const nextIndex = (currentIndex - 1 + COLOR_SCHEMES.length) % COLOR_SCHEMES.length;
handleSchemeSelect(COLOR_SCHEMES[nextIndex].id);
}
};
return (
<div className="space-y-6">
{/* Color Scheme Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
</h3>
<div
className="grid grid-cols-4 gap-3"
role="group"
aria-label="Color scheme selection"
onKeyDown={handleKeyDown}
>
{COLOR_SCHEMES.map((scheme) => (
<button
key={scheme.id}
onClick={() => handleSchemeSelect(scheme.id)}
aria-label={`选择${scheme.name}主题`}
aria-selected={colorScheme === scheme.id}
role="radio"
className={`
flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
${colorScheme === scheme.id
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{/* Color swatch */}
<div
className="w-8 h-8 rounded-full border-2 border-border shadow-sm"
style={{ backgroundColor: scheme.accentColor }}
aria-hidden="true"
/>
{/* Label */}
<span className="text-xs font-medium text-text text-center">
{scheme.name}
</span>
</button>
))}
</div>
</div>
{/* Theme Mode Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
</h3>
<div
className="grid grid-cols-2 gap-3"
role="group"
aria-label="Theme mode selection"
>
{THEME_MODES.map((modeOption) => (
<button
key={modeOption.id}
onClick={() => handleModeSelect(modeOption.id)}
aria-label={`选择${modeOption.name}模式`}
aria-selected={mode === modeOption.id}
role="radio"
className={`
flex items-center justify-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
${mode === modeOption.id
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{/* Icon */}
<span className="text-lg" aria-hidden="true">
{modeOption.id === 'light' ? '☀️' : '🌙'}
</span>
{/* Label */}
<span className="text-sm font-medium text-text">
{modeOption.name}
</span>
</button>
))}
</div>
</div>
{/* Current Theme Display */}
<div className="p-3 rounded-lg bg-surface border border-border">
<p className="text-xs text-text-secondary">
: <span className="font-medium text-text">{getThemeName(colorScheme, mode)}</span>
</p>
</div>
</div>
);
}