mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: initialize monorepo with package.json for CCW workflow platform
This commit is contained in:
@@ -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 ==========
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
131
ccw/frontend/src/components/charts/ActivityLineChart.tsx
Normal file
131
ccw/frontend/src/components/charts/ActivityLineChart.tsx
Normal 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;
|
||||
111
ccw/frontend/src/components/charts/ChartSkeleton.tsx
Normal file
111
ccw/frontend/src/components/charts/ChartSkeleton.tsx
Normal 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;
|
||||
79
ccw/frontend/src/components/charts/Sparkline.tsx
Normal file
79
ccw/frontend/src/components/charts/Sparkline.tsx
Normal 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;
|
||||
118
ccw/frontend/src/components/charts/TaskTypeBarChart.tsx
Normal file
118
ccw/frontend/src/components/charts/TaskTypeBarChart.tsx
Normal 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;
|
||||
110
ccw/frontend/src/components/charts/WorkflowStatusPieChart.tsx
Normal file
110
ccw/frontend/src/components/charts/WorkflowStatusPieChart.tsx
Normal 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;
|
||||
18
ccw/frontend/src/components/charts/index.ts
Normal file
18
ccw/frontend/src/components/charts/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
83
ccw/frontend/src/components/dashboard/DashboardHeader.tsx
Normal file
83
ccw/frontend/src/components/dashboard/DashboardHeader.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
64
ccw/frontend/src/components/dashboard/defaultLayouts.ts
Normal file
64
ccw/frontend/src/components/dashboard/defaultLayouts.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
19
ccw/frontend/src/components/dashboard/widgets/index.ts
Normal file
19
ccw/frontend/src/components/dashboard/widgets/index.ts
Normal 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';
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
312
ccw/frontend/src/components/shared/SkillDetailPanel.tsx
Normal file
312
ccw/frontend/src/components/shared/SkillDetailPanel.tsx
Normal 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;
|
||||
@@ -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 }))}>
|
||||
|
||||
63
ccw/frontend/src/components/shared/TickerMarquee.test.tsx
Normal file
63
ccw/frontend/src/components/shared/TickerMarquee.test.tsx
Normal 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' });
|
||||
});
|
||||
});
|
||||
146
ccw/frontend/src/components/shared/TickerMarquee.tsx
Normal file
146
ccw/frontend/src/components/shared/TickerMarquee.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user