mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
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:
@@ -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">
|
||||
|
||||
272
ccw/frontend/src/components/shared/CliStreamPanel.tsx
Normal file
272
ccw/frontend/src/components/shared/CliStreamPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
ccw/frontend/src/components/shared/StreamingOutput.tsx
Normal file
140
ccw/frontend/src/components/shared/StreamingOutput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
ccw/frontend/src/components/shared/TaskDrawer.tsx
Normal file
336
ccw/frontend/src/components/shared/TaskDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
ccw/frontend/src/components/shared/ThemeSelector.tsx
Normal file
139
ccw/frontend/src/components/shared/ThemeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user