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>
);
}

View File

@@ -166,3 +166,13 @@ export type {
UseRulesOptions,
UseRulesReturn,
} from './useCli';
// ========== CLI Execution ==========
export {
useCliExecutionDetail,
cliExecutionKeys,
} from './useCliExecution';
export type {
UseCliExecutionOptions,
UseCliExecutionReturn,
} from './useCliExecution';

View File

@@ -0,0 +1,112 @@
// ========================================
// useCliExecution Hook
// ========================================
// TanStack Query hook for CLI execution details
import { useQuery } from '@tanstack/react-query';
import {
fetchExecutionDetail,
type ConversationRecord,
} from '../lib/api';
// ========== Query Keys ==========
/**
* Query key factory for CLI execution queries
*/
export const cliExecutionKeys = {
all: ['cliExecution'] as const,
details: () => [...cliExecutionKeys.all, 'detail'] as const,
detail: (id: string | null) => [...cliExecutionKeys.details(), id] as const,
};
// ========== Constants ==========
/**
* Default stale time: 5 minutes
* Execution details don't change frequently after completion
*/
const STALE_TIME = 5 * 60 * 1000;
/**
* Cache time: 10 minutes
* Keep cached data available for potential re-use
*/
const GC_TIME = 10 * 60 * 1000;
// ========== Types ==========
export interface UseCliExecutionOptions {
/** Override default stale time (ms) */
staleTime?: number;
/** Override default cache time (ms) */
gcTime?: number;
/** Enable/disable the query */
enabled?: boolean;
}
export interface UseCliExecutionReturn {
/** Execution detail data */
data: ConversationRecord | undefined;
/** Loading state for initial fetch */
isLoading: boolean;
/** Fetching state (initial or refetch) */
isFetching: boolean;
/** Error object if query failed */
error: Error | null;
/** Manually refetch data */
refetch: () => Promise<void>;
}
// ========== Hook ==========
/**
* Hook for fetching CLI execution detail (conversation records)
*
* @param executionId - The CLI execution ID to fetch details for
* @param options - Query options
*
* @example
* ```tsx
* const { data, isLoading, error } = useCliExecutionDetail('exec-123');
* ```
*
* @remarks
* - Query is disabled when executionId is null/undefined
* - Data is cached for 5 minutes by default
* - Auto-refetch is disabled (execution details don't change)
*/
export function useCliExecutionDetail(
executionId: string | null,
options: UseCliExecutionOptions = {}
): UseCliExecutionReturn {
const { staleTime = STALE_TIME, gcTime = GC_TIME, enabled = true } = options;
const query = useQuery<ConversationRecord>({
queryKey: cliExecutionKeys.detail(executionId),
queryFn: () => {
if (!executionId) throw new Error('executionId is required');
return fetchExecutionDetail(executionId);
},
enabled: !!executionId && enabled,
staleTime,
gcTime,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
};
}

View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
/**
* Generic hook for managing localStorage state with SSR safety
* @template T The type of value being stored
* @param key The localStorage key
* @param defaultValue The default value if not in localStorage
* @returns [value, setValue] tuple similar to useState
*/
export function useLocalStorage<T>(key: string, defaultValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(defaultValue);
// Load value from localStorage on mount (for SSR safety)
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.warn(`Failed to load localStorage key "${key}":`, error);
}
}, [key]);
// Update localStorage when value changes
const setValue = (value: T) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn(`Failed to set localStorage key "${key}":`, error);
}
};
// Return storedValue immediately (it will be hydrated after effect runs)
return [storedValue, setValue];
}

View File

@@ -1,11 +1,11 @@
// ========================================
// useTheme Hook
// ========================================
// Convenient hook for theme management
// Convenient hook for theme management with multi-color scheme support
import { useCallback } from 'react';
import { useAppStore, selectTheme, selectResolvedTheme } from '../stores/appStore';
import type { Theme } from '../types/store';
import type { Theme, ColorScheme } from '../types/store';
export interface UseThemeReturn {
/** Current theme preference ('light', 'dark', 'system') */
@@ -14,31 +14,40 @@ export interface UseThemeReturn {
resolvedTheme: 'light' | 'dark';
/** Whether the resolved theme is dark */
isDark: boolean;
/** Current color scheme ('blue', 'green', 'orange', 'purple') */
colorScheme: ColorScheme;
/** Set theme preference */
setTheme: (theme: Theme) => void;
/** Set color scheme */
setColorScheme: (scheme: ColorScheme) => void;
/** Toggle between light and dark (ignores system) */
toggleTheme: () => void;
}
/**
* Hook for managing theme state
* Hook for managing theme state with multi-color scheme support
* @returns Theme state and actions
*
* @example
* ```tsx
* const { theme, isDark, setTheme, toggleTheme } = useTheme();
* const { theme, colorScheme, isDark, setTheme, setColorScheme, toggleTheme } = useTheme();
*
* return (
* <button onClick={toggleTheme}>
* {isDark ? 'Switch to Light' : 'Switch to Dark'}
* </button>
* <div>
* <button onClick={() => setColorScheme('blue')}>Blue Theme</button>
* <button onClick={toggleTheme}>
* {isDark ? 'Switch to Light' : 'Switch to Dark'}
* </button>
* </div>
* );
* ```
*/
export function useTheme(): UseThemeReturn {
const theme = useAppStore(selectTheme);
const resolvedTheme = useAppStore(selectResolvedTheme);
const colorScheme = useAppStore((state) => state.colorScheme);
const setThemeAction = useAppStore((state) => state.setTheme);
const setColorSchemeAction = useAppStore((state) => state.setColorScheme);
const toggleThemeAction = useAppStore((state) => state.toggleTheme);
const setTheme = useCallback(
@@ -48,6 +57,13 @@ export function useTheme(): UseThemeReturn {
[setThemeAction]
);
const setColorScheme = useCallback(
(newColorScheme: ColorScheme) => {
setColorSchemeAction(newColorScheme);
},
[setColorSchemeAction]
);
const toggleTheme = useCallback(() => {
toggleThemeAction();
}, [toggleThemeAction]);
@@ -56,7 +72,9 @@ export function useTheme(): UseThemeReturn {
theme,
resolvedTheme,
isDark: resolvedTheme === 'dark',
colorScheme,
setTheme,
setColorScheme,
toggleTheme,
};
}

View File

@@ -7,6 +7,7 @@ import { useEffect, useRef, useCallback } from 'react';
import { useNotificationStore } from '@/stores';
import { useExecutionStore } from '@/stores/executionStore';
import { useFlowStore } from '@/stores';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import {
OrchestratorMessageSchema,
type OrchestratorWebSocketMessage,
@@ -54,6 +55,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Flow store for node status updates on canvas
const updateNode = useFlowStore((state) => state.updateNode);
// CLI stream store for CLI output handling
const addOutput = useCliStreamStore((state) => state.addOutput);
// Handle incoming WebSocket messages
const handleMessage = useCallback(
(event: MessageEvent) => {
@@ -63,6 +67,69 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Store last message for debugging
setWsLastMessage(data);
// Handle CLI messages
if (data.type?.startsWith('CLI_')) {
switch (data.type) {
case 'CLI_STARTED': {
const { executionId, tool, mode, timestamp } = data.payload;
// Add system message for CLI start
addOutput(executionId, {
type: 'system',
content: `[${new Date(timestamp).toLocaleTimeString()}] CLI execution started: ${tool} (${mode || 'default'} mode)`,
timestamp: Date.now(),
});
break;
}
case 'CLI_OUTPUT': {
const { executionId, chunkType, data: outputData, unit } = data.payload;
// Handle structured output
const unitContent = unit?.content || outputData;
const unitType = unit?.type || chunkType;
// Special handling for tool_call type
let content: string;
if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) {
// Format tool_call display
content = JSON.stringify(unitContent);
} else {
content = typeof unitContent === 'string' ? unitContent : JSON.stringify(unitContent);
}
// Split by lines and add each line to store
const lines = content.split('\n');
lines.forEach((line: string) => {
// Add non-empty lines, or single line if that's all we have
if (line.trim() || lines.length === 1) {
addOutput(executionId, {
type: unitType as any,
content: line,
timestamp: Date.now(),
});
}
});
break;
}
case 'CLI_COMPLETED': {
const { executionId, success, duration } = data.payload;
const statusText = success ? 'completed successfully' : 'failed';
const durationText = duration ? ` (${duration}ms)` : '';
addOutput(executionId, {
type: 'system',
content: `[${new Date().toLocaleTimeString()}] CLI execution ${statusText}${durationText}`,
timestamp: Date.now(),
});
break;
}
}
return;
}
// Check if this is an orchestrator message
if (!data.type?.startsWith('ORCHESTRATOR_')) {
return;
@@ -138,6 +205,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
addLog,
completeExecution,
updateNode,
addOutput,
onMessage,
]
);

View File

@@ -1,30 +1,45 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
@import './styles/typography.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* CSS Custom Properties - Light Mode */
:root {
--background: 0 0% 98%;
--foreground: 0 0% 13%;
--card: 0 0% 100%;
--card-foreground: 0 0% 13%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 220 65% 50%;
--primary: 220 65% 50%;
/* ===========================
Multi-Theme Color System
4 Colors × 2 Modes = 8 Themes
=========================== */
/* Classic Blue - Light Mode */
:root,
[data-theme="light-blue"] {
--bg: 0 0% 98%;
--surface: 220 60% 99%;
--border: 220 20% 88%;
--text: 220 30% 15%;
--text-secondary: 220 15% 45%;
--accent: 220 90% 56%;
/* Legacy variables for backward compatibility */
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: 0 0% 100%;
--primary-light: 220 65% 95%;
--primary-light: 220 90% 95%;
--secondary: 220 60% 65%;
--secondary-foreground: 0 0% 100%;
--accent: 220 40% 95%;
--accent-foreground: 0 0% 13%;
--accent-foreground: 0 0% 100%;
--destructive: 8 75% 55%;
--destructive-foreground: 0 0% 100%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--sidebar-background: 0 0% 97%;
--sidebar-foreground: 0 0% 13%;
--hover: 0 0% 93%;
--muted: 220 20% 96%;
--muted-foreground: var(--text-secondary);
--sidebar-background: 220 40% 97%;
--sidebar-foreground: var(--text);
--hover: 220 20% 93%;
--input: var(--border);
--ring: var(--accent);
--success: 142 71% 45%;
--success-light: 142 76% 90%;
--warning: 38 92% 50%;
@@ -37,29 +52,324 @@
--orange-light: 25 90% 92%;
}
/* Dark Mode */
[data-theme="dark"] {
--background: 220 13% 10%;
--foreground: 0 0% 90%;
--card: 220 13% 14%;
--card-foreground: 0 0% 90%;
--border: 220 13% 20%;
--input: 220 13% 20%;
--ring: 220 65% 55%;
--primary: 220 65% 55%;
--primary-foreground: 0 0% 100%;
--primary-light: 220 50% 25%;
/* Classic Blue - Dark Mode */
[data-theme="dark-blue"] {
--bg: 220 30% 10%;
--surface: 220 25% 14%;
--border: 220 20% 22%;
--text: 220 20% 90%;
--text-secondary: 220 15% 60%;
--accent: 220 90% 60%;
/* Legacy variables */
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: 220 30% 10%;
--primary-light: 220 70% 25%;
--secondary: 220 60% 60%;
--secondary-foreground: 0 0% 100%;
--accent: 220 30% 20%;
--accent-foreground: 0 0% 90%;
--accent-foreground: 220 30% 10%;
--destructive: 8 70% 50%;
--destructive-foreground: 0 0% 100%;
--muted: 220 13% 18%;
--muted-foreground: 0 0% 55%;
--sidebar-background: 220 13% 12%;
--sidebar-foreground: 0 0% 90%;
--hover: 220 13% 22%;
--muted: 220 25% 18%;
--muted-foreground: var(--text-secondary);
--sidebar-background: 220 30% 12%;
--sidebar-foreground: var(--text);
--hover: 220 20% 24%;
--input: var(--border);
--ring: var(--accent);
--success: 142 71% 40%;
--success-light: 142 50% 20%;
--warning: 38 85% 45%;
--warning-light: 40 50% 20%;
--info: 210 75% 50%;
--info-light: 210 50% 20%;
--indigo: 239 60% 55%;
--indigo-light: 239 40% 20%;
--orange: 25 85% 50%;
--orange-light: 25 50% 20%;
}
/* Deep Green - Light Mode */
[data-theme="light-green"] {
--bg: 0 0% 98%;
--surface: 150 50% 99%;
--border: 150 20% 88%;
--text: 150 30% 15%;
--text-secondary: 150 15% 45%;
--accent: 150 75% 42%;
/* Legacy variables */
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: 0 0% 100%;
--primary-light: 150 75% 95%;
--secondary: 150 60% 55%;
--secondary-foreground: 0 0% 100%;
--accent-foreground: 0 0% 100%;
--destructive: 8 75% 55%;
--destructive-foreground: 0 0% 100%;
--muted: 150 20% 96%;
--muted-foreground: var(--text-secondary);
--sidebar-background: 150 30% 97%;
--sidebar-foreground: var(--text);
--hover: 150 20% 93%;
--input: var(--border);
--ring: var(--accent);
--success: 142 71% 45%;
--success-light: 142 76% 90%;
--warning: 38 92% 50%;
--warning-light: 48 96% 89%;
--info: 210 80% 55%;
--info-light: 210 80% 92%;
--indigo: 239 65% 60%;
--indigo-light: 239 65% 92%;
--orange: 25 90% 55%;
--orange-light: 25 90% 92%;
}
/* Deep Green - Dark Mode */
[data-theme="dark-green"] {
--bg: 150 30% 10%;
--surface: 150 25% 14%;
--border: 150 20% 22%;
--text: 150 15% 90%;
--text-secondary: 150 12% 60%;
--accent: 150 75% 45%;
/* Legacy variables */
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: 150 30% 10%;
--primary-light: 150 60% 25%;
--secondary: 150 55% 50%;
--secondary-foreground: 0 0% 100%;
--accent-foreground: 150 30% 10%;
--destructive: 8 70% 50%;
--destructive-foreground: 0 0% 100%;
--muted: 150 25% 18%;
--muted-foreground: var(--text-secondary);
--sidebar-background: 150 30% 12%;
--sidebar-foreground: var(--text);
--hover: 150 20% 24%;
--input: var(--border);
--ring: var(--accent);
--success: 142 71% 40%;
--success-light: 142 50% 20%;
--warning: 38 85% 45%;
--warning-light: 40 50% 20%;
--info: 210 75% 50%;
--info-light: 210 50% 20%;
--indigo: 239 60% 55%;
--indigo-light: 239 40% 20%;
--orange: 25 85% 50%;
--orange-light: 25 50% 20%;
}
/* Vibrant Orange - Light Mode */
[data-theme="light-orange"] {
--bg: 0 0% 98%;
--surface: 25 70% 99%;
--border: 25 30% 88%;
--text: 25 30% 15%;
--text-secondary: 25 15% 45%;
--accent: 25 95% 53%;
/* Legacy variables */
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: 0 0% 100%;
--primary-light: 25 95% 95%;
--secondary: 25 80% 60%;
--secondary-foreground: 0 0% 100%;
--accent-foreground: 0 0% 100%;
--destructive: 8 75% 55%;
--destructive-foreground: 0 0% 100%;
--muted: 25 20% 96%;
--muted-foreground: var(--text-secondary);
--sidebar-background: 25 40% 97%;
--sidebar-foreground: var(--text);
--hover: 25 30% 93%;
--input: var(--border);
--ring: var(--accent);
--success: 142 71% 45%;
--success-light: 142 76% 90%;
--warning: 38 92% 50%;
--warning-light: 48 96% 89%;
--info: 210 80% 55%;
--info-light: 210 80% 92%;
--indigo: 239 65% 60%;
--indigo-light: 239 65% 92%;
--orange: 25 90% 55%;
--orange-light: 25 90% 92%;
}
/* Vibrant Orange - Dark Mode */
[data-theme="dark-orange"] {
--bg: 25 30% 10%;
--surface: 25 25% 14%;
--border: 25 20% 22%;
--text: 25 15% 90%;
--text-secondary: 25 12% 60%;
--accent: 25 95% 55%;
/* Legacy variables */
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: 25 30% 10%;
--primary-light: 25 80% 30%;
--secondary: 25 75% 55%;
--secondary-foreground: 0 0% 100%;
--accent-foreground: 25 30% 10%;
--destructive: 8 70% 50%;
--destructive-foreground: 0 0% 100%;
--muted: 25 25% 18%;
--muted-foreground: var(--text-secondary);
--sidebar-background: 25 30% 12%;
--sidebar-foreground: var(--text);
--hover: 25 20% 24%;
--input: var(--border);
--ring: var(--accent);
--success: 142 71% 40%;
--success-light: 142 50% 20%;
--warning: 38 85% 45%;
--warning-light: 40 50% 20%;
--info: 210 75% 50%;
--info-light: 210 50% 20%;
--indigo: 239 60% 55%;
--indigo-light: 239 40% 20%;
--orange: 25 85% 50%;
--orange-light: 25 50% 20%;
}
/* Elegant Purple - Light Mode */
[data-theme="light-purple"] {
--bg: 0 0% 98%;
--surface: 270 60% 99%;
--border: 270 20% 88%;
--text: 270 30% 15%;
--text-secondary: 270 15% 45%;
--accent: 270 75% 60%;
/* Legacy variables */
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: 0 0% 100%;
--primary-light: 270 75% 95%;
--secondary: 270 65% 65%;
--secondary-foreground: 0 0% 100%;
--accent-foreground: 0 0% 100%;
--destructive: 8 75% 55%;
--destructive-foreground: 0 0% 100%;
--muted: 270 20% 96%;
--muted-foreground: var(--text-secondary);
--sidebar-background: 270 35% 97%;
--sidebar-foreground: var(--text);
--hover: 270 20% 93%;
--input: var(--border);
--ring: var(--accent);
--success: 142 71% 45%;
--success-light: 142 76% 90%;
--warning: 38 92% 50%;
--warning-light: 48 96% 89%;
--info: 210 80% 55%;
--info-light: 210 80% 92%;
--indigo: 239 65% 60%;
--indigo-light: 239 65% 92%;
--orange: 25 90% 55%;
--orange-light: 25 90% 92%;
}
/* Elegant Purple - Dark Mode */
[data-theme="dark-purple"] {
--bg: 270 30% 10%;
--surface: 270 25% 14%;
--border: 270 20% 22%;
--text: 270 15% 90%;
--text-secondary: 270 12% 60%;
--accent: 270 75% 62%;
/* Legacy variables */
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--primary: var(--accent);
--primary-foreground: 270 30% 10%;
--primary-light: 270 60% 30%;
--secondary: 270 60% 58%;
--secondary-foreground: 0 0% 100%;
--accent-foreground: 270 30% 10%;
--destructive: 8 70% 50%;
--destructive-foreground: 0 0% 100%;
--muted: 270 25% 18%;
--muted-foreground: var(--text-secondary);
--sidebar-background: 270 30% 12%;
--sidebar-foreground: var(--text);
--hover: 270 20% 24%;
--input: var(--border);
--ring: var(--accent);
--success: 142 71% 40%;
--success-light: 142 50% 20%;
--warning: 38 85% 45%;
--warning-light: 40 50% 20%;
--info: 210 75% 50%;
--info-light: 210 50% 20%;
--indigo: 239 60% 55%;
--indigo-light: 239 40% 20%;
--orange: 25 85% 50%;
--orange-light: 25 50% 20%;
}
/* Alias for legacy dark mode */
[data-theme="dark"] {
--bg: var(--bg, 220 30% 10%);
--surface: var(--surface, 220 25% 14%);
--border: var(--border, 220 20% 22%);
--text: var(--text, 220 20% 90%);
--text-secondary: var(--text-secondary, 220 15% 60%);
--accent: var(--accent, 220 90% 60%);
/* Apply dark-blue theme as fallback */
--background: 220 30% 10%;
--foreground: 220 20% 90%;
--card: 220 25% 14%;
--card-foreground: 220 20% 90%;
--border: 220 20% 22%;
--input: 220 20% 22%;
--ring: 220 90% 60%;
--primary: 220 90% 60%;
--primary-foreground: 220 30% 10%;
--primary-light: 220 70% 25%;
--secondary: 220 60% 60%;
--secondary-foreground: 0 0% 100%;
--accent: 220 90% 60%;
--accent-foreground: 220 30% 10%;
--destructive: 8 70% 50%;
--destructive-foreground: 0 0% 100%;
--muted: 220 25% 18%;
--muted-foreground: 220 15% 60%;
--sidebar-background: 220 30% 12%;
--sidebar-foreground: 220 20% 90%;
--hover: 220 20% 24%;
--success: 142 71% 40%;
--success-light: 142 50% 20%;
--warning: 38 85% 45%;
@@ -80,6 +390,14 @@
body {
@apply bg-background text-foreground;
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Code and data display should use monospace */
code, kbd, pre {
@apply font-mono;
}
}

View File

@@ -215,6 +215,7 @@ function transformBackendSession(
created_at: backendSession.created_at,
updated_at: backendSession.updated_at,
location,
path: (backendSession as unknown as { path?: string }).path,
// Preserve additional fields if they exist
has_plan: (backendSession as unknown as { has_plan?: boolean }).has_plan,
plan_updated_at: (backendSession as unknown as { plan_updated_at?: string }).plan_updated_at,
@@ -871,9 +872,32 @@ export interface SessionDetailResponse {
/**
* Fetch session detail
* First fetches session list to get the session path, then fetches detail data
*/
export async function fetchSessionDetail(sessionId: string): Promise<SessionDetailResponse> {
return fetchApi<SessionDetailResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/detail`);
// Step 1: Fetch all sessions to get the session path
const sessionsData = await fetchSessions();
const allSessions = [...sessionsData.activeSessions, ...sessionsData.archivedSessions];
const session = allSessions.find(s => s.session_id === sessionId);
if (!session) {
throw new Error(`Session not found: ${sessionId}`);
}
// Step 2: Use the session path to fetch detail data from the correct endpoint
// Backend expects path parameter, not sessionId
const sessionPath = (session as any).path || session.session_id;
const detailData = await fetchApi<any>(`/api/session-detail?path=${encodeURIComponent(sessionPath)}&type=all`);
// Step 3: Transform the response to match SessionDetailResponse interface
return {
session,
context: detailData.context,
summary: detailData.summary,
implPlan: detailData.implPlan,
conflicts: detailData.conflicts,
review: detailData.review,
};
}
// ========== History / CLI Execution API ==========
@@ -931,6 +955,54 @@ export async function deleteAllHistory(): Promise<void> {
});
}
/**
* Fetch CLI execution detail (conversation records)
*/
export async function fetchExecutionDetail(
executionId: string,
sourceDir?: string
): Promise<ConversationRecord> {
const params = new URLSearchParams({ id: executionId });
if (sourceDir) params.set('path', sourceDir);
const data = await fetchApi<ConversationRecord>(
`/api/cli/execution?${params.toString()}`
);
return data;
}
// ========== CLI Execution Types ==========
/**
* Conversation record for a CLI execution
* Contains the full conversation history between user and CLI tool
*/
export interface ConversationRecord {
id: string;
tool: string;
mode?: string;
turns: ConversationTurn[];
turn_count: number;
created_at: string;
updated_at?: string;
}
/**
* Single turn in a CLI conversation
*/
export interface ConversationTurn {
turn: number;
prompt: string;
output: {
stdout: string;
stderr?: string;
truncated?: boolean;
structured?: unknown[];
};
timestamp: string;
duration_ms: number;
}
// ========== CLI Tools Config API ==========
export interface CliToolsConfigResponse {

View File

@@ -0,0 +1,114 @@
/**
* Theme System Configuration
* Defines available color schemes and theme modes for the CCW application
*/
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
export type ThemeMode = 'light' | 'dark';
export type ThemeId = `${ThemeMode}-${ColorScheme}`;
export interface ThemeOption {
id: ColorScheme;
name: string;
accentColor: string; // Display color for theme selector UI
description: string;
}
export interface Theme {
id: ThemeId;
scheme: ColorScheme;
mode: ThemeMode;
name: string;
}
/**
* Available color schemes with display metadata
*/
export const COLOR_SCHEMES: ThemeOption[] = [
{
id: 'blue',
name: '经典蓝',
accentColor: '#3b82f6', // blue-500
description: 'Classic professional blue tone'
},
{
id: 'green',
name: '深邃绿',
accentColor: '#10b981', // emerald-500
description: 'Deep natural green tone'
},
{
id: 'orange',
name: '活力橙',
accentColor: '#f97316', // orange-500
description: 'Vibrant energetic orange tone'
},
{
id: 'purple',
name: '优雅紫',
accentColor: '#a855f7', // purple-500
description: 'Elegant creative purple tone'
}
];
/**
* Theme mode options
*/
export const THEME_MODES: Array<{id: ThemeMode; name: string}> = [
{ id: 'light', name: '浅色' },
{ id: 'dark', name: '深色' }
];
/**
* Generate full theme ID from scheme and mode
*/
export function getThemeId(scheme: ColorScheme, mode: ThemeMode): ThemeId {
return `${mode}-${scheme}`;
}
/**
* Parse theme ID into scheme and mode components
*/
export function parseThemeId(themeId: string): { scheme: ColorScheme; mode: ThemeMode } | null {
const match = themeId.match(/^(light|dark)-(blue|green|orange|purple)$/);
if (!match) return null;
return {
mode: match[1] as ThemeMode,
scheme: match[2] as ColorScheme
};
}
/**
* Get display name for a theme
*/
export function getThemeName(scheme: ColorScheme, mode: ThemeMode): string {
const schemeOption = COLOR_SCHEMES.find(s => s.id === scheme);
const modeOption = THEME_MODES.find(m => m.id === mode);
if (!schemeOption || !modeOption) return 'Unknown Theme';
return `${schemeOption.name} · ${modeOption.name}`;
}
/**
* All available theme combinations (8 total)
*/
export const ALL_THEMES: Theme[] = COLOR_SCHEMES.flatMap(scheme =>
THEME_MODES.map(mode => ({
id: getThemeId(scheme.id, mode.id),
scheme: scheme.id,
mode: mode.id,
name: getThemeName(scheme.id, mode.id)
}))
);
/**
* Default theme configuration
*/
export const DEFAULT_THEME: Theme = {
id: 'light-blue',
scheme: 'blue',
mode: 'light',
name: '经典蓝 · 浅色'
};

View File

@@ -123,5 +123,11 @@
"title": "No Rules Found",
"message": "Add a rule to enforce code quality standards."
}
},
"executionDetails": "Execution Details",
"tabs": {
"prompt": "Prompt",
"output": "Output",
"details": "Details"
}
}

View File

@@ -19,6 +19,9 @@ import liteTasks from './lite-tasks.json';
import projectOverview from './project-overview.json';
import reviewSession from './review-session.json';
import sessionDetail from './session-detail.json';
import skills from './skills.json';
import cliManager from './cli-manager.json';
import mcpManager from './mcp-manager.json';
/**
* Flattens nested JSON object to dot-separated keys
@@ -45,20 +48,23 @@ function flattenMessages(obj: Record<string, unknown>, prefix = ''): Record<stri
* Consolidated and flattened English messages
*/
export default {
...flattenMessages(common),
...flattenMessages(navigation),
...flattenMessages(sessions),
...flattenMessages(issues),
...flattenMessages(home),
...flattenMessages(orchestrator),
...flattenMessages(loops),
...flattenMessages(commands),
...flattenMessages(memory),
...flattenMessages(settings),
...flattenMessages(fixSession),
...flattenMessages(history),
...flattenMessages(liteTasks),
...flattenMessages(projectOverview),
...flattenMessages(reviewSession),
...flattenMessages(sessionDetail),
...flattenMessages(common, 'common'),
...flattenMessages(navigation, 'navigation'),
...flattenMessages(sessions, 'sessions'),
...flattenMessages(issues, 'issues'),
...flattenMessages(home, 'home'),
...flattenMessages(orchestrator, 'orchestrator'),
...flattenMessages(loops, 'loops'),
...flattenMessages(commands, 'commands'),
...flattenMessages(memory, 'memory'),
...flattenMessages(settings, 'settings'),
...flattenMessages(fixSession, 'fixSession'),
...flattenMessages(history, 'history'),
...flattenMessages(liteTasks, 'liteTasks'),
...flattenMessages(projectOverview, 'projectOverview'),
...flattenMessages(reviewSession, 'reviewSession'),
...flattenMessages(sessionDetail, 'sessionDetail'),
...flattenMessages(skills, 'skills'),
...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys
...flattenMessages(mcpManager, 'mcp'),
} as Record<string, string>;

View File

@@ -50,5 +50,34 @@
"updated": "Updated",
"tasks": "Tasks",
"description": "Description"
},
"taskDrawer": {
"status": {
"pending": "Pending",
"inProgress": "In Progress",
"completed": "Completed",
"blocked": "Blocked",
"skipped": "Skipped",
"failed": "Failed"
},
"tabs": {
"overview": "Overview",
"flowchart": "Flowchart",
"files": "Files"
},
"overview": {
"description": "Description",
"preAnalysis": "Pre-Analysis Steps",
"implementationSteps": "Implementation Steps",
"modificationPoints": "Modification Points",
"dependsOn": "Depends on",
"empty": "No overview information available for this task."
},
"flowchart": {
"empty": "No flowchart available for this task."
},
"files": {
"empty": "No files specified for this task."
}
}
}

View File

@@ -123,5 +123,11 @@
"title": "未找到规则",
"message": "添加规则以强制执行代码质量标准。"
}
},
"executionDetails": "执行详情",
"tabs": {
"prompt": "提示词",
"output": "输出",
"details": "详情"
}
}

View File

@@ -19,6 +19,9 @@ import liteTasks from './lite-tasks.json';
import projectOverview from './project-overview.json';
import reviewSession from './review-session.json';
import sessionDetail from './session-detail.json';
import skills from './skills.json';
import cliManager from './cli-manager.json';
import mcpManager from './mcp-manager.json';
/**
* Flattens nested JSON object to dot-separated keys
@@ -45,20 +48,23 @@ function flattenMessages(obj: Record<string, unknown>, prefix = ''): Record<stri
* Consolidated and flattened Chinese messages
*/
export default {
...flattenMessages(common),
...flattenMessages(navigation),
...flattenMessages(sessions),
...flattenMessages(issues),
...flattenMessages(home),
...flattenMessages(orchestrator),
...flattenMessages(loops),
...flattenMessages(commands),
...flattenMessages(memory),
...flattenMessages(settings),
...flattenMessages(fixSession),
...flattenMessages(history),
...flattenMessages(liteTasks),
...flattenMessages(projectOverview),
...flattenMessages(reviewSession),
...flattenMessages(sessionDetail),
...flattenMessages(common, 'common'),
...flattenMessages(navigation, 'navigation'),
...flattenMessages(sessions, 'sessions'),
...flattenMessages(issues, 'issues'),
...flattenMessages(home, 'home'),
...flattenMessages(orchestrator, 'orchestrator'),
...flattenMessages(loops, 'loops'),
...flattenMessages(commands, 'commands'),
...flattenMessages(memory, 'memory'),
...flattenMessages(settings, 'settings'),
...flattenMessages(fixSession, 'fixSession'),
...flattenMessages(history, 'history'),
...flattenMessages(liteTasks, 'liteTasks'),
...flattenMessages(projectOverview, 'projectOverview'),
...flattenMessages(reviewSession, 'reviewSession'),
...flattenMessages(sessionDetail, 'sessionDetail'),
...flattenMessages(skills, 'skills'),
...flattenMessages(cliManager), // No prefix - has cliEndpoints, cliInstallations, etc. as top-level keys
...flattenMessages(mcpManager, 'mcp'),
} as Record<string, string>;

View File

@@ -50,5 +50,34 @@
"updated": "更新时间",
"tasks": "任务",
"description": "描述"
},
"taskDrawer": {
"status": {
"pending": "待处理",
"inProgress": "进行中",
"completed": "已完成",
"blocked": "已阻塞",
"skipped": "已跳过",
"failed": "失败"
},
"tabs": {
"overview": "概览",
"flowchart": "流程图",
"files": "文件"
},
"overview": {
"description": "描述",
"preAnalysis": "前置分析步骤",
"implementationSteps": "实现步骤",
"modificationPoints": "修改点",
"dependsOn": "依赖于",
"empty": "此任务暂无概览信息。"
},
"flowchart": {
"empty": "此任务暂无流程图。"
},
"files": {
"empty": "此任务未指定文件。"
}
}
}

View File

@@ -116,7 +116,7 @@ export function FixSessionPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div className="h-8 w-48 rounded bg-muted animate-pulse" />
</div>
@@ -158,7 +158,7 @@ export function FixSessionPage() {
</p>
<Button onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
</div>
);
@@ -170,7 +170,7 @@ export function FixSessionPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div className="flex-1">
<h1 className="text-2xl font-semibold text-foreground">{session.session_id}</h1>

View File

@@ -17,6 +17,7 @@ import {
import { cn } from '@/lib/utils';
import { useHistory } from '@/hooks/useHistory';
import { ConversationCard } from '@/components/shared/ConversationCard';
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
@@ -35,6 +36,7 @@ import {
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/Dropdown';
import type { CliExecution } from '@/lib/api';
/**
* HistoryPage component - Display CLI execution history
@@ -46,6 +48,8 @@ export function HistoryPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [deleteType, setDeleteType] = React.useState<'single' | 'tool' | 'all' | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<string | null>(null);
const [selectedExecution, setSelectedExecution] = React.useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = React.useState(false);
const {
executions,
@@ -78,6 +82,12 @@ export function HistoryPage() {
const hasActiveFilters = searchQuery.length > 0 || toolFilter !== undefined;
// Card click handler - open execution details panel
const handleCardClick = (execution: CliExecution) => {
setSelectedExecution(execution.id);
setIsPanelOpen(true);
};
// Delete handlers
const handleDeleteClick = (id: string) => {
setDeleteType('single');
@@ -263,6 +273,7 @@ export function HistoryPage() {
<ConversationCard
key={execution.id}
execution={execution}
onClick={handleCardClick}
onDelete={handleDeleteClick}
actionsDisabled={isDeleting}
/>
@@ -270,6 +281,13 @@ export function HistoryPage() {
</div>
)}
{/* CLI Stream Panel */}
<CliStreamPanel
executionId={selectedExecution || ''}
open={isPanelOpen}
onOpenChange={setIsPanelOpen}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>

View File

@@ -88,7 +88,7 @@ export function LiteTaskDetailPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
</div>
@@ -126,7 +126,7 @@ export function LiteTaskDetailPage() {
</p>
<Button onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
</div>
);
@@ -143,7 +143,7 @@ export function LiteTaskDetailPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">

View File

@@ -1,10 +1,9 @@
// ========================================
// LiteTasksPage Component
// ========================================
// Lite-plan and lite-fix task list page with flowchart rendering
// Lite-plan and lite-fix task list page with TaskDrawer
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
ArrowLeft,
@@ -18,12 +17,17 @@ import {
Activity,
Repeat,
MessageCircle,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { useLiteTasks } from '@/hooks/useLiteTasks';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TaskDrawer } from '@/components/shared/TaskDrawer';
import type { LiteTask, LiteTaskSession } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
type LiteTaskTab = 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
@@ -37,13 +41,15 @@ function getI18nText(label: string | { en?: string; zh?: string } | undefined, f
}
/**
* LiteTasksPage component - Display lite-plan and lite-fix sessions
* LiteTasksPage component - Display lite-plan and lite-fix sessions with expandable tasks
*/
export function LiteTasksPage() {
const navigate = useNavigate();
const { formatMessage } = useIntl();
const { litePlan, liteFix, multiCliPlan, isLoading, error, refetch } = useLiteTasks();
const [activeTab, setActiveTab] = React.useState<LiteTaskTab>('lite-plan');
const [expandedSessionId, setExpandedSessionId] = React.useState<string | null>(null);
const [selectedTask, setSelectedTask] = React.useState<LiteTask | null>(null);
const handleBack = () => {
navigate('/sessions');
@@ -66,53 +72,102 @@ export function LiteTasksPage() {
return statusColors[status || ''] || 'secondary';
};
// Render lite task card
const renderLiteTaskCard = (session: { id: string; type: string; createdAt?: string; tasks?: unknown[] }) => {
// Render lite task card with expandable tasks
const renderLiteTaskCard = (session: LiteTaskSession) => {
const isLitePlan = session.type === 'lite-plan';
const taskCount = session.tasks?.length || 0;
const isExpanded = expandedSessionId === session.id;
return (
<Card
key={session.id}
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/lite-tasks/${session.id}`)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
<div key={session.id}>
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => setExpandedSessionId(isExpanded ? null : session.id)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
</div>
</div>
<Badge variant={isLitePlan ? 'secondary' : 'warning'} className="gap-1 flex-shrink-0">
{isLitePlan ? <FileEdit className="h-3 w-3" /> : <Wrench className="h-3 w-3" />}
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
</Badge>
</div>
<Badge variant={isLitePlan ? 'info' : 'warning'} className="gap-1">
{isLitePlan ? <FileEdit className="h-3 w-3" /> : <Wrench className="h-3 w-3" />}
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{session.createdAt && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{session.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{new Date(session.createdAt).toLocaleDateString()}
</span>
)}
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{new Date(session.createdAt).toLocaleDateString()}
<ListChecks className="h-3.5 w-3.5" />
{taskCount} {formatMessage({ id: 'session.tasks' })}
</span>
)}
<span className="flex items-center gap-1">
<ListChecks className="h-3.5 w-3.5" />
{taskCount} {formatMessage({ id: 'session.tasks' })}
</span>
</div>
</CardContent>
</Card>
{/* Expanded tasks list */}
{isExpanded && session.tasks && session.tasks.length > 0 && (
<div className="mt-2 ml-6 space-y-2 pb-2">
{session.tasks.map((task, index) => {
const taskStatusColor = task.status === 'completed' ? 'success' :
task.status === 'in_progress' ? 'warning' :
task.status === 'failed' ? 'destructive' : 'secondary';
return (
<Card
key={task.id || index}
className="cursor-pointer hover:shadow-sm hover:border-primary/50 transition-all border-border"
onClick={(e) => {
e.stopPropagation();
setSelectedTask(task);
}}
>
<CardContent className="p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-muted-foreground">
{task.task_id || `#${index + 1}`}
</span>
<Badge variant={taskStatusColor as 'success' | 'warning' | 'destructive' | 'secondary'} className="text-xs">
{task.status}
</Badge>
</div>
<h4 className="text-sm font-medium text-foreground">
{task.title || 'Untitled Task'}
</h4>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{task.description}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
);
};
// Render multi-cli plan card
const renderMultiCliCard = (session: {
id: string;
metadata?: Record<string, unknown>;
latestSynthesis?: { title?: string | { en?: string; zh?: string }; status?: string };
roundCount?: number;
status?: string;
createdAt?: string;
}) => {
const renderMultiCliCard = (session: LiteTaskSession) => {
const metadata = session.metadata || {};
const latestSynthesis = session.latestSynthesis || {};
const roundCount = (metadata.roundId as number) || session.roundCount || 1;
@@ -127,14 +182,23 @@ export function LiteTasksPage() {
<Card
key={session.id}
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => navigate(`/lite-tasks/${session.id}`)}
onClick={() => setExpandedSessionId(expandedSessionId === session.id ? null : session.id)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">
{expandedSessionId === session.id ? (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
</div>
</div>
<Badge variant="info" className="gap-1">
<Badge variant="secondary" className="gap-1 flex-shrink-0">
<MessagesSquare className="h-3 w-3" />
{formatMessage({ id: 'liteTasks.type.multiCli' })}
</Badge>
@@ -171,7 +235,7 @@ export function LiteTasksPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
</div>
@@ -205,7 +269,7 @@ export function LiteTasksPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
@@ -295,6 +359,13 @@ export function LiteTasksPage() {
)}
</TabsContent>
</Tabs>
{/* TaskDrawer */}
<TaskDrawer
task={selectedTask}
isOpen={!!selectedTask}
onClose={() => setSelectedTask(null)}
/>
</div>
);
}

View File

@@ -209,7 +209,7 @@ export function ReviewSessionPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
</div>
@@ -247,7 +247,7 @@ export function ReviewSessionPage() {
</p>
<Button onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
</div>
);
@@ -263,7 +263,7 @@ export function ReviewSessionPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">

View File

@@ -18,9 +18,11 @@ import { useSessionDetail } from '@/hooks/useSessionDetail';
import { TaskListTab } from './session-detail/TaskListTab';
import { ContextTab } from './session-detail/ContextTab';
import { SummaryTab } from './session-detail/SummaryTab';
import { TaskDrawer } from '@/components/shared/TaskDrawer';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import type { TaskData } from '@/types/store';
type TabValue = 'tasks' | 'context' | 'summary';
@@ -33,6 +35,7 @@ export function SessionDetailPage() {
const { formatMessage } = useIntl();
const { sessionDetail, isLoading, error, refetch } = useSessionDetail(sessionId!);
const [activeTab, setActiveTab] = React.useState<TabValue>('tasks');
const [selectedTask, setSelectedTask] = React.useState<TaskData | null>(null);
const handleBack = () => {
navigate('/sessions');
@@ -45,7 +48,7 @@ export function SessionDetailPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" disabled>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div className="h-8 w-64 rounded bg-muted animate-pulse" />
</div>
@@ -83,7 +86,7 @@ export function SessionDetailPage() {
</p>
<Button onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
</div>
);
@@ -100,7 +103,7 @@ export function SessionDetailPage() {
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.back' })}
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
@@ -158,7 +161,7 @@ export function SessionDetailPage() {
</TabsList>
<TabsContent value="tasks" className="mt-4">
<TaskListTab session={session} />
<TaskListTab session={session} onTaskClick={setSelectedTask} />
</TabsContent>
<TabsContent value="context" className="mt-4">
@@ -179,6 +182,13 @@ export function SessionDetailPage() {
<p className="text-sm text-muted-foreground">{session.description}</p>
</div>
)}
{/* TaskDrawer */}
<TaskDrawer
task={selectedTask}
isOpen={!!selectedTask}
onClose={() => setSelectedTask(null)}
/>
</div>
);
}

View File

@@ -8,7 +8,6 @@ import { useIntl } from 'react-intl';
import {
Settings,
Moon,
Sun,
Bell,
Cpu,
RefreshCw,
@@ -28,6 +27,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { ThemeSelector } from '@/components/shared/ThemeSelector';
import { useTheme } from '@/hooks';
import { useHooks, useRules, useToggleHook, useToggleRule } from '@/hooks';
import { useConfigStore, selectCliTools, selectDefaultCliTool, selectUserPreferences } from '@/stores/configStore';
@@ -430,39 +430,33 @@ export function SettingsPage() {
<Moon className="w-5 h-5" />
{formatMessage({ id: 'settings.sections.appearance' })}
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-6">
{/* Multi-Theme Selector */}
<div>
<p className="font-medium text-foreground mb-1">
{formatMessage({ id: 'settings.appearance.theme' })}
</p>
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'settings.appearance.description' })}
</p>
<ThemeSelector />
</div>
{/* System Theme Toggle (Backward Compatibility) */}
<div className="flex items-center justify-between pt-4 border-t border-border">
<div>
<p className="font-medium text-foreground">{formatMessage({ id: 'settings.appearance.theme' })}</p>
<p className="font-medium text-foreground"></p>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'settings.appearance.description' })}
使/
</p>
</div>
<div className="flex gap-2">
<Button
variant={theme === 'light' ? 'default' : 'outline'}
size="sm"
onClick={() => setTheme('light')}
>
<Sun className="w-4 h-4 mr-2" />
{formatMessage({ id: 'settings.appearance.themeOptions.light' })}
</Button>
<Button
variant={theme === 'dark' ? 'default' : 'outline'}
size="sm"
onClick={() => setTheme('dark')}
>
<Moon className="w-4 h-4 mr-2" />
{formatMessage({ id: 'settings.appearance.themeOptions.dark' })}
</Button>
<Button
variant={theme === 'system' ? 'default' : 'outline'}
size="sm"
onClick={() => setTheme('system')}
>
{formatMessage({ id: 'settings.appearance.themeOptions.system' })}
</Button>
</div>
<Button
variant={theme === 'system' ? 'default' : 'outline'}
size="sm"
onClick={() => setTheme('system')}
>
{formatMessage({ id: 'settings.appearance.themeOptions.system' })}
</Button>
</div>
</div>
</Card>

View File

@@ -13,10 +13,11 @@ import {
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { SessionMetadata } from '@/types/store';
import type { SessionMetadata, TaskData } from '@/types/store';
export interface TaskListTabProps {
session: SessionMetadata;
onTaskClick?: (task: TaskData) => void;
}
// Status configuration
@@ -51,7 +52,7 @@ const taskStatusConfig: Record<string, { label: string; variant: 'default' | 'se
/**
* TaskListTab component - Display tasks in a list format
*/
export function TaskListTab({ session }: TaskListTabProps) {
export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
const { formatMessage } = useIntl();
const tasks = session.tasks || [];
@@ -104,7 +105,8 @@ export function TaskListTab({ session }: TaskListTabProps) {
return (
<Card
key={task.task_id || index}
className="hover:shadow-sm transition-shadow"
className={`hover:shadow-sm transition-shadow ${onTaskClick ? 'cursor-pointer hover:shadow-md' : ''}`}
onClick={() => onTaskClick?.(task as TaskData)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">

View File

@@ -22,7 +22,7 @@ import {
HelpPage,
NotFoundPage,
LiteTasksPage,
LiteTaskDetailPage,
// LiteTaskDetailPage removed - now using TaskDrawer instead
ReviewSessionPage,
McpManagerPage,
EndpointsPage,
@@ -62,10 +62,7 @@ const routes: RouteObject[] = [
path: 'lite-tasks',
element: <LiteTasksPage />,
},
{
path: 'lite-tasks/:sessionId',
element: <LiteTaskDetailPage />,
},
// /lite-tasks/:sessionId route removed - now using TaskDrawer
{
path: 'project',
element: <ProjectOverviewPage />,
@@ -142,7 +139,7 @@ export const ROUTES = {
FIX_SESSION: '/sessions/:sessionId/fix',
REVIEW_SESSION: '/sessions/:sessionId/review',
LITE_TASKS: '/lite-tasks',
LITE_TASK_DETAIL: '/lite-tasks/:sessionId',
// LITE_TASK_DETAIL removed - now using TaskDrawer
PROJECT: '/project',
HISTORY: '/history',
ORCHESTRATOR: '/orchestrator',

View File

@@ -5,8 +5,9 @@
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import type { AppStore, Theme, Locale, ViewMode, SessionFilter, LiteTaskType } from '../types/store';
import type { AppStore, Theme, ColorScheme, Locale, ViewMode, SessionFilter, LiteTaskType } from '../types/store';
import { getInitialLocale, updateIntl } from '../lib/i18n';
import { getThemeId } from '../lib/theme';
// Helper to resolve system theme
const getSystemTheme = (): 'light' | 'dark' => {
@@ -27,6 +28,7 @@ const initialState = {
// Theme
theme: 'system' as Theme,
resolvedTheme: 'light' as 'light' | 'dark',
colorScheme: 'blue' as ColorScheme, // New: default to blue scheme
// Locale
locale: getInitialLocale() as Locale,
@@ -61,9 +63,23 @@ export const useAppStore = create<AppStore>()(
// Apply theme to document
if (typeof document !== 'undefined') {
const { colorScheme } = get();
const themeId = getThemeId(colorScheme, resolved);
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme', themeId);
}
},
setColorScheme: (colorScheme: ColorScheme) => {
set({ colorScheme }, false, 'setColorScheme');
// Apply color scheme to document
if (typeof document !== 'undefined') {
const { resolvedTheme } = get();
const themeId = getThemeId(colorScheme, resolvedTheme);
document.documentElement.setAttribute('data-theme', themeId);
document.documentElement.setAttribute('data-color-scheme', colorScheme);
}
},
@@ -131,6 +147,7 @@ export const useAppStore = create<AppStore>()(
// Only persist theme and locale preferences
partialize: (state) => ({
theme: state.theme,
colorScheme: state.colorScheme,
locale: state.locale,
sidebarCollapsed: state.sidebarCollapsed,
}),
@@ -139,10 +156,11 @@ export const useAppStore = create<AppStore>()(
if (state) {
const resolved = resolveTheme(state.theme);
state.resolvedTheme = resolved;
const themeId = getThemeId(state.colorScheme, resolved);
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme', themeId);
}
}
// Apply locale on rehydration
@@ -164,9 +182,10 @@ if (typeof window !== 'undefined') {
if (state.theme === 'system') {
const resolved = getSystemTheme();
useAppStore.setState({ resolvedTheme: resolved });
const themeId = getThemeId(state.colorScheme, resolved);
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.setAttribute('data-theme', themeId);
}
});
}

View File

@@ -0,0 +1,223 @@
// ========================================
// CLI Stream Store
// ========================================
// Zustand store for managing CLI streaming output
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
// ========== Types ==========
/**
* Output line type for CLI streaming
*/
export interface CliOutputLine {
type: 'stdout' | 'stderr' | 'metadata' | 'thought' | 'system' | 'tool_call';
content: string;
timestamp: number;
}
/**
* CLI execution status
*/
export type CliExecutionStatus = 'running' | 'completed' | 'error';
/**
* CLI execution state
*/
export interface CliExecutionState {
tool: string;
mode: string;
status: CliExecutionStatus;
output: CliOutputLine[];
startTime: number;
endTime?: number;
recovered?: boolean;
}
/**
* CLI stream state interface
*/
interface CliStreamState {
outputs: Record<string, CliOutputLine[]>;
executions: Record<string, CliExecutionState>;
currentExecutionId: string | null;
// Legacy methods
addOutput: (executionId: string, line: CliOutputLine) => void;
clearOutputs: (executionId: string) => void;
getOutputs: (executionId: string) => CliOutputLine[];
// Multi-execution methods
getAllExecutions: () => CliExecutionState[];
upsertExecution: (executionId: string, exec: Partial<CliExecutionState> & { tool?: string; mode?: string }) => void;
removeExecution: (executionId: string) => void;
setCurrentExecution: (executionId: string | null) => void;
}
// ========== Constants ==========
/**
* Maximum number of output lines to keep per execution
* Prevents memory issues for long-running executions
*/
const MAX_OUTPUT_LINES = 5000;
// ========== Store ==========
/**
* Zustand store for CLI streaming output
*
* @remarks
* Manages streaming output from CLI executions in memory.
* Each execution has its own output array, accessible by executionId.
*
* @example
* ```tsx
* const addOutput = useCliStreamStore(state => state.addOutput);
* addOutput('exec-123', { type: 'stdout', content: 'Hello', timestamp: Date.now() });
* ```
*/
export const useCliStreamStore = create<CliStreamState>()(
devtools(
(set, get) => ({
outputs: {},
executions: {},
currentExecutionId: null,
addOutput: (executionId: string, line: CliOutputLine) => {
set((state) => {
const current = state.outputs[executionId] || [];
const updated = [...current, line];
// Trim if too long to prevent memory issues
if (updated.length > MAX_OUTPUT_LINES) {
return {
outputs: {
...state.outputs,
[executionId]: updated.slice(-MAX_OUTPUT_LINES),
},
};
}
return {
outputs: {
...state.outputs,
[executionId]: updated,
},
};
}, false, 'cliStream/addOutput');
// Also update in executions
const state = get();
if (state.executions[executionId]) {
set((state) => ({
executions: {
...state.executions,
[executionId]: {
...state.executions[executionId],
output: [...state.executions[executionId].output, line],
},
},
}), false, 'cliStream/updateExecutionOutput');
}
},
clearOutputs: (executionId: string) => {
set(
(state) => ({
outputs: {
...state.outputs,
[executionId]: [],
},
}),
false,
'cliStream/clearOutputs'
);
},
getOutputs: (executionId: string) => {
return get().outputs[executionId] || [];
},
// Multi-execution methods
getAllExecutions: () => {
return Object.values(get().executions);
},
upsertExecution: (executionId: string, exec: Partial<CliExecutionState> & { tool?: string; mode?: string }) => {
set((state) => {
const existing = state.executions[executionId];
const updated: CliExecutionState = existing
? { ...existing, ...exec }
: {
tool: exec.tool || 'cli',
mode: exec.mode || 'analysis',
status: exec.status || 'running',
output: exec.output || [],
startTime: exec.startTime || Date.now(),
endTime: exec.endTime,
recovered: exec.recovered,
};
return {
executions: {
...state.executions,
[executionId]: updated,
},
};
}, false, 'cliStream/upsertExecution');
},
removeExecution: (executionId: string) => {
set((state) => {
const newExecutions = { ...state.executions };
delete newExecutions[executionId];
return {
executions: newExecutions,
currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId,
};
}, false, 'cliStream/removeExecution');
},
setCurrentExecution: (executionId: string | null) => {
set({ currentExecutionId: executionId }, false, 'cliStream/setCurrentExecution');
},
}),
{ name: 'CliStreamStore' }
)
);
// ========== Selectors ==========
/**
* Selector for getting outputs by execution ID
*/
export const selectOutputs = (state: CliStreamState, executionId: string) =>
state.outputs[executionId] || [];
/**
* Selector for getting addOutput action
*/
export const selectAddOutput = (state: CliStreamState) => state.addOutput;
/**
* Selector for getting clearOutputs action
*/
export const selectClearOutputs = (state: CliStreamState) => state.clearOutputs;
/**
* Selector for getting all executions
*/
export const selectAllExecutions = (state: CliStreamState) => state.executions;
/**
* Selector for getting current execution ID
*/
export const selectCurrentExecutionId = (state: CliStreamState) => state.currentExecutionId;
/**
* Selector for getting active execution count
*/
export const selectActiveExecutionCount = (state: CliStreamState) =>
Object.values(state.executions).filter(e => e.status === 'running').length;

View File

@@ -0,0 +1,115 @@
/**
* Typography System
* Defines font utilities for consistent typographic styles across the application
* Fonts are loaded via Google Fonts CDN in index.css
*/
/* Monospace font utilities */
.font-mono {
font-family: 'JetBrains Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
/* Tabular numbers - ensures digits are aligned in columns */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
.diagonal-nums {
font-variant-numeric: diagonal-nums;
}
.stacked-fractions {
font-variant-numeric: stacked-fractions;
}
/* Common utility combinations */
.font-mono-tabular {
font-family: 'JetBrains Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-variant-numeric: tabular-nums;
}
/* Text sizing utilities */
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
/* Font weight utilities */
.font-light {
font-weight: 300;
}
.font-normal {
font-weight: 400;
}
.font-medium {
font-weight: 500;
}
.font-semibold {
font-weight: 600;
}
.font-bold {
font-weight: 700;
}
/* Line height utilities for specific use cases */
.leading-tight {
line-height: 1.25;
}
.leading-normal {
line-height: 1.5;
}
.leading-relaxed {
line-height: 1.625;
}
.leading-loose {
line-height: 2;
}
/* Tracking (letter spacing) utilities */
.tracking-tight {
letter-spacing: -0.025em;
}
.tracking-normal {
letter-spacing: 0;
}
.tracking-wide {
letter-spacing: 0.025em;
}
.tracking-wider {
letter-spacing: 0.05em;
}

View File

@@ -6,6 +6,7 @@
// ========== App Store Types ==========
export type Theme = 'light' | 'dark' | 'system';
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
export type Locale = 'en' | 'zh';
export type ViewMode = 'sessions' | 'liteTasks' | 'project-overview' | 'sessionDetail' | 'liteTaskDetail' | 'loop-monitor' | 'issue-manager' | 'orchestrator';
export type SessionFilter = 'all' | 'active' | 'archived';
@@ -15,6 +16,7 @@ export interface AppState {
// Theme
theme: Theme;
resolvedTheme: 'light' | 'dark';
colorScheme: ColorScheme; // New: 4 color scheme options (blue/green/orange/purple)
// Locale
locale: Locale;
@@ -39,6 +41,7 @@ export interface AppActions {
// Theme actions
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
setColorScheme: (scheme: ColorScheme) => void; // New: set color scheme
// Locale actions
setLocale: (locale: Locale) => void;
@@ -96,6 +99,7 @@ export interface SessionMetadata {
created_at: string;
updated_at?: string;
location: 'active' | 'archived';
path?: string; // Full filesystem path to session directory (from backend)
has_plan?: boolean;
plan_updated_at?: string;
has_review?: boolean;