feat: initialize monorepo with package.json for CCW workflow platform

This commit is contained in:
catlog22
2026-02-03 14:42:20 +08:00
parent 5483a72e9f
commit 39b80b3386
267 changed files with 99597 additions and 2658 deletions

View File

@@ -14,7 +14,6 @@ import {
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Checkbox } from '@/components/ui/Checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
@@ -22,7 +21,7 @@ import { Label } from '@/components/ui/Label';
import { AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useNotificationStore } from '@/stores';
import type { AskQuestionPayload, Question, QuestionType } from '@/types/store';
import type { AskQuestionPayload, Question } from '@/types/store';
// ========== Types ==========

View File

@@ -12,7 +12,6 @@ import {
Trash2,
Settings,
CheckCircle2,
XCircle,
MoreVertical,
Link as LinkIcon,
} from 'lucide-react';
@@ -35,7 +34,6 @@ import {
} from '@/hooks/useApiSettings';
import { useNotifications } from '@/hooks/useNotifications';
import type { CliSettingsEndpoint } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
@@ -163,7 +161,7 @@ export function CliSettingsList({
onEditCliSettings,
}: CliSettingsListProps) {
const { formatMessage } = useIntl();
const { showNotification } = useNotifications();
const { error } = useNotifications();
const [searchQuery, setSearchQuery] = useState('');
const {
@@ -204,8 +202,8 @@ export function CliSettingsList({
if (confirm(confirmMessage)) {
try {
await deleteCliSettings(endpointId);
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.cliSettings.deleteError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.cliSettings.deleteError' }));
}
}
};
@@ -213,8 +211,8 @@ export function CliSettingsList({
const handleToggleEnabled = async (endpointId: string, enabled: boolean) => {
try {
await toggleCliSettings(endpointId, enabled);
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.cliSettings.toggleError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.cliSettings.toggleError' }));
}
};

View File

@@ -39,7 +39,7 @@ type ModeType = 'provider-based' | 'direct';
export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModalProps) {
const { formatMessage } = useIntl();
const { showNotification } = useNotifications();
const { error } = useNotifications();
const isEditing = !!cliSettings;
// Mutations
@@ -213,8 +213,8 @@ export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModa
}
onClose();
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.cliSettings.saveError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.cliSettings.saveError' }));
}
};

View File

@@ -139,7 +139,7 @@ export function EndpointList({
onEditEndpoint,
}: EndpointListProps) {
const { formatMessage } = useIntl();
const { showNotification } = useNotifications();
const { error } = useNotifications();
const [searchQuery, setSearchQuery] = useState('');
const [showDisabledOnly, setShowDisabledOnly] = useState(false);
const [showCachedOnly, setShowCachedOnly] = useState(false);
@@ -176,8 +176,8 @@ export function EndpointList({
if (window.confirm(confirmMessage)) {
try {
await deleteEndpoint(endpointId);
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.endpoints.deleteError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.endpoints.deleteError' }));
}
}
};
@@ -185,8 +185,8 @@ export function EndpointList({
const handleToggleEnabled = async (endpointId: string, enabled: boolean) => {
try {
await updateEndpoint(endpointId, { enabled });
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.endpoints.toggleError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.endpoints.toggleError' }));
}
};

View File

@@ -105,7 +105,7 @@ function FilePatternInput({ value, onChange, placeholder }: FilePatternInputProp
export function EndpointModal({ open, onClose, endpoint }: EndpointModalProps) {
const { formatMessage } = useIntl();
const { showNotification } = useNotifications();
const { error } = useNotifications();
const isEditing = !!endpoint;
// Mutations
@@ -213,8 +213,8 @@ export function EndpointModal({ open, onClose, endpoint }: EndpointModalProps) {
}
onClose();
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.endpoints.saveError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.endpoints.saveError' }));
}
};

View File

@@ -29,7 +29,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { Badge } from '@/components/ui/Badge';
import { useProviders, useUpdateProvider } from '@/hooks/useApiSettings';
import { useNotifications } from '@/hooks/useNotifications';
import type { ModelDefinition } from '@/lib/api';
// ========== Types ==========
@@ -164,7 +163,7 @@ function ModelEntryRow({
export function ManageModelsModal({ open, onClose, providerId }: ManageModelsModalProps) {
const { formatMessage } = useIntl();
const { showNotification } = useNotifications();
const { success, error } = useNotifications();
const { providers } = useProviders();
const { updateProvider, isUpdating } = useUpdateProvider();
@@ -259,10 +258,10 @@ export function ManageModelsModal({ open, onClose, providerId }: ManageModelsMod
})),
});
showNotification('success', formatMessage({ id: 'apiSettings.providers.actions.save' }) + ' success');
success(formatMessage({ id: 'apiSettings.providers.actions.save' }));
onClose();
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.providers.saveError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.providers.saveError' }));
}
};

View File

@@ -46,13 +46,6 @@ interface ApiKeyFormEntry {
enabled: boolean;
}
interface HealthCheckSettings {
enabled: boolean;
intervalSeconds: number;
cooldownSeconds: number;
failureThreshold: number;
}
// ========== Helper Components ==========
interface ApiKeyEntryRowProps {
@@ -147,7 +140,7 @@ function ApiKeyEntryRow({
export function MultiKeySettingsModal({ open, onClose, providerId }: MultiKeySettingsModalProps) {
const { formatMessage } = useIntl();
const { showNotification } = useNotifications();
const { success, error } = useNotifications();
const { providers } = useProviders();
const { updateProvider, isUpdating } = useUpdateProvider();
@@ -256,10 +249,10 @@ export function MultiKeySettingsModal({ open, onClose, providerId }: MultiKeySet
} : undefined,
});
showNotification('success', formatMessage({ id: 'apiSettings.providers.actions.save' }) + ' success');
success(formatMessage({ id: 'apiSettings.providers.actions.save' }));
onClose();
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.providers.saveError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.providers.saveError' }));
}
};

View File

@@ -189,7 +189,7 @@ export function ProviderList({
onManageModels,
}: ProviderListProps) {
const { formatMessage } = useIntl();
const { showNotification } = useNotifications();
const { error } = useNotifications();
const [searchQuery, setSearchQuery] = useState('');
const [showDisabledOnly, setShowDisabledOnly] = useState(false);
@@ -224,8 +224,8 @@ export function ProviderList({
if (window.confirm(confirmMessage)) {
try {
await deleteProvider(providerId);
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.providers.deleteError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.providers.deleteError' }));
}
}
};
@@ -233,8 +233,8 @@ export function ProviderList({
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
try {
await updateProvider(providerId, { enabled });
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.providers.toggleError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.providers.toggleError' }));
}
};
@@ -245,8 +245,8 @@ export function ProviderList({
// Trigger health check refresh
await triggerHealthCheck(providerId);
}
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.providers.testError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.providers.testError' }));
}
};

View File

@@ -151,7 +151,7 @@ function ApiKeyEntryRow({
export function ProviderModal({ open, onClose, provider }: ProviderModalProps) {
const { formatMessage } = useIntl();
const { showNotification } = useNotifications();
const { error } = useNotifications();
const isEditing = !!provider;
// Mutations
@@ -420,8 +420,8 @@ export function ProviderModal({ open, onClose, provider }: ProviderModalProps) {
}
onClose();
} catch (error) {
showNotification('error', formatMessage({ id: 'apiSettings.providers.saveError' }));
} catch (err) {
error(formatMessage({ id: 'apiSettings.providers.saveError' }));
}
};

View File

@@ -0,0 +1,131 @@
// ========================================
// ActivityLineChart Component
// ========================================
// Recharts line chart visualizing activity timeline
import { useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import type { ActivityTimelineData } from '@/hooks/useActivityTimeline';
import { getChartColors } from '@/lib/chartTheme';
export interface ActivityLineChartProps {
/** Activity timeline data */
data: ActivityTimelineData[];
/** Optional CSS class name */
className?: string;
/** Chart height (default: 300) */
height?: number;
/** Optional label for the chart */
title?: string;
}
/**
* Custom tooltip component for the line chart
*/
function CustomTooltip({ active, payload, label }: any) {
if (active && payload && payload.length) {
return (
<div className="rounded bg-card p-3 shadow-md border border-border">
<p className="text-sm font-medium text-foreground mb-2">{label}</p>
{payload.map((item: any, index: number) => (
<p key={index} className="text-sm" style={{ color: item.color }}>
{item.name}: {item.value}
</p>
))}
</div>
);
}
return null;
}
/**
* Format date for X-axis display (MM/DD)
*/
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return `${date.getMonth() + 1}/${date.getDate()}`;
}
/**
* ActivityLineChart - Visualizes sessions and tasks over time
*
* @example
* ```tsx
* const { data, isLoading } = useActivityTimeline();
* return <ActivityLineChart data={data} />;
* ```
*/
export function ActivityLineChart({
data,
className = '',
height = 300,
title,
}: ActivityLineChartProps) {
const colors = useMemo(() => getChartColors(), []);
const chartData = useMemo(() => {
return data.map((item) => ({
...item,
displayDate: formatDate(item.date),
}));
}, [data]);
return (
<div
className={`w-full ${className}`}
role="img"
aria-label="Activity timeline line chart showing sessions and tasks over time"
>
{title && <h3 className="text-lg font-semibold text-foreground mb-4">{title}</h3>}
<ResponsiveContainer width="100%" height={height}>
<LineChart
data={chartData}
margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
accessibilityLayer
>
<CartesianGrid strokeDasharray="3 3" stroke={colors.muted} />
<XAxis
dataKey="displayDate"
stroke={colors.muted}
style={{ fontSize: '12px' }}
/>
<YAxis stroke={colors.muted} style={{ fontSize: '12px' }} />
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: '14px' }}
iconType="line"
/>
<Line
type="monotone"
dataKey="sessions"
stroke={colors.primary}
strokeWidth={2}
dot={{ fill: colors.primary, r: 4 }}
activeDot={{ r: 6 }}
name="Sessions"
/>
<Line
type="monotone"
dataKey="tasks"
stroke={colors.success}
strokeWidth={2}
dot={{ fill: colors.success, r: 4 }}
activeDot={{ r: 6 }}
name="Tasks"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
export default ActivityLineChart;

View File

@@ -0,0 +1,111 @@
// ========================================
// ChartSkeleton Component
// ========================================
// Loading skeleton for chart components
export interface ChartSkeletonProps {
/** Skeleton type: pie, line, or bar */
type?: 'pie' | 'line' | 'bar';
/** Height in pixels (default: 300) */
height?: number;
/** Optional CSS class name */
className?: string;
}
/**
* ChartSkeleton - Animated loading skeleton for chart components
*
* @example
* ```tsx
* const { data, isLoading } = useWorkflowStatusCounts();
*
* if (isLoading) return <ChartSkeleton type="pie" />;
* return <WorkflowStatusPieChart data={data} />;
* ```
*/
export function ChartSkeleton({
type = 'bar',
height = 300,
className = '',
}: ChartSkeletonProps) {
return (
<div className={`w-full animate-pulse ${className}`} style={{ height }}>
{type === 'pie' && <PieSkeleton height={height} />}
{type === 'line' && <LineSkeleton height={height} />}
{type === 'bar' && <BarSkeleton height={height} />}
</div>
);
}
/**
* Pie chart skeleton
*/
function PieSkeleton({ height }: { height: number }) {
const radius = Math.min(height * 0.3, 80);
return (
<div className="flex flex-col items-center justify-center h-full p-4">
<div
className="rounded-full bg-muted"
style={{ width: radius * 2, height: radius * 2 }}
/>
<div className="flex gap-4 mt-6">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-muted" />
<div className="w-12 h-3 rounded bg-muted" />
</div>
))}
</div>
</div>
);
}
/**
* Line chart skeleton
*/
function LineSkeleton({ height: _height }: { height: number }) {
return (
<div className="flex flex-col h-full p-4">
<div className="flex-1 flex items-end gap-2">
{[40, 65, 45, 80, 55, 70, 60].map((h, i) => (
<div
key={i}
className="flex-1 bg-muted rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
<div className="flex justify-between mt-4">
{[1, 2, 3, 4, 5, 6, 7].map((i) => (
<div key={i} className="w-8 h-3 rounded bg-muted" />
))}
</div>
</div>
);
}
/**
* Bar chart skeleton
*/
function BarSkeleton({ height: _height }: { height: number }) {
return (
<div className="flex flex-col h-full p-4">
<div className="flex-1 flex items-end gap-3">
{[60, 85, 45, 70, 55, 30].map((h, i) => (
<div
key={i}
className="flex-1 bg-muted rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
<div className="flex justify-between mt-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="w-10 h-3 rounded bg-muted" />
))}
</div>
</div>
);
}
export default ChartSkeleton;

View File

@@ -0,0 +1,79 @@
// ========================================
// Sparkline Component
// ========================================
// Mini line chart for trend visualization in StatCards
import { useMemo } from 'react';
import { LineChart, Line, ResponsiveContainer } from 'recharts';
import { getChartColors } from '@/lib/chartTheme';
export interface SparklineProps {
/** Array of numeric values for the sparkline */
data: number[];
/** Optional CSS class name */
className?: string;
/** Chart height in pixels (default: 50) */
height?: number;
/** Line color (default: primary theme color) */
color?: string;
/** Line width (default: 2) */
strokeWidth?: number;
}
/**
* Sparkline - Minimal line chart for at-a-glance trend visualization
*
* Displays a simple line chart with no axes or labels, optimized for
* showing trends in constrained spaces like StatCards.
*
* @example
* ```tsx
* // Show last 7 days of activity
* <Sparkline data={[12, 19, 3, 5, 2, 3, 7]} height={40} />
* ```
*/
export function Sparkline({
data,
className = '',
height = 50,
color,
strokeWidth = 2,
}: SparklineProps) {
const colors = useMemo(() => getChartColors(), []);
const lineColor = color || colors.primary;
// Transform data into Recharts format
const chartData = useMemo(() => {
return data.map((value, index) => ({
index,
value,
}));
}, [data]);
// Don't render if no data
if (!data || data.length === 0) {
return null;
}
return (
<div className={`w-full ${className}`}>
<ResponsiveContainer width="100%" height={height}>
<LineChart
data={chartData}
margin={{ top: 2, right: 2, bottom: 2, left: 2 }}
>
<Line
type="monotone"
dataKey="value"
stroke={lineColor}
strokeWidth={strokeWidth}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
export default Sparkline;

View File

@@ -0,0 +1,118 @@
// ========================================
// TaskTypeBarChart Component
// ========================================
// Recharts bar chart visualizing task type breakdown
import { useMemo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} from 'recharts';
import type { TaskTypeCount } from '@/hooks/useTaskTypeCounts';
import { getChartColors, TASK_TYPE_COLORS } from '@/lib/chartTheme';
export interface TaskTypeBarChartProps {
/** Task type count data */
data: TaskTypeCount[];
/** Optional CSS class name */
className?: string;
/** Chart height (default: 300) */
height?: number;
/** Optional label for the chart */
title?: string;
}
/**
* Custom tooltip component for the bar chart
*/
function CustomTooltip({ active, payload }: any) {
if (active && payload && payload.length) {
const { type, count, percentage } = payload[0].payload;
const displayName = type.charAt(0).toUpperCase() + type.slice(1);
return (
<div className="rounded bg-card p-3 shadow-md border border-border">
<p className="text-sm font-medium text-foreground">{displayName}</p>
<p className="text-sm text-muted-foreground">
{count} tasks ({Math.round(percentage || 0)}%)
</p>
</div>
);
}
return null;
}
/**
* TaskTypeBarChart - Visualizes task type distribution
*
* @example
* ```tsx
* const { data, isLoading } = useTaskTypeCounts();
* return <TaskTypeBarChart data={data} />;
* ```
*/
export function TaskTypeBarChart({
data,
className = '',
height = 300,
title,
}: TaskTypeBarChartProps) {
const colors = useMemo(() => getChartColors(), []);
const chartData = useMemo(() => {
return data.map((item) => ({
...item,
displayName: item.type.charAt(0).toUpperCase() + item.type.slice(1),
}));
}, [data]);
const barColors = useMemo(() => {
return chartData.map((item) => {
const colorKey = TASK_TYPE_COLORS[item.type] || 'muted';
return colors[colorKey];
});
}, [chartData, colors]);
return (
<div
className={`w-full ${className}`}
role="img"
aria-label="Task type bar chart showing distribution of task types"
>
{title && <h3 className="text-lg font-semibold text-foreground mb-4">{title}</h3>}
<ResponsiveContainer width="100%" height={height}>
<BarChart
data={chartData}
margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
accessibilityLayer
>
<CartesianGrid strokeDasharray="3 3" stroke={colors.muted} />
<XAxis
dataKey="displayName"
stroke={colors.muted}
style={{ fontSize: '12px' }}
/>
<YAxis stroke={colors.muted} style={{ fontSize: '12px' }} />
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: '14px' }}
formatter={() => 'Task Count'}
/>
<Bar dataKey="count" radius={[8, 8, 0, 0]}>
{barColors.map((color, index) => (
<Cell key={`cell-${index}`} fill={color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
export default TaskTypeBarChart;

View File

@@ -0,0 +1,110 @@
// ========================================
// WorkflowStatusPieChart Component
// ========================================
// Recharts pie chart visualizing workflow status distribution
import { useMemo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import type { WorkflowStatusCount } from '@/hooks/useWorkflowStatusCounts';
import { getChartColors, STATUS_COLORS } from '@/lib/chartTheme';
export interface WorkflowStatusPieChartProps {
/** Workflow status count data */
data: WorkflowStatusCount[];
/** Optional CSS class name */
className?: string;
/** Chart height (default: 300) */
height?: number;
/** Optional label for the chart */
title?: string;
}
/**
* Custom tooltip component for the pie chart
*/
function CustomTooltip({ active, payload }: any) {
if (active && payload && payload.length) {
const { name, value, payload: data } = payload[0];
const percentage = data.percentage ?? Math.round((value / 100) * 100);
return (
<div className="rounded bg-card p-2 shadow-md border border-border">
<p className="text-sm font-medium text-foreground">{name}</p>
<p className="text-sm text-muted-foreground">
{value} ({percentage}%)
</p>
</div>
);
}
return null;
}
/**
* WorkflowStatusPieChart - Visualizes workflow status distribution
*
* @example
* ```tsx
* const { data, isLoading } = useWorkflowStatusCounts();
* return <WorkflowStatusPieChart data={data} />;
* ```
*/
export function WorkflowStatusPieChart({
data,
className = '',
height = 300,
title,
}: WorkflowStatusPieChartProps) {
const colors = useMemo(() => getChartColors(), []);
const chartData = useMemo(() => {
return data.map((item) => ({
...item,
displayName: item.status.charAt(0).toUpperCase() + item.status.slice(1).replace('_', ' '),
}));
}, [data]);
const sliceColors = useMemo(() => {
return chartData.map((item) => {
const colorKey = STATUS_COLORS[item.status];
return colors[colorKey];
});
}, [chartData, colors]);
return (
<div
className={`w-full ${className}`}
role="img"
aria-label="Workflow status pie chart showing distribution of workflow statuses"
>
{title && <h3 className="text-lg font-semibold text-foreground mb-4">{title}</h3>}
<ResponsiveContainer width="100%" height={height}>
<PieChart
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
accessibilityLayer
>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ displayName, percentage }) => `${displayName} ${Math.round(percentage || 0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="count"
>
{sliceColors.map((color, index) => (
<Cell key={`cell-${index}`} fill={color} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
verticalAlign="bottom"
height={36}
formatter={(_value, entry: any) => entry.payload.displayName}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
}
export default WorkflowStatusPieChart;

View File

@@ -0,0 +1,18 @@
// ========================================
// Chart Components Exports
// ========================================
export { WorkflowStatusPieChart } from './WorkflowStatusPieChart';
export type { WorkflowStatusPieChartProps } from './WorkflowStatusPieChart';
export { ActivityLineChart } from './ActivityLineChart';
export type { ActivityLineChartProps } from './ActivityLineChart';
export { TaskTypeBarChart } from './TaskTypeBarChart';
export type { TaskTypeBarChartProps } from './TaskTypeBarChart';
export { Sparkline } from './Sparkline';
export type { SparklineProps } from './Sparkline';
export { ChartSkeleton } from './ChartSkeleton';
export type { ChartSkeletonProps } from './ChartSkeleton';

View File

@@ -48,21 +48,21 @@ type IndexOperation = {
export function IndexOperations({ disabled = false, onRefresh }: IndexOperationsProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const { success, error: showError, wsLastMessage } = useNotifications();
const projectPath = useWorkflowStore(selectProjectPath);
const { inProgress } = useCodexLensIndexingStatus();
const { rebuildIndex, isRebuilding } = useRebuildIndex();
const { updateIndex, isUpdating } = useUpdateIndex();
const { cancelIndexing, isCancelling } = useCancelIndexing();
const { lastMessage } = useWebSocket();
useWebSocket();
const [indexProgress, setIndexProgress] = useState<IndexProgress | null>(null);
const [activeOperation, setActiveOperation] = useState<string | null>(null);
// Listen for WebSocket progress updates
useEffect(() => {
if (lastMessage?.type === 'CODEXLENS_INDEX_PROGRESS') {
const progress = lastMessage.payload as IndexProgress;
if (wsLastMessage?.type === 'CODEXLENS_INDEX_PROGRESS') {
const progress = wsLastMessage.payload as IndexProgress;
setIndexProgress(progress);
// Clear active operation when complete or error
@@ -83,7 +83,7 @@ export function IndexOperations({ disabled = false, onRefresh }: IndexOperations
setIndexProgress(null);
}
}
}, [lastMessage, formatMessage, success, showError, onRefresh]);
}, [wsLastMessage, formatMessage, success, showError, onRefresh]);
const isOperating = isRebuilding || isUpdating || inProgress || !!activeOperation;

View File

@@ -49,24 +49,33 @@ const mockModels: CodexLensModel[] = [
];
const mockMutations = {
updateConfig: vi.fn().mockResolvedValue({ success: true }),
updateConfig: vi.fn().mockResolvedValue({ success: true }) as any,
isUpdatingConfig: false,
bootstrap: vi.fn().mockResolvedValue({ success: true }),
bootstrap: vi.fn().mockResolvedValue({ success: true }) as any,
isBootstrapping: false,
uninstall: vi.fn().mockResolvedValue({ success: true }),
installSemantic: vi.fn().mockResolvedValue({ success: true }) as any,
isInstallingSemantic: false,
uninstall: vi.fn().mockResolvedValue({ success: true }) as any,
isUninstalling: false,
downloadModel: vi.fn().mockResolvedValue({ success: true }),
downloadCustomModel: vi.fn().mockResolvedValue({ success: true }),
downloadModel: vi.fn().mockResolvedValue({ success: true }) as any,
downloadCustomModel: vi.fn().mockResolvedValue({ success: true }) as any,
isDownloading: false,
deleteModel: vi.fn().mockResolvedValue({ success: true }),
deleteModel: vi.fn().mockResolvedValue({ success: true }) as any,
deleteModelByPath: vi.fn().mockResolvedValue({ success: true }) as any,
isDeleting: false,
updateEnv: vi.fn().mockResolvedValue({ success: true, env: {}, settings: {}, raw: '' }),
updateEnv: vi.fn().mockResolvedValue({ success: true, env: {}, settings: {}, raw: '' }) as any,
isUpdatingEnv: false,
selectGpu: vi.fn().mockResolvedValue({ success: true }),
resetGpu: vi.fn().mockResolvedValue({ success: true }),
selectGpu: vi.fn().mockResolvedValue({ success: true }) as any,
resetGpu: vi.fn().mockResolvedValue({ success: true }) as any,
isSelectingGpu: false,
updatePatterns: vi.fn().mockResolvedValue({ patterns: [], extensionFilters: [], defaults: {} }),
updatePatterns: vi.fn().mockResolvedValue({ patterns: [], extensionFilters: [], defaults: {} }) as any,
isUpdatingPatterns: false,
rebuildIndex: vi.fn().mockResolvedValue({ success: true }) as any,
isRebuildingIndex: false,
updateIndex: vi.fn().mockResolvedValue({ success: true }) as any,
isUpdatingIndex: false,
cancelIndexing: vi.fn().mockResolvedValue({ success: true }) as any,
isCancellingIndexing: false,
isMutating: false,
};

View File

@@ -84,7 +84,7 @@ export function SemanticInstallDialog({ open, onOpenChange, onSuccess }: Semanti
onSuccess?.();
onOpenChange(false);
} else {
throw new Error(result.error || 'Installation failed');
throw new Error(result.message || 'Installation failed');
}
} catch (err) {
showError(

View File

@@ -17,17 +17,26 @@ vi.mock('@/hooks', async (importOriginal) => {
useCodexLensConfig: vi.fn(),
useUpdateCodexLensConfig: vi.fn(),
useNotifications: vi.fn(() => ({
success: vi.fn(),
error: vi.fn(),
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
connectWebSocket: vi.fn(),
disconnectWebSocket: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
})),
};
});
@@ -129,8 +138,26 @@ describe('SettingsTab', () => {
const success = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success,
warning: vi.fn(),
error: vi.fn(),
removeToast: vi.fn(),
clearAllToasts: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
});
const user = userEvent.setup();
@@ -407,17 +434,26 @@ describe('SettingsTab', () => {
it('should show error notification on save failure', async () => {
const error = vi.fn();
vi.mocked(useNotifications).mockReturnValue({
success: vi.fn(),
error,
toasts: [],
wsStatus: 'disconnected' as const,
wsLastMessage: null,
isWsConnected: false,
isPanelVisible: false,
persistentNotifications: [],
addToast: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error,
removeToast: vi.fn(),
clearToasts: vi.fn(),
connectWebSocket: vi.fn(),
disconnectWebSocket: vi.fn(),
clearAllToasts: vi.fn(),
setWsStatus: vi.fn(),
setWsLastMessage: vi.fn(),
togglePanel: vi.fn(),
setPanelVisible: vi.fn(),
addPersistentNotification: vi.fn(),
removePersistentNotification: vi.fn(),
clearPersistentNotifications: vi.fn(),
});
vi.mocked(useCodexLensConfig).mockReturnValue({
config: mockConfig,

View File

@@ -5,7 +5,7 @@
import * as React from 'react';
import { useIntl } from 'react-intl';
import { ChevronDown, ChevronRight, Eye, EyeOff } from 'lucide-react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/Badge';
import { Switch } from '@/components/ui/Switch';
@@ -39,7 +39,7 @@ export interface CommandGroupAccordionProps {
* Get icon for a command group
* Uses top-level parent's icon for nested groups
*/
function getGroupIcon(groupName: string): React.ReactNode {
function getGroupIcon(groupName: string): string {
const groupIcons: Record<string, string> = {
cli: 'terminal',
workflow: 'git-branch',
@@ -246,7 +246,6 @@ export function CommandGroupAccordion({
const { formatMessage } = useIntl();
const enabledCommands = commands.filter((cmd) => cmd.enabled);
const disabledCommands = commands.filter((cmd) => !cmd.enabled);
const allEnabled = enabledCommands.length === commands.length && commands.length > 0;
// Filter commands based on showDisabled setting
@@ -264,7 +263,7 @@ export function CommandGroupAccordion({
return (
<div className={cn('mb-4', indentLevel > 0 && 'ml-5')} style={indentLevel > 0 ? { marginLeft: `${indentLevel * 20}px` } : undefined}>
<Collapsible open={isExpanded} onOpenChange={(open) => onToggleExpand(groupName)}>
<Collapsible open={isExpanded} onOpenChange={() => onToggleExpand(groupName)}>
{/* Group Header */}
<div className="flex items-center justify-between px-4 py-3 bg-card border border-border rounded-lg hover:bg-muted/50 transition-colors">
<CollapsibleTrigger asChild>
@@ -334,7 +333,7 @@ export function CommandGroupAccordion({
<tbody className="divide-y divide-border">
{visibleCommands.map((command) => (
<CommandRow
key={command.name}
key={`${command.name}-${command.location || 'default'}`}
command={command}
onToggle={onToggleCommand}
disabled={isToggling}

View File

@@ -3,7 +3,6 @@
// ========================================
// Toggle between Project and User command locations
import * as React from 'react';
import { useIntl } from 'react-intl';
import { Folder, User } from 'lucide-react';
import { cn } from '@/lib/utils';

View File

@@ -0,0 +1,72 @@
// ========================================
// DashboardGridContainer Component
// ========================================
// Responsive grid layout using react-grid-layout for draggable/resizable widgets
import * as React from 'react';
import { Responsive, WidthProvider, Layout as RGLLayout } from 'react-grid-layout';
import { cn } from '@/lib/utils';
import { useUserDashboardLayout } from '@/hooks/useUserDashboardLayout';
import { GRID_BREAKPOINTS, GRID_COLS, GRID_ROW_HEIGHT } from './defaultLayouts';
import type { DashboardLayouts } from '@/types/store';
const ResponsiveGridLayout = WidthProvider(Responsive);
export interface DashboardGridContainerProps {
/** Child elements to render in the grid (widgets/sections) */
children: React.ReactNode;
/** Additional CSS classes for the grid container */
className?: string;
/** Whether grid items are draggable */
isDraggable?: boolean;
/** Whether grid items are resizable */
isResizable?: boolean;
}
/**
* DashboardGridContainer - Responsive grid layout with drag-drop support
*
* Uses react-grid-layout for draggable and resizable dashboard widgets.
* Layouts are persisted to localStorage and Zustand store.
*
* Breakpoints:
* - lg: >= 1024px (12 columns)
* - md: >= 768px (6 columns)
* - sm: >= 640px (2 columns)
*/
export function DashboardGridContainer({
children,
className,
isDraggable = true,
isResizable = true,
}: DashboardGridContainerProps) {
const { layouts, updateLayouts } = useUserDashboardLayout();
// Handle layout change (debounced via hook)
const handleLayoutChange = React.useCallback(
(_currentLayout: RGLLayout[], allLayouts: DashboardLayouts) => {
updateLayouts(allLayouts);
},
[updateLayouts]
);
return (
<ResponsiveGridLayout
className={cn('dashboard-grid', className)}
layouts={layouts}
breakpoints={GRID_BREAKPOINTS}
cols={GRID_COLS}
rowHeight={GRID_ROW_HEIGHT}
isDraggable={isDraggable}
isResizable={isResizable}
onLayoutChange={handleLayoutChange}
draggableHandle=".drag-handle"
containerPadding={[0, 0]}
margin={[16, 16]}
>
{children}
</ResponsiveGridLayout>
);
}
export default DashboardGridContainer;

View File

@@ -0,0 +1,83 @@
// ========================================
// DashboardHeader Component
// ========================================
// Reusable dashboard header with title, description, and refresh action
import * as React from 'react';
import { useIntl } from 'react-intl';
import { RefreshCw, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
export interface DashboardHeaderProps {
/** i18n key for the dashboard title */
titleKey: string;
/** i18n key for the dashboard description */
descriptionKey: string;
/** Callback when refresh button is clicked */
onRefresh?: () => void;
/** Whether the refresh action is currently loading */
isRefreshing?: boolean;
/** Callback when reset layout button is clicked */
onResetLayout?: () => void;
/** Optional additional actions to render */
actions?: React.ReactNode;
}
/**
* DashboardHeader - Reusable header component for dashboard pages
*
* Displays a title, description, and optional refresh/reset layout buttons.
* Supports additional custom actions via the actions prop.
*/
export function DashboardHeader({
titleKey,
descriptionKey,
onRefresh,
isRefreshing = false,
onResetLayout,
actions,
}: DashboardHeaderProps) {
const { formatMessage } = useIntl();
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">
{formatMessage({ id: titleKey })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: descriptionKey })}
</p>
</div>
<div className="flex items-center gap-2">
{actions}
{onResetLayout && (
<Button
variant="outline"
size="sm"
onClick={onResetLayout}
aria-label="Reset dashboard layout"
>
<RotateCcw className="h-4 w-4 mr-2" />
{formatMessage({ id: 'common.actions.resetLayout' })}
</Button>
)}
{onRefresh && (
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={isRefreshing}
aria-label={formatMessage({ id: 'home.dashboard.refreshTooltip' })}
>
<RefreshCw className={cn('h-4 w-4 mr-2', isRefreshing && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
)}
</div>
</div>
);
}
export default DashboardHeader;

View File

@@ -0,0 +1,436 @@
// ========================================
// Dashboard Integration Tests
// ========================================
// Integration tests for HomePage data flows: stats + sessions + charts + ticker all loading concurrently
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderWithI18n, screen, waitFor } from '@/test/i18n';
import HomePage from '@/pages/HomePage';
// Mock hooks
vi.mock('@/hooks/useDashboardStats', () => ({
useDashboardStats: vi.fn(),
}));
vi.mock('@/hooks/useSessions', () => ({
useSessions: vi.fn(),
}));
vi.mock('@/hooks/useWorkflowStatusCounts', () => ({
useWorkflowStatusCounts: vi.fn(),
}));
vi.mock('@/hooks/useActivityTimeline', () => ({
useActivityTimeline: vi.fn(),
}));
vi.mock('@/hooks/useTaskTypeCounts', () => ({
useTaskTypeCounts: vi.fn(),
}));
vi.mock('@/hooks/useRealtimeUpdates', () => ({
useRealtimeUpdates: vi.fn(),
}));
vi.mock('@/hooks/useUserDashboardLayout', () => ({
useUserDashboardLayout: vi.fn(),
}));
vi.mock('@/stores/appStore', () => ({
useAppStore: vi.fn(() => ({
projectPath: '/test/project',
locale: 'en',
})),
}));
import { useDashboardStats } from '@/hooks/useDashboardStats';
import { useSessions } from '@/hooks/useSessions';
import { useWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
import { useActivityTimeline } from '@/hooks/useActivityTimeline';
import { useTaskTypeCounts } from '@/hooks/useTaskTypeCounts';
import { useRealtimeUpdates } from '@/hooks/useRealtimeUpdates';
import { useUserDashboardLayout } from '@/hooks/useUserDashboardLayout';
describe('Dashboard Integration Tests', () => {
beforeEach(() => {
// Setup default mock responses
vi.mocked(useDashboardStats).mockReturnValue({
data: {
totalSessions: 42,
activeSessions: 5,
completedToday: 12,
averageTime: '2.5h',
successRate: 85,
taskCount: 156,
},
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useSessions).mockReturnValue({
activeSessions: [
{
id: 'session-1',
name: 'Test Session 1',
status: 'in_progress',
tasks: [{ status: 'completed' }, { status: 'pending' }],
created_at: new Date().toISOString(),
},
],
archivedSessions: [],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useWorkflowStatusCounts).mockReturnValue({
data: [
{ status: 'completed', count: 30, percentage: 60 },
{ status: 'in_progress', count: 10, percentage: 20 },
{ status: 'pending', count: 10, percentage: 20 },
],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useActivityTimeline).mockReturnValue({
data: [
{ date: '2026-02-01', sessions: 5, tasks: 20 },
{ date: '2026-02-02', sessions: 8, tasks: 35 },
],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useTaskTypeCounts).mockReturnValue({
data: [
{ type: 'feature', count: 45 },
{ type: 'bugfix', count: 30 },
{ type: 'refactor', count: 15 },
],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
vi.mocked(useRealtimeUpdates).mockReturnValue({
messages: [
{
id: 'msg-1',
text: 'Session completed',
type: 'session',
timestamp: Date.now(),
},
],
connectionStatus: 'connected',
reconnect: vi.fn(),
});
vi.mocked(useUserDashboardLayout).mockReturnValue({
layouts: {
lg: [],
md: [],
sm: [],
},
saveLayout: vi.fn(),
resetLayout: vi.fn(),
isSaving: false,
} as any);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Concurrent Data Loading', () => {
it('INT-1.1 - should load all data sources concurrently', async () => {
renderWithI18n(<HomePage />);
// Verify all hooks are called
expect(useDashboardStats).toHaveBeenCalled();
expect(useSessions).toHaveBeenCalled();
expect(useWorkflowStatusCounts).toHaveBeenCalled();
expect(useActivityTimeline).toHaveBeenCalled();
expect(useTaskTypeCounts).toHaveBeenCalled();
expect(useRealtimeUpdates).toHaveBeenCalled();
});
it('INT-1.2 - should display all widgets with loaded data', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
// Check for stat cards
expect(screen.queryByText('42')).toBeInTheDocument(); // total sessions
});
});
it('INT-1.3 - should handle loading states correctly', async () => {
vi.mocked(useDashboardStats).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
// Should show loading skeleton
await waitFor(() => {
const skeletons = screen.queryAllByTestId(/skeleton/i);
expect(skeletons.length).toBeGreaterThan(0);
});
});
it('INT-1.4 - should handle partial loading states', async () => {
// Stats loading, sessions loaded
vi.mocked(useDashboardStats).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
// Check that hooks were called (rendering may vary based on implementation)
expect(useDashboardStats).toHaveBeenCalled();
expect(useSessions).toHaveBeenCalled();
});
});
});
describe('Data Flow Integration', () => {
it('INT-2.1 - should pass stats data to DetailedStatsWidget', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(screen.queryByText('42')).toBeInTheDocument();
expect(screen.queryByText('5')).toBeInTheDocument();
});
});
it('INT-2.2 - should pass session data to RecentSessionsWidget', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(screen.queryByText('Test Session 1')).toBeInTheDocument();
});
});
it('INT-2.3 - should pass chart data to chart widgets', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
// Chart data should be rendered
expect(useWorkflowStatusCounts).toHaveBeenCalled();
expect(useActivityTimeline).toHaveBeenCalled();
expect(useTaskTypeCounts).toHaveBeenCalled();
});
});
it('INT-2.4 - should pass ticker messages to TickerMarquee', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useRealtimeUpdates).toHaveBeenCalled();
});
});
});
describe('Error Handling', () => {
it('INT-3.1 - should display error state when stats hook fails', async () => {
vi.mocked(useDashboardStats).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load stats'),
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
const errorText = screen.queryByText(/error|failed/i);
expect(errorText).toBeInTheDocument();
});
});
it('INT-3.2 - should display error state when sessions hook fails', async () => {
vi.mocked(useSessions).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load sessions'),
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
const errorText = screen.queryByText(/error|failed/i);
expect(errorText).toBeInTheDocument();
});
});
it('INT-3.3 - should display error state when chart hooks fail', async () => {
vi.mocked(useWorkflowStatusCounts).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load chart data'),
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useWorkflowStatusCounts).toHaveBeenCalled();
});
});
it('INT-3.4 - should handle partial errors gracefully', async () => {
// Only stats fails, others succeed
vi.mocked(useDashboardStats).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Stats failed'),
refetch: vi.fn(),
} as any);
renderWithI18n(<HomePage />);
await waitFor(() => {
// Check that useSessions was called (sessions may or may not render)
expect(useSessions).toHaveBeenCalled();
});
});
it('INT-3.5 - should handle WebSocket disconnection', async () => {
vi.mocked(useRealtimeUpdates).mockReturnValue({
messages: [],
connectionStatus: 'disconnected',
reconnect: vi.fn(),
});
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useRealtimeUpdates).toHaveBeenCalled();
});
});
});
describe('Data Refresh', () => {
it('INT-4.1 - should refresh all data sources on refresh button click', async () => {
const mockRefetch = vi.fn();
vi.mocked(useDashboardStats).mockReturnValue({
data: { totalSessions: 42 } as any,
isLoading: false,
error: null,
refetch: mockRefetch,
} as any);
renderWithI18n(<HomePage />);
const refreshButton = screen.queryByRole('button', { name: /refresh/i });
if (refreshButton) {
refreshButton.click();
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled();
});
}
});
it('INT-4.2 - should update UI when data changes', async () => {
const { rerender } = renderWithI18n(<HomePage />);
await waitFor(() => {
expect(screen.queryByText('42')).toBeInTheDocument();
});
// Update data
vi.mocked(useDashboardStats).mockReturnValue({
data: { totalSessions: 50 } as any,
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
rerender(<HomePage />);
await waitFor(() => {
expect(screen.queryByText('50')).toBeInTheDocument();
});
});
});
describe('Workspace Scoping', () => {
it('INT-5.1 - should pass workspace path to all data hooks', async () => {
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useDashboardStats).toHaveBeenCalledWith(
expect.objectContaining({ projectPath: '/test/project' })
);
});
});
it('INT-5.2 - should refresh data when workspace changes', async () => {
const { rerender } = renderWithI18n(<HomePage />);
// Change workspace
vi.mocked(require('@/stores/appStore').useAppStore).mockReturnValue({
projectPath: '/different/project',
locale: 'en',
});
rerender(<HomePage />);
await waitFor(() => {
expect(useDashboardStats).toHaveBeenCalled();
});
});
});
describe('Realtime Updates', () => {
it('INT-6.1 - should display new ticker messages as they arrive', async () => {
const { rerender } = renderWithI18n(<HomePage />);
// Add new message
vi.mocked(useRealtimeUpdates).mockReturnValue({
messages: [
{
id: 'msg-2',
text: 'New session started',
type: 'session',
timestamp: Date.now(),
},
],
connectionStatus: 'connected',
reconnect: vi.fn(),
});
rerender(<HomePage />);
await waitFor(() => {
expect(useRealtimeUpdates).toHaveBeenCalled();
});
});
it('INT-6.2 - should maintain connection status indicator', async () => {
vi.mocked(useRealtimeUpdates).mockReturnValue({
messages: [],
connectionStatus: 'reconnecting',
reconnect: vi.fn(),
});
renderWithI18n(<HomePage />);
await waitFor(() => {
expect(useRealtimeUpdates).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,64 @@
// ========================================
// Default Dashboard Layouts
// ========================================
// Default widget configurations and responsive layouts for the dashboard grid
import type { WidgetConfig, DashboardLayouts, DashboardLayoutState } from '@/types/store';
/** Widget IDs used across the dashboard */
export const WIDGET_IDS = {
STATS: 'detailed-stats',
RECENT_SESSIONS: 'recent-sessions',
WORKFLOW_STATUS: 'workflow-status-pie',
ACTIVITY: 'activity-line',
TASK_TYPES: 'task-type-bar',
} as const;
/** Default widget configurations */
export const DEFAULT_WIDGETS: WidgetConfig[] = [
{ i: WIDGET_IDS.STATS, name: 'Statistics', visible: true, minW: 4, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, name: 'Recent Sessions', visible: true, minW: 4, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, name: 'Workflow Status', visible: true, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, name: 'Activity', visible: true, minW: 4, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, name: 'Task Types', visible: true, minW: 3, minH: 3 },
];
/** Default responsive layouts */
export const DEFAULT_LAYOUTS: DashboardLayouts = {
lg: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 12, h: 2, minW: 4, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 2, w: 6, h: 4, minW: 4, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 6, y: 2, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 6, w: 7, h: 4, minW: 4, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 7, y: 6, w: 5, h: 4, minW: 3, minH: 3 },
],
md: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 6, h: 2, minW: 3, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 2, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 0, y: 6, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 10, w: 6, h: 4, minW: 3, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 0, y: 14, w: 6, h: 4, minW: 3, minH: 3 },
],
sm: [
{ i: WIDGET_IDS.STATS, x: 0, y: 0, w: 2, h: 3, minW: 2, minH: 2 },
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 3, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.WORKFLOW_STATUS, x: 0, y: 7, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.ACTIVITY, x: 0, y: 11, w: 2, h: 4, minW: 2, minH: 3 },
{ i: WIDGET_IDS.TASK_TYPES, x: 0, y: 15, w: 2, h: 4, minW: 2, minH: 3 },
],
};
/** Default dashboard layout state */
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayoutState = {
widgets: DEFAULT_WIDGETS,
layouts: DEFAULT_LAYOUTS,
};
/** Grid breakpoints matching Tailwind config */
export const GRID_BREAKPOINTS = { lg: 1024, md: 768, sm: 640 };
/** Grid columns per breakpoint */
export const GRID_COLS = { lg: 12, md: 6, sm: 2 };
/** Row height in pixels */
export const GRID_ROW_HEIGHT = 60;

View File

@@ -0,0 +1,58 @@
// ========================================
// ActivityLineChartWidget Component
// ========================================
// Widget wrapper for activity line chart
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { ActivityLineChart, ChartSkeleton } from '@/components/charts';
import { useActivityTimeline, generateMockActivityTimeline } from '@/hooks/useActivityTimeline';
export interface ActivityLineChartWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
}
/**
* ActivityLineChartWidget - Dashboard widget showing activity trends over time
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function ActivityLineChartWidgetComponent({ className, ...props }: ActivityLineChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useActivityTimeline();
// Use mock data if API is not ready
const chartData = data || generateMockActivityTimeline();
return (
<div {...props} className={className}>
<Card className="h-full p-4 flex flex-col">
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'home.widgets.activity' })}
</h3>
{isLoading ? (
<ChartSkeleton type="line" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<ActivityLineChart data={chartData} height={280} />
)}
</Card>
</div>
);
}
export const ActivityLineChartWidget = memo(ActivityLineChartWidgetComponent);
export default ActivityLineChartWidget;

View File

@@ -0,0 +1,150 @@
// ========================================
// DetailedStatsWidget Component
// ========================================
// Widget wrapper for detailed statistics cards in dashboard grid layout
import * as React from 'react';
import { useIntl } from 'react-intl';
import {
FolderKanban,
ListChecks,
CheckCircle2,
Clock,
XCircle,
Activity,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
import { useDashboardStats } from '@/hooks/useDashboardStats';
export interface DetailedStatsWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
}
/**
* DetailedStatsWidget - Dashboard widget showing detailed statistics
*
* Displays 6 stat cards with key metrics:
* - Active sessions, total tasks, completed tasks
* - Pending tasks, failed tasks, today's activity
*
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function DetailedStatsWidgetComponent({ className, ...props }: DetailedStatsWidgetProps) {
const { formatMessage } = useIntl();
// Fetch dashboard stats
const { stats, isLoading, isFetching } = useDashboardStats({
refetchInterval: 60000, // Refetch every minute
});
// Generate mock sparkline data for last 7 days
// TODO: Replace with real API data when backend provides trend data
const generateSparklineData = (currentValue: number, variance = 0.3): number[] => {
const days = 7;
const data: number[] = [];
let value = Math.max(0, currentValue * (1 - variance));
for (let i = 0; i < days - 1; i++) {
data.push(Math.round(value));
const change = (Math.random() - 0.5) * 2 * variance * currentValue;
value = Math.max(0, value + change);
}
// Last day is current value
data.push(currentValue);
return data;
};
// Stat card configuration with sparkline data
const statCards = React.useMemo(() => [
{
key: 'activeSessions',
title: formatMessage({ id: 'home.stats.activeSessions' }),
icon: FolderKanban,
variant: 'primary' as const,
getValue: (stats: { activeSessions: number }) => stats.activeSessions,
getSparkline: (stats: { activeSessions: number }) => generateSparklineData(stats.activeSessions, 0.4),
},
{
key: 'totalTasks',
title: formatMessage({ id: 'home.stats.totalTasks' }),
icon: ListChecks,
variant: 'info' as const,
getValue: (stats: { totalTasks: number }) => stats.totalTasks,
getSparkline: (stats: { totalTasks: number }) => generateSparklineData(stats.totalTasks, 0.3),
},
{
key: 'completedTasks',
title: formatMessage({ id: 'home.stats.completedTasks' }),
icon: CheckCircle2,
variant: 'success' as const,
getValue: (stats: { completedTasks: number }) => stats.completedTasks,
getSparkline: (stats: { completedTasks: number }) => generateSparklineData(stats.completedTasks, 0.25),
},
{
key: 'pendingTasks',
title: formatMessage({ id: 'home.stats.pendingTasks' }),
icon: Clock,
variant: 'warning' as const,
getValue: (stats: { pendingTasks: number }) => stats.pendingTasks,
getSparkline: (stats: { pendingTasks: number }) => generateSparklineData(stats.pendingTasks, 0.35),
},
{
key: 'failedTasks',
title: formatMessage({ id: 'common.status.failed' }),
icon: XCircle,
variant: 'danger' as const,
getValue: (stats: { failedTasks: number }) => stats.failedTasks,
getSparkline: (stats: { failedTasks: number }) => generateSparklineData(stats.failedTasks, 0.5),
},
{
key: 'todayActivity',
title: formatMessage({ id: 'common.stats.todayActivity' }),
icon: Activity,
variant: 'default' as const,
getValue: (stats: { todayActivity: number }) => stats.todayActivity,
getSparkline: (stats: { todayActivity: number }) => generateSparklineData(stats.todayActivity, 0.6),
},
], [formatMessage]);
return (
<div {...props} className={className}>
<Card className="h-full p-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{isLoading
? Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
: statCards.map((card) => (
<StatCard
key={card.key}
title={card.title}
value={stats ? card.getValue(stats as any) : 0}
icon={card.icon}
variant={card.variant}
isLoading={isFetching && !stats}
sparklineData={stats ? (card as any).getSparkline(stats as any) : undefined}
showSparkline={true}
/>
))}
</div>
</Card>
</div>
);
}
/**
* Memoized DetailedStatsWidget - Prevents re-renders when parent updates
* Props are compared shallowly; use useCallback for function props
*/
export const DetailedStatsWidget = React.memo(DetailedStatsWidgetComponent);
export default DetailedStatsWidget;

View File

@@ -0,0 +1,117 @@
// ========================================
// RecentSessionsWidget Component
// ========================================
// Widget wrapper for recent sessions list in dashboard grid layout
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useIntl } from 'react-intl';
import { FolderKanban } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
import { useSessions } from '@/hooks/useSessions';
import { Button } from '@/components/ui/Button';
export interface RecentSessionsWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
/** Maximum number of sessions to display */
maxSessions?: number;
}
/**
* RecentSessionsWidget - Dashboard widget showing recent workflow sessions
*
* Displays recent active sessions (max 6 by default) with navigation to session detail.
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function RecentSessionsWidgetComponent({
className,
maxSessions = 6,
...props
}: RecentSessionsWidgetProps) {
const { formatMessage } = useIntl();
const navigate = useNavigate();
// Fetch recent sessions (active only)
const { activeSessions, isLoading } = useSessions({
filter: { location: 'active' },
});
// Get recent sessions (sorted by creation date)
const recentSessions = React.useMemo(
() =>
[...activeSessions]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, maxSessions),
[activeSessions, maxSessions]
);
const handleSessionClick = (sessionId: string) => {
navigate(`/sessions/${sessionId}`);
};
const handleViewAll = () => {
navigate('/sessions');
};
return (
<div {...props} className={className}>
<Card className="h-full p-4 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-foreground">
{formatMessage({ id: 'home.sections.recentSessions' })}
</h3>
<Button variant="link" size="sm" onClick={handleViewAll}>
{formatMessage({ id: 'common.actions.viewAll' })}
</Button>
</div>
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<SessionCardSkeleton key={i} />
))}
</div>
) : recentSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8">
<FolderKanban className="h-12 w-12 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'home.emptyState.noSessions.message' })}
</p>
</div>
) : (
<div className="space-y-3">
{recentSessions.map((session) => (
<SessionCard
key={session.session_id}
session={session}
onClick={handleSessionClick}
onView={handleSessionClick}
showActions={false}
/>
))}
</div>
)}
</div>
</Card>
</div>
);
}
/**
* Memoized RecentSessionsWidget - Prevents re-renders when parent updates
* Props are compared shallowly; use useCallback for function props
*/
export const RecentSessionsWidget = React.memo(RecentSessionsWidgetComponent);
export default RecentSessionsWidget;

View File

@@ -0,0 +1,58 @@
// ========================================
// TaskTypeBarChartWidget Component
// ========================================
// Widget wrapper for task type bar chart
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { TaskTypeBarChart, ChartSkeleton } from '@/components/charts';
import { useTaskTypeCounts, generateMockTaskTypeCounts } from '@/hooks/useTaskTypeCounts';
export interface TaskTypeBarChartWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
}
/**
* TaskTypeBarChartWidget - Dashboard widget showing task type distribution
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function TaskTypeBarChartWidgetComponent({ className, ...props }: TaskTypeBarChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useTaskTypeCounts();
// Use mock data if API is not ready
const chartData = data || generateMockTaskTypeCounts();
return (
<div {...props} className={className}>
<Card className="h-full p-4 flex flex-col">
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'home.widgets.taskTypes' })}
</h3>
{isLoading ? (
<ChartSkeleton type="bar" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<TaskTypeBarChart data={chartData} height={280} />
)}
</Card>
</div>
);
}
export const TaskTypeBarChartWidget = memo(TaskTypeBarChartWidgetComponent);
export default TaskTypeBarChartWidget;

View File

@@ -0,0 +1,58 @@
// ========================================
// WorkflowStatusPieChartWidget Component
// ========================================
// Widget wrapper for workflow status pie chart
import { memo } from 'react';
import { useIntl } from 'react-intl';
import { Card } from '@/components/ui/Card';
import { WorkflowStatusPieChart, ChartSkeleton } from '@/components/charts';
import { useWorkflowStatusCounts, generateMockWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
export interface WorkflowStatusPieChartWidgetProps {
/** Data grid attributes for react-grid-layout */
'data-grid'?: {
i: string;
x: number;
y: number;
w: number;
h: number;
};
/** Additional CSS classes */
className?: string;
}
/**
* WorkflowStatusPieChartWidget - Dashboard widget showing workflow status distribution
* Wrapped with React.memo to prevent unnecessary re-renders when parent updates.
*/
function WorkflowStatusPieChartWidgetComponent({ className, ...props }: WorkflowStatusPieChartWidgetProps) {
const { formatMessage } = useIntl();
const { data, isLoading, error } = useWorkflowStatusCounts();
// Use mock data if API is not ready
const chartData = data || generateMockWorkflowStatusCounts();
return (
<div {...props} className={className}>
<Card className="h-full p-4 flex flex-col">
<h3 className="text-lg font-semibold text-foreground mb-2">
{formatMessage({ id: 'home.widgets.workflowStatus' })}
</h3>
{isLoading ? (
<ChartSkeleton type="pie" height={280} />
) : error ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-destructive">Failed to load chart data</p>
</div>
) : (
<WorkflowStatusPieChart data={chartData} height={280} />
)}
</Card>
</div>
);
}
export const WorkflowStatusPieChartWidget = memo(WorkflowStatusPieChartWidgetComponent);
export default WorkflowStatusPieChartWidget;

View File

@@ -0,0 +1,19 @@
// ========================================
// Dashboard Widgets - Export Index
// ========================================
// Central export point for all dashboard widget components
export { DetailedStatsWidget } from './DetailedStatsWidget';
export type { DetailedStatsWidgetProps } from './DetailedStatsWidget';
export { RecentSessionsWidget } from './RecentSessionsWidget';
export type { RecentSessionsWidgetProps } from './RecentSessionsWidget';
export { WorkflowStatusPieChartWidget } from './WorkflowStatusPieChartWidget';
export type { WorkflowStatusPieChartWidgetProps } from './WorkflowStatusPieChartWidget';
export { ActivityLineChartWidget } from './ActivityLineChartWidget';
export type { ActivityLineChartWidgetProps } from './ActivityLineChartWidget';
export { TaskTypeBarChartWidget } from './TaskTypeBarChartWidget';
export type { TaskTypeBarChartWidgetProps } from './TaskTypeBarChartWidget';

View File

@@ -36,16 +36,16 @@ import {
Plus,
Trash2,
} from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { fetchSkills, type Skill, createHook } from '@/lib/api';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchSkills, type Skill, type SkillsResponse, createHook } from '@/lib/api';
import { cn } from '@/lib/utils';
import {
detect,
getShell,
getShellCommand,
getShellName,
checkCompatibility,
getPlatformName,
adjustCommandForPlatform,
DEFAULT_PLATFORM_REQUIREMENTS,
type Platform,
} from '@/utils/platformUtils';
@@ -72,14 +72,6 @@ export interface HookWizardProps {
open: boolean;
/** Callback when dialog is closed */
onClose: () => void;
/** Callback when wizard completes with hook configuration */
onComplete: (hookConfig: {
name: string;
description: string;
trigger: string;
matcher?: string;
command: string;
}) => Promise<void>;
}
/**
@@ -108,11 +100,6 @@ interface SkillContextConfig {
priority: 'high' | 'medium' | 'low';
}
/**
* Wizard configuration union type
*/
type WizardConfig = MemoryUpdateConfig | DangerProtectionConfig | SkillContextConfig;
// ========== Wizard Definitions ==========
/**
@@ -157,16 +144,16 @@ export function HookWizard({
wizardType,
open,
onClose,
onComplete,
}: HookWizardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const [currentStep, setCurrentStep] = useState<WizardStep>(1);
const [detectedPlatform, setDetectedPlatform] = useState<Platform>('linux');
// Fetch available skills for skill-context wizard
const { data: skillsData, isLoading: skillsLoading } = useQuery({
const { data: skillsData, isLoading: skillsLoading } = useQuery<SkillsResponse>({
queryKey: ['skills'],
queryFn: fetchSkills,
queryFn: () => fetchSkills(),
enabled: open && wizardType === 'skill-context',
});
@@ -174,6 +161,7 @@ export function HookWizard({
const createMutation = useMutation({
mutationFn: createHook,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hooks'] });
onClose();
setCurrentStep(1);
},
@@ -533,7 +521,7 @@ export function HookWizard({
);
const renderSkillContextConfig = () => {
const skills = skillsData?.skills ?? [];
const skills: Skill[] = skillsData?.skills ?? [];
const addPair = () => {
setSkillConfig({

View File

@@ -22,6 +22,7 @@ import {
Clock,
Zap,
GitFork,
Activity,
Shield,
History,
Server,
@@ -79,6 +80,8 @@ const navGroupDefinitions: NavGroupDef[] = [
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
{ path: '/coordinator', labelKey: 'navigation.main.coordinator', icon: GitFork },
{ path: '/executions', labelKey: 'navigation.main.executions', icon: Activity },
{ path: '/loops', labelKey: 'navigation.main.loops', icon: RefreshCw },
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
],

View File

@@ -26,6 +26,7 @@ import {
Clock,
CheckCircle2,
AlertCircle,
RefreshCw,
} from 'lucide-react';
import type { SessionMetadata } from '@/types/store';
@@ -88,22 +89,33 @@ function formatDate(dateString: string | undefined): string {
}
/**
* Calculate progress percentage from tasks
* Task status breakdown returned by calculateProgress
*/
function calculateProgress(tasks: SessionMetadata['tasks']): {
completed: number;
interface TaskStatusBreakdown {
total: number;
completed: number;
failed: number;
pending: number;
inProgress: number;
percentage: number;
} {
}
/**
* Calculate progress and status breakdown from tasks
*/
function calculateProgress(tasks: SessionMetadata['tasks']): TaskStatusBreakdown {
if (!tasks || tasks.length === 0) {
return { completed: 0, total: 0, percentage: 0 };
return { total: 0, completed: 0, failed: 0, pending: 0, inProgress: 0, percentage: 0 };
}
const completed = tasks.filter((t) => t.status === 'completed').length;
const total = tasks.length;
const completed = tasks.filter((t) => t.status === 'completed').length;
const failed = tasks.filter((t) => t.status === 'blocked' || t.status === 'skipped').length;
const inProgress = tasks.filter((t) => t.status === 'in_progress').length;
const pending = tasks.filter((t) => t.status === 'pending').length;
const percentage = Math.round((completed / total) * 100);
return { completed, total, percentage };
return { total, completed, failed, pending, inProgress, percentage };
}
/**
@@ -260,6 +272,36 @@ export function SessionCard({
)}
</div>
{/* Task status badges */}
{progress.total > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{progress.pending > 0 && (
<Badge variant="warning" className="gap-1 px-1.5 py-0 text-[10px]">
<Clock className="h-3 w-3" />
{progress.pending} {formatMessage({ id: 'sessions.taskStatus.pending' })}
</Badge>
)}
{progress.inProgress > 0 && (
<Badge variant="info" className="gap-1 px-1.5 py-0 text-[10px]">
<RefreshCw className="h-3 w-3" />
{progress.inProgress} {formatMessage({ id: 'sessions.taskStatus.inProgress' })}
</Badge>
)}
{progress.completed > 0 && (
<Badge variant="success" className="gap-1 px-1.5 py-0 text-[10px]">
<CheckCircle2 className="h-3 w-3" />
{progress.completed} {formatMessage({ id: 'sessions.taskStatus.completed' })}
</Badge>
)}
{progress.failed > 0 && (
<Badge variant="destructive" className="gap-1 px-1.5 py-0 text-[10px]">
<AlertCircle className="h-3 w-3" />
{progress.failed} {formatMessage({ id: 'sessions.taskStatus.failed' })}
</Badge>
)}
</div>
)}
{/* Progress bar (only show if not planning and has tasks) */}
{progress.total > 0 && !isPlanning && (
<div className="mt-3">
@@ -310,6 +352,12 @@ export function SessionCardSkeleton({ className }: { className?: string }) {
<div className="h-4 w-20 rounded bg-muted" />
<div className="h-4 w-16 rounded bg-muted" />
</div>
{/* Status badge skeletons */}
<div className="mt-2 flex gap-1.5">
<div className="h-5 w-16 rounded-full bg-muted" />
<div className="h-5 w-20 rounded-full bg-muted" />
<div className="h-5 w-18 rounded-full bg-muted" />
</div>
<div className="mt-3">
<div className="h-1.5 w-full rounded-full bg-muted" />
</div>

View File

@@ -101,9 +101,9 @@ export function SkillCard({
<div
onClick={handleClick}
className={cn(
'p-3 bg-card border border-border rounded-lg cursor-pointer',
'hover:shadow-md hover:border-primary/50 transition-all',
!skill.enabled && 'opacity-60',
'p-3 bg-card border rounded-lg cursor-pointer',
'hover:shadow-md transition-all',
skill.enabled ? 'border-border hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/50 grayscale-[0.5]',
className
)}
>
@@ -140,8 +140,8 @@ export function SkillCard({
<Card
onClick={handleClick}
className={cn(
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all',
!skill.enabled && 'opacity-75',
'p-4 cursor-pointer hover:shadow-md transition-all',
skill.enabled ? 'hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/30 grayscale-[0.3]',
className
)}
>

View File

@@ -0,0 +1,312 @@
// ========================================
// SkillDetailPanel Component
// ========================================
// Right-side slide-out panel for viewing skill details
import { useEffect } from 'react';
import { useIntl } from 'react-intl';
import {
X,
FileText,
Edit,
Trash2,
Folder,
Lock,
Tag,
MapPin,
Code,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import type { Skill } from '@/lib/api';
export interface SkillDetailPanelProps {
skill: Skill | null;
isOpen: boolean;
onClose: () => void;
onEdit?: (skill: Skill) => void;
onDelete?: (skill: Skill) => void;
onEditFile?: (skillName: string, fileName: string, location: 'project' | 'user') => void;
isLoading?: boolean;
}
export function SkillDetailPanel({
skill,
isOpen,
onClose,
onEdit,
onDelete,
onEditFile,
isLoading = false,
}: SkillDetailPanelProps) {
const { formatMessage } = useIntl();
// Prevent body scroll when panel is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen || !skill) {
return null;
}
const hasAllowedTools = skill.allowedTools && skill.allowedTools.length > 0;
const hasSupportingFiles = skill.supportingFiles && skill.supportingFiles.length > 0;
const folderName = skill.folderName || skill.name;
const handleEditFile = (fileName: string) => {
onEditFile?.(folderName, fileName, skill.location || 'project');
};
return (
<>
{/* Overlay */}
<div
className="fixed inset-0 bg-black/50 z-50 transition-opacity"
onClick={onClose}
/>
{/* Panel */}
<div className="fixed top-0 right-0 w-full sm:w-[480px] md:w-[560px] lg:w-[640px] h-full bg-background border-l border-border shadow-xl z-50 flex flex-col transition-transform">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
'p-2 rounded-lg flex-shrink-0',
skill.enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<Tag className={cn('w-5 h-5', skill.enabled ? 'text-primary' : 'text-muted-foreground')} />
</div>
<div className="min-w-0">
<h3 className="text-lg font-semibold text-foreground truncate">{skill.name}</h3>
{skill.version && (
<p className="text-sm text-muted-foreground">v{skill.version}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex items-center justify-center h-48">
<div className="animate-spin text-muted-foreground">
<Tag className="w-8 h-8" />
</div>
</div>
) : (
<div className="space-y-6">
{/* Description */}
<section>
<h4 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<FileText className="w-4 h-4 text-muted-foreground" />
{formatMessage({ id: 'skills.card.description' })}
</h4>
<p className="text-sm text-muted-foreground leading-relaxed">
{skill.description || formatMessage({ id: 'skills.noDescription' })}
</p>
</section>
{/* Metadata */}
<section>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<MapPin className="w-4 h-4 text-muted-foreground" />
{formatMessage({ id: 'skills.metadata' })}
</h4>
<div className="grid grid-cols-2 gap-3">
<Card className="p-3 bg-muted/50">
<span className="text-xs text-muted-foreground block mb-1">
{formatMessage({ id: 'skills.location' })}
</span>
<p className="text-sm font-medium text-foreground">
{skill.location === 'project' ? formatMessage({ id: 'skills.projectSkills' }) : formatMessage({ id: 'skills.userSkills' })}
</p>
</Card>
{skill.version && (
<Card className="p-3 bg-muted/50">
<span className="text-xs text-muted-foreground block mb-1">
{formatMessage({ id: 'skills.card.version' })}
</span>
<p className="text-sm font-medium text-foreground">v{skill.version}</p>
</Card>
)}
{skill.author && (
<Card className="p-3 bg-muted/50">
<span className="text-xs text-muted-foreground block mb-1">
{formatMessage({ id: 'skills.card.author' })}
</span>
<p className="text-sm font-medium text-foreground">{skill.author}</p>
</Card>
)}
{skill.source && (
<Card className="p-3 bg-muted/50">
<span className="text-xs text-muted-foreground block mb-1">
{formatMessage({ id: 'skills.card.source' })}
</span>
<p className="text-sm font-medium text-foreground">
{formatMessage({ id: `skills.source.${skill.source}` })}
</p>
</Card>
)}
</div>
</section>
{/* Triggers */}
{skill.triggers && skill.triggers.length > 0 && (
<section>
<h4 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<Tag className="w-4 h-4 text-muted-foreground" />
{formatMessage({ id: 'skills.card.triggers' })}
</h4>
<div className="flex flex-wrap gap-2">
{skill.triggers.map((trigger) => (
<Badge key={trigger} variant="secondary" className="text-sm">
{trigger}
</Badge>
))}
</div>
</section>
)}
{/* Allowed Tools */}
{hasAllowedTools && (
<section>
<h4 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<Lock className="w-4 h-4 text-muted-foreground" />
{formatMessage({ id: 'skills.allowedTools' })}
</h4>
<div className="flex flex-wrap gap-2">
{skill.allowedTools!.map((tool) => (
<Badge key={tool} variant="outline" className="text-xs font-mono">
{tool}
</Badge>
))}
</div>
</section>
)}
{/* Files */}
<section>
<h4 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<Folder className="w-4 h-4 text-muted-foreground" />
{formatMessage({ id: 'skills.files' })}
</h4>
<div className="space-y-2">
{/* SKILL.md (main file) */}
<div className="flex items-center justify-between p-3 bg-primary/5 border border-primary/20 rounded-lg hover:bg-primary/10 transition-colors">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
<span className="text-sm font-mono text-foreground font-medium">SKILL.md</span>
</div>
{onEditFile && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-primary hover:bg-primary/20"
onClick={() => handleEditFile('SKILL.md')}
>
<Edit className="w-3.5 h-3.5" />
</Button>
)}
</div>
{/* Supporting Files */}
{hasSupportingFiles && skill.supportingFiles!.map((file) => {
const isDir = file.endsWith('/');
const displayName = isDir ? file.slice(0, -1) : file;
return (
<div
key={file}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg hover:bg-muted transition-colors"
>
<div className="flex items-center gap-2">
{isDir ? (
<Folder className="w-4 h-4 text-muted-foreground" />
) : (
<FileText className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm font-mono text-foreground">{displayName}</span>
</div>
{!isDir && onEditFile && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => handleEditFile(file)}
>
<Edit className="w-3.5 h-3.5" />
</Button>
)}
</div>
);
})}
</div>
</section>
{/* Path */}
{skill.path && (
<section>
<h4 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<Code className="w-4 h-4 text-muted-foreground" />
{formatMessage({ id: 'skills.path' })}
</h4>
<Card className="p-3 bg-muted">
<code className="text-xs font-mono text-muted-foreground break-all">
{skill.path}
</code>
</Card>
</section>
)}
</div>
)}
</div>
{/* Footer Actions */}
<div className="px-6 py-4 border-t border-border flex justify-between">
{onDelete && (
<Button
variant="destructive"
onClick={() => onDelete(skill)}
className="flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{formatMessage({ id: 'common.actions.delete' })}
</Button>
)}
<div className="flex gap-2 ml-auto">
{onEdit && (
<Button
variant="outline"
onClick={() => onEdit(skill)}
className="flex items-center gap-2"
>
<Edit className="w-4 h-4" />
{formatMessage({ id: 'common.actions.edit' })}
</Button>
)}
<Button onClick={onClose}>
{formatMessage({ id: 'common.actions.close' })}
</Button>
</div>
</div>
</div>
</>
);
}
export default SkillDetailPanel;

View File

@@ -8,6 +8,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/Card';
import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react';
import { Sparkline } from '@/components/charts/Sparkline';
const statCardVariants = cva(
'transition-all duration-200 hover:shadow-md',
@@ -64,6 +65,10 @@ export interface StatCardProps
isLoading?: boolean;
/** Optional description */
description?: string;
/** Optional sparkline data (e.g., last 7 days) */
sparklineData?: number[];
/** Whether to show sparkline */
showSparkline?: boolean;
}
/**
@@ -91,6 +96,8 @@ export function StatCard({
trendValue,
isLoading = false,
description,
sparklineData,
showSparkline = false,
...props
}: StatCardProps) {
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
@@ -129,6 +136,15 @@ export function StatCard({
{description}
</p>
)}
{showSparkline && sparklineData && sparklineData.length > 0 && (
<div className="mt-3 -mx-2">
<Sparkline
data={sparklineData}
height={40}
strokeWidth={2}
/>
</div>
)}
</div>
{Icon && (
<div className={cn(iconContainerVariants({ variant }))}>

View File

@@ -0,0 +1,63 @@
// ========================================
// TickerMarquee Component Tests
// ========================================
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TickerMarquee } from './TickerMarquee';
import type { TickerMessage } from '@/hooks/useRealtimeUpdates';
describe('TickerMarquee', () => {
const mockMessages: TickerMessage[] = [
{
id: '1',
text: 'Session WFS-001 created',
type: 'session',
link: '/sessions/WFS-001',
timestamp: Date.now(),
},
{
id: '2',
text: 'Task IMPL-001 completed successfully',
type: 'task',
link: '/tasks/IMPL-001',
timestamp: Date.now(),
},
{
id: '3',
text: 'Workflow authentication started',
type: 'workflow',
timestamp: Date.now(),
},
];
it('renders mock messages when provided', () => {
render(<TickerMarquee mockMessages={mockMessages} />);
expect(screen.getByText('Session WFS-001 created')).toBeInTheDocument();
expect(screen.getByText('Task IMPL-001 completed successfully')).toBeInTheDocument();
expect(screen.getByText('Workflow authentication started')).toBeInTheDocument();
});
it('shows waiting message when no messages', () => {
render(<TickerMarquee mockMessages={[]} />);
expect(screen.getByText(/Waiting for activity/i)).toBeInTheDocument();
});
it('renders links for messages with link property', () => {
render(<TickerMarquee mockMessages={mockMessages} />);
const sessionLink = screen.getByRole('link', { name: /Session WFS-001 created/i });
expect(sessionLink).toHaveAttribute('href', '/sessions/WFS-001');
});
it('applies custom duration to animation', () => {
const { container } = render(
<TickerMarquee mockMessages={mockMessages} duration={60} />
);
const animatedDiv = container.querySelector('[class*="animate-marquee"]');
expect(animatedDiv).toHaveStyle({ animationDuration: '60s' });
});
});

View File

@@ -0,0 +1,146 @@
// ========================================
// TickerMarquee Component
// ========================================
// Real-time scrolling ticker with CSS marquee animation and WebSocket messages
import * as React from 'react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { useRealtimeUpdates, type TickerMessage } from '@/hooks/useRealtimeUpdates';
import {
Play,
CheckCircle2,
XCircle,
Workflow,
Activity,
WifiOff,
type LucideIcon,
} from 'lucide-react';
// --- Types ---
export interface TickerMarqueeProps {
/** WebSocket endpoint path (default: 'ws/ticker-stream') */
endpoint?: string;
/** Animation duration in seconds (default: 30) */
duration?: number;
/** Additional CSS classes */
className?: string;
/** Mock messages for development/testing */
mockMessages?: TickerMessage[];
}
// --- Icon map ---
const typeIcons: Record<TickerMessage['type'], LucideIcon> = {
session: Play,
task: CheckCircle2,
workflow: Workflow,
status: Activity,
};
const typeColors: Record<TickerMessage['type'], string> = {
session: 'text-primary',
task: 'text-success',
workflow: 'text-info',
status: 'text-warning',
};
// --- Component ---
function TickerItem({ message }: { message: TickerMessage }) {
const Icon = typeIcons[message.type] || Activity;
const colorClass = typeColors[message.type] || 'text-muted-foreground';
const content = (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap px-4">
<Icon className={cn('h-3.5 w-3.5 shrink-0', colorClass)} />
<span className="text-sm text-text-secondary">{message.text}</span>
</span>
);
if (message.link) {
return (
<a
href={message.link}
className="inline-flex hover:text-accent transition-colors"
title={message.text}
>
{content}
</a>
);
}
return content;
}
function MessageList({ messages }: { messages: TickerMessage[] }) {
return (
<>
{messages.map((msg) => (
<TickerItem key={msg.id} message={msg} />
))}
</>
);
}
export function TickerMarquee({
endpoint = 'ws/ticker-stream',
duration = 30,
className,
mockMessages,
}: TickerMarqueeProps) {
const { formatMessage } = useIntl();
const { messages: wsMessages, connectionStatus } = useRealtimeUpdates(endpoint);
const messages = mockMessages && mockMessages.length > 0 ? mockMessages : wsMessages;
if (messages.length === 0) {
return (
<div
className={cn(
'flex h-8 items-center justify-center overflow-hidden border-b border-border bg-surface/50',
className
)}
>
{connectionStatus === 'connected' ? (
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'common.ticker.waiting' })}
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<WifiOff className="h-3 w-3" />
{formatMessage({ id: 'common.ticker.disconnected' })}
</span>
)}
</div>
);
}
return (
<div
className={cn(
'group relative flex h-8 items-center overflow-hidden border-b border-border bg-surface/50',
className
)}
role="marquee"
aria-label={formatMessage({ id: 'common.ticker.aria_label' })}
>
{/* Fade edges */}
<div className="pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-surface/50 to-transparent" />
<div className="pointer-events-none absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-surface/50 to-transparent" />
{/* Scrolling content - duplicate for seamless loop */}
<div
className="flex animate-marquee group-hover:[animation-play-state:paused]"
style={{ animationDuration: `${duration}s` }}
>
<MessageList messages={messages} />
{/* Duplicate for seamless loop */}
<MessageList messages={messages} />
</div>
</div>
);
}
export default TickerMarquee;

View File

@@ -10,12 +10,15 @@ export type { SessionCardProps } from './SessionCard';
export { ConversationCard } from './ConversationCard';
export type { ConversationCardProps } from './ConversationCard';
export { IssueCard, IssueCardSkeleton } from './IssueCard';
export { IssueCard } from './IssueCard';
export type { IssueCardProps } from './IssueCard';
export { SkillCard, SkillCardSkeleton } from './SkillCard';
export { SkillCard } from './SkillCard';
export type { SkillCardProps } from './SkillCard';
export { SkillDetailPanel } from './SkillDetailPanel';
export type { SkillDetailPanelProps } from './SkillDetailPanel';
export { StatCard, StatCardSkeleton } from './StatCard';
export type { StatCardProps } from './StatCard';
@@ -139,3 +142,7 @@ export type { IndexManagerProps } from './IndexManager';
export { ExplorerToolbar } from './ExplorerToolbar';
export type { ExplorerToolbarProps } from './ExplorerToolbar';
// Ticker components
export { TickerMarquee } from './TickerMarquee';
export type { TickerMarqueeProps } from './TickerMarquee';