mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 11:33:45 +08:00
feat: add CLI Viewer Page with multi-pane layout and state management
- Implemented the CliViewerPage component for displaying CLI outputs in a configurable multi-pane layout. - Integrated Zustand for state management, allowing for dynamic layout changes and tab management. - Added layout options: single, split horizontal, split vertical, and 2x2 grid. - Created viewerStore for managing layout, panes, and tabs, including actions for adding/removing panes and tabs. - Added CoordinatorPage barrel export for easier imports.
This commit is contained in:
165
ccw/frontend/src/components/dashboard/DashboardWidgetConfig.tsx
Normal file
165
ccw/frontend/src/components/dashboard/DashboardWidgetConfig.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
// ========================================
|
||||
// DashboardWidgetConfig Component
|
||||
// ========================================
|
||||
// Configuration panel for managing dashboard widgets visibility and layout
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ChevronDown, Eye, EyeOff, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WidgetConfig } from '@/types/store';
|
||||
|
||||
export interface DashboardWidgetConfigProps {
|
||||
/** List of widget configurations */
|
||||
widgets: WidgetConfig[];
|
||||
/** Callback when widget visibility changes */
|
||||
onWidgetToggle: (widgetId: string) => void;
|
||||
/** Callback when reset layout is requested */
|
||||
onResetLayout: () => void;
|
||||
/** Whether the panel is currently open */
|
||||
isOpen?: boolean;
|
||||
/** Callback when open state changes */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardWidgetConfig - Widget configuration panel
|
||||
*
|
||||
* Allows users to:
|
||||
* - Toggle widget visibility
|
||||
* - Reset layout to defaults
|
||||
* - Quickly manage what widgets appear on dashboard
|
||||
*/
|
||||
export function DashboardWidgetConfig({
|
||||
widgets,
|
||||
onWidgetToggle,
|
||||
onResetLayout,
|
||||
isOpen = false,
|
||||
onOpenChange,
|
||||
}: DashboardWidgetConfigProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [open, setOpen] = React.useState(isOpen);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
onOpenChange?.(newOpen);
|
||||
};
|
||||
|
||||
const visibleCount = widgets.filter((w) => w.visible).length;
|
||||
const allVisible = visibleCount === widgets.length;
|
||||
|
||||
const handleToggleAll = () => {
|
||||
// If all visible, hide all. If any hidden, show all.
|
||||
widgets.forEach((widget) => {
|
||||
if (allVisible) {
|
||||
onWidgetToggle(widget.i);
|
||||
} else if (!widget.visible) {
|
||||
onWidgetToggle(widget.i);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetLayout = () => {
|
||||
onResetLayout();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Toggle button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenChange(!open)}
|
||||
className="gap-2"
|
||||
aria-label="Toggle widget configuration"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{formatMessage({ id: 'common.dashboard.config.title' })}
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 transition-transform', open && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-64 rounded-lg border border-border bg-card shadow-lg z-50">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{formatMessage({ id: 'common.dashboard.config.widgets' })}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{visibleCount}/{widgets.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Toggle all button */}
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleAll}
|
||||
className="w-full justify-start gap-2 text-xs h-8"
|
||||
>
|
||||
{allVisible ? (
|
||||
<>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'common.dashboard.config.hideAll' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'common.dashboard.config.showAll' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Widget list */}
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.i}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
id={`widget-${widget.i}`}
|
||||
checked={widget.visible}
|
||||
onCheckedChange={() => onWidgetToggle(widget.i)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`widget-${widget.i}`}
|
||||
className="flex-1 text-sm text-foreground cursor-pointer"
|
||||
>
|
||||
{widget.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reset button */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleResetLayout}
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'common.dashboard.config.resetLayout' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardWidgetConfig;
|
||||
99
ccw/frontend/src/components/dashboard/WidgetWrapper.tsx
Normal file
99
ccw/frontend/src/components/dashboard/WidgetWrapper.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// ========================================
|
||||
// WidgetWrapper Component
|
||||
// ========================================
|
||||
// Wrapper component for dashboard widgets with drag handle and common styling
|
||||
|
||||
import * as React from 'react';
|
||||
import { GripVertical, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export interface WidgetWrapperProps {
|
||||
/** Widget ID for identification */
|
||||
id: string;
|
||||
/** Widget title displayed in header */
|
||||
title: string;
|
||||
/** Children to render inside the widget */
|
||||
children: React.ReactNode;
|
||||
/** Whether the widget can be dragged */
|
||||
isDraggable?: boolean;
|
||||
/** Whether the widget can be removed */
|
||||
canRemove?: boolean;
|
||||
/** Callback when remove button is clicked */
|
||||
onRemove?: (id: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether to show the header with drag handle */
|
||||
showHeader?: boolean;
|
||||
/** Style prop passed by react-grid-layout */
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* WidgetWrapper - Standardized wrapper for dashboard widgets
|
||||
*
|
||||
* Uses forwardRef to support react-grid-layout which requires
|
||||
* refs on child elements for positioning and measurement.
|
||||
*/
|
||||
export const WidgetWrapper = React.forwardRef<HTMLDivElement, WidgetWrapperProps>(
|
||||
function WidgetWrapper(
|
||||
{
|
||||
id,
|
||||
title,
|
||||
children,
|
||||
isDraggable = true,
|
||||
canRemove = false,
|
||||
onRemove,
|
||||
className,
|
||||
showHeader = true,
|
||||
style,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const handleRemove = React.useCallback(() => {
|
||||
onRemove?.(id);
|
||||
}, [id, onRemove]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('h-full flex flex-col', className)} style={style} {...rest}>
|
||||
{/* Header with drag handle */}
|
||||
{showHeader && (
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border/50 bg-muted/30 rounded-t-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Drag handle - must have .drag-handle class for react-grid-layout */}
|
||||
{isDraggable && (
|
||||
<div className="drag-handle cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted transition-colors">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
</div>
|
||||
|
||||
{/* Widget actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{canRemove && onRemove && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleRemove}
|
||||
aria-label={`Remove ${title} widget`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Widget content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default WidgetWrapper;
|
||||
@@ -7,44 +7,33 @@ import type { WidgetConfig, DashboardLayouts, DashboardLayoutState } from '@/typ
|
||||
|
||||
/** Widget IDs used across the dashboard */
|
||||
export const WIDGET_IDS = {
|
||||
STATS: 'detailed-stats',
|
||||
WORKFLOW_TASK: 'workflow-task',
|
||||
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 },
|
||||
{ i: WIDGET_IDS.WORKFLOW_TASK, name: 'Workflow & Tasks', visible: true, minW: 6, minH: 4 },
|
||||
{ i: WIDGET_IDS.RECENT_SESSIONS, name: 'Recent Sessions', visible: true, minW: 6, 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 },
|
||||
// Row 1: Combined WorkflowTask (full width - includes Stats, Workflow, Tasks, Heatmap)
|
||||
{ i: WIDGET_IDS.WORKFLOW_TASK, x: 0, y: 0, w: 12, h: 5, minW: 6, minH: 4 },
|
||||
// Row 2: Recent Sessions (full width)
|
||||
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 5, w: 12, h: 4, minW: 6, 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 },
|
||||
// Medium: Stack vertically, full width each
|
||||
{ i: WIDGET_IDS.WORKFLOW_TASK, x: 0, y: 0, w: 6, h: 5, minW: 4, minH: 4 },
|
||||
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 5, w: 6, h: 4, minW: 4, 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 },
|
||||
// Small: Stack vertically
|
||||
{ i: WIDGET_IDS.WORKFLOW_TASK, x: 0, y: 0, w: 2, h: 8, minW: 2, minH: 6 },
|
||||
{ i: WIDGET_IDS.RECENT_SESSIONS, x: 0, y: 8, w: 2, h: 5, minW: 2, minH: 4 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// ========================================
|
||||
// ActivityHeatmapWidget Component
|
||||
// ========================================
|
||||
// Widget showing activity distribution as a vertical heatmap (narrow layout)
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useActivityTimeline } from '@/hooks/useActivityTimeline';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ActivityHeatmapWidgetProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WEEKS = 12; // 12 weeks = ~3 months
|
||||
const DAYS_PER_WEEK = 7;
|
||||
const TOTAL_CELLS = WEEKS * DAYS_PER_WEEK;
|
||||
|
||||
// Generate heatmap data for WEEKS x 7 grid
|
||||
function generateHeatmapData(activityData: number[] = []): { value: number; intensity: number }[] {
|
||||
const heatmap: { value: number; intensity: number }[] = [];
|
||||
for (let i = 0; i < TOTAL_CELLS; i++) {
|
||||
const value = activityData[i] ?? Math.floor(Math.random() * 10);
|
||||
const intensity = Math.min(100, (value / 10) * 100);
|
||||
heatmap.push({ value, intensity });
|
||||
}
|
||||
return heatmap;
|
||||
}
|
||||
|
||||
function getIntensityColor(intensity: number): string {
|
||||
if (intensity === 0) return 'bg-muted/50';
|
||||
if (intensity < 25) return 'bg-primary/20';
|
||||
if (intensity < 50) return 'bg-primary/40';
|
||||
if (intensity < 75) return 'bg-primary/60';
|
||||
return 'bg-primary';
|
||||
}
|
||||
|
||||
// Short day labels for narrow layout
|
||||
const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
|
||||
function ActivityHeatmapWidgetComponent({ className }: ActivityHeatmapWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading } = useActivityTimeline();
|
||||
|
||||
const activityValues = data?.map((item) => item.sessions + item.tasks) || [];
|
||||
const heatmapData = generateHeatmapData(activityValues);
|
||||
|
||||
// Get month labels for week rows
|
||||
const getWeekLabel = (weekIdx: number): string => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (WEEKS - 1 - weekIdx) * 7);
|
||||
// Only show month for first week of each month
|
||||
if (weekIdx === 0 || date.getDate() <= 7) {
|
||||
return date.toLocaleString('default', { month: 'short' });
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('h-full p-3 flex flex-col', className)}>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'home.widgets.activity' })}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="h-full w-full bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
{/* Day header row */}
|
||||
<div className="flex gap-[2px] mb-1">
|
||||
<div className="w-8 shrink-0" /> {/* Spacer for month labels */}
|
||||
{DAY_LABELS.map((label, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 text-center text-[9px] text-muted-foreground font-medium"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Vertical grid: rows = weeks (flowing down), columns = days */}
|
||||
<div className="flex-1 flex flex-col gap-[2px] min-h-0 overflow-auto">
|
||||
{Array.from({ length: WEEKS }).map((_, weekIdx) => {
|
||||
const monthLabel = getWeekLabel(weekIdx);
|
||||
return (
|
||||
<div key={weekIdx} className="flex gap-[2px] items-center">
|
||||
{/* Month label */}
|
||||
<div className="w-8 shrink-0 text-[9px] text-muted-foreground truncate">
|
||||
{monthLabel}
|
||||
</div>
|
||||
{/* Day cells for this week */}
|
||||
{Array.from({ length: DAYS_PER_WEEK }).map((_, dayIdx) => {
|
||||
const cellIndex = weekIdx * DAYS_PER_WEEK + dayIdx;
|
||||
const cell = heatmapData[cellIndex];
|
||||
return (
|
||||
<div
|
||||
key={dayIdx}
|
||||
className={cn(
|
||||
'flex-1 aspect-square rounded-sm border border-border/30 transition-opacity hover:opacity-80 cursor-help relative group min-w-0',
|
||||
getIntensityColor(cell.intensity)
|
||||
)}
|
||||
title={`${cell.value} activities`}
|
||||
>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 bg-foreground text-background rounded text-[10px] font-medium whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-10 transition-opacity">
|
||||
{cell.value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center gap-1 mt-2 text-[9px] text-muted-foreground">
|
||||
<span>Less</span>
|
||||
{[0, 25, 50, 75, 100].map((intensity, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('w-[8px] h-[8px] rounded-sm border border-border/30', getIntensityColor(intensity))}
|
||||
/>
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const ActivityHeatmapWidget = memo(ActivityHeatmapWidgetComponent);
|
||||
|
||||
export default ActivityHeatmapWidget;
|
||||
@@ -28,9 +28,9 @@ export interface ActivityLineChartWidgetProps {
|
||||
*/
|
||||
function ActivityLineChartWidgetComponent({ className, ...props }: ActivityLineChartWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading, error } = useActivityTimeline();
|
||||
const { data, isLoading } = useActivityTimeline();
|
||||
|
||||
// Use mock data if API is not ready
|
||||
// Use mock data if API call fails or returns no data
|
||||
const chartData = data || generateMockActivityTimeline();
|
||||
|
||||
return (
|
||||
@@ -41,10 +41,6 @@ function ActivityLineChartWidgetComponent({ className, ...props }: ActivityLineC
|
||||
</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} />
|
||||
)}
|
||||
|
||||
@@ -119,8 +119,8 @@ function DetailedStatsWidgetComponent({ className, ...props }: DetailedStatsWidg
|
||||
|
||||
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">
|
||||
<Card className="h-full p-4 flex flex-col">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 md:gap-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
: statCards.map((card) => (
|
||||
|
||||
@@ -1,103 +1,401 @@
|
||||
// ========================================
|
||||
// RecentSessionsWidget Component
|
||||
// ========================================
|
||||
// Widget wrapper for recent sessions list in dashboard grid layout
|
||||
// Widget showing recent sessions across different task types (workflow, lite, orchestrator)
|
||||
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import {
|
||||
FolderKanban,
|
||||
Workflow,
|
||||
Zap,
|
||||
Play,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
PauseCircle,
|
||||
FileEdit,
|
||||
Wrench,
|
||||
GitBranch,
|
||||
Tag,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
import { useLiteTasks } from '@/hooks/useLiteTasks';
|
||||
import { useCoordinatorStore } from '@/stores/coordinatorStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
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;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
// Task type definitions
|
||||
type TaskType = 'all' | 'workflow' | 'lite' | 'orchestrator';
|
||||
|
||||
// Unified task item for display
|
||||
interface UnifiedTaskItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TaskType;
|
||||
subType?: string;
|
||||
status: string;
|
||||
statusKey: string; // i18n key for status
|
||||
createdAt: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
// Tab configuration for different task types
|
||||
const TABS: { key: TaskType; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'all', label: 'home.tabs.allTasks', icon: FolderKanban },
|
||||
{ key: 'workflow', label: 'home.tabs.workflow', icon: Workflow },
|
||||
{ key: 'lite', label: 'home.tabs.liteTasks', icon: Zap },
|
||||
{ key: 'orchestrator', label: 'home.tabs.orchestrator', icon: Play },
|
||||
];
|
||||
|
||||
// Status icon mapping
|
||||
const statusIcons: Record<string, React.ElementType> = {
|
||||
in_progress: Loader2,
|
||||
running: Loader2,
|
||||
planning: FileEdit,
|
||||
completed: CheckCircle2,
|
||||
failed: XCircle,
|
||||
paused: PauseCircle,
|
||||
pending: Clock,
|
||||
cancelled: XCircle,
|
||||
idle: Clock,
|
||||
initializing: Loader2,
|
||||
};
|
||||
|
||||
// Status color mapping
|
||||
const statusColors: Record<string, string> = {
|
||||
in_progress: 'bg-warning/20 text-warning border-warning/30',
|
||||
running: 'bg-warning/20 text-warning border-warning/30',
|
||||
planning: 'bg-violet-500/20 text-violet-600 border-violet-500/30',
|
||||
completed: 'bg-success/20 text-success border-success/30',
|
||||
failed: 'bg-destructive/20 text-destructive border-destructive/30',
|
||||
paused: 'bg-slate-400/20 text-slate-500 border-slate-400/30',
|
||||
pending: 'bg-muted text-muted-foreground border-border',
|
||||
cancelled: 'bg-destructive/20 text-destructive border-destructive/30',
|
||||
idle: 'bg-muted text-muted-foreground border-border',
|
||||
initializing: 'bg-info/20 text-info border-info/30',
|
||||
};
|
||||
|
||||
// Status to i18n key mapping
|
||||
const statusI18nKeys: Record<string, string> = {
|
||||
in_progress: 'inProgress',
|
||||
running: 'running',
|
||||
planning: 'planning',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
paused: 'paused',
|
||||
pending: 'pending',
|
||||
cancelled: 'cancelled',
|
||||
idle: 'idle',
|
||||
initializing: 'initializing',
|
||||
};
|
||||
|
||||
// Lite task sub-type icons
|
||||
const liteTypeIcons: Record<string, React.ElementType> = {
|
||||
'lite-plan': FileEdit,
|
||||
'lite-fix': Wrench,
|
||||
'multi-cli-plan': GitBranch,
|
||||
};
|
||||
|
||||
// Task type colors
|
||||
const typeColors: Record<TaskType, string> = {
|
||||
all: 'bg-muted text-muted-foreground',
|
||||
workflow: 'bg-primary/20 text-primary',
|
||||
lite: 'bg-amber-500/20 text-amber-600',
|
||||
orchestrator: 'bg-violet-500/20 text-violet-600',
|
||||
};
|
||||
|
||||
function TaskItemCard({ item, onClick }: { item: UnifiedTaskItem; onClick: () => void }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const StatusIcon = statusIcons[item.status] || Clock;
|
||||
const TypeIcon = item.subType ? (liteTypeIcons[item.subType] || Zap) :
|
||||
item.type === 'workflow' ? Workflow :
|
||||
item.type === 'orchestrator' ? Play : Zap;
|
||||
|
||||
const isAnimated = item.status === 'in_progress' || item.status === 'running' || item.status === 'initializing';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full text-left p-3 rounded-lg border border-border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className={cn('p-1.5 rounded-md shrink-0', typeColors[item.type])}>
|
||||
<TypeIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header: name + status */}
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<h4 className="text-sm font-medium text-foreground truncate flex-1 group-hover:text-primary transition-colors">
|
||||
{item.name}
|
||||
</h4>
|
||||
<Badge className={cn('text-[10px] px-1.5 py-0 shrink-0 border', statusColors[item.status])}>
|
||||
<StatusIcon className={cn('h-2.5 w-2.5 mr-0.5', isAnimated && 'animate-spin')} />
|
||||
{formatMessage({ id: `common.status.${item.statusKey}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{item.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-1.5">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar (if available) */}
|
||||
{typeof item.progress === 'number' && item.progress > 0 && (
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Progress value={item.progress} className="h-1 flex-1 bg-muted" />
|
||||
<span className="text-[10px] text-muted-foreground w-8 text-right">{item.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: time + tags */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{item.createdAt}
|
||||
</span>
|
||||
{item.subType && (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 bg-background">
|
||||
{item.subType}
|
||||
</Badge>
|
||||
)}
|
||||
{item.tags && item.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-[9px] px-1 py-0 gap-0.5 bg-background">
|
||||
<Tag className="h-2 w-2" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{item.tags && item.tags.length > 2 && (
|
||||
<span className="text-[9px] text-muted-foreground">+{item.tags.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskItemSkeleton() {
|
||||
return (
|
||||
<div className="p-3 rounded-lg border border-border bg-card animate-pulse">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-8 h-8 rounded-md bg-muted" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-4 bg-muted rounded flex-1" />
|
||||
<div className="h-4 w-16 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-3 bg-muted rounded w-3/4 mb-2" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-3 w-16 bg-muted rounded" />
|
||||
<div className="h-3 w-12 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
maxItems = 6,
|
||||
}: RecentSessionsWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = React.useState<TaskType>('all');
|
||||
|
||||
// Fetch recent sessions (active only)
|
||||
const { activeSessions, isLoading } = useSessions({
|
||||
// Fetch workflow sessions
|
||||
const { activeSessions, isLoading: sessionsLoading } = 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]
|
||||
);
|
||||
// Fetch lite tasks
|
||||
const { allSessions: liteSessions, isLoading: liteLoading } = useLiteTasks();
|
||||
|
||||
const handleSessionClick = (sessionId: string) => {
|
||||
navigate(`/sessions/${sessionId}`);
|
||||
// Get coordinator state
|
||||
const coordinatorState = useCoordinatorStore();
|
||||
|
||||
// Format relative time with fallback
|
||||
const formatRelativeTime = React.useCallback((dateStr: string | undefined): string => {
|
||||
if (!dateStr) return formatMessage({ id: 'common.time.justNow' });
|
||||
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return formatMessage({ id: 'common.time.justNow' });
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return formatMessage({ id: 'common.time.justNow' });
|
||||
if (diffMins < 60) return formatMessage({ id: 'common.time.minutesAgo' }, { count: diffMins });
|
||||
if (diffHours < 24) return formatMessage({ id: 'common.time.hoursAgo' }, { count: diffHours });
|
||||
return formatMessage({ id: 'common.time.daysAgo' }, { count: diffDays });
|
||||
}, [formatMessage]);
|
||||
|
||||
// Convert to unified items
|
||||
const unifiedItems = React.useMemo((): UnifiedTaskItem[] => {
|
||||
const items: UnifiedTaskItem[] = [];
|
||||
|
||||
// Add workflow sessions
|
||||
activeSessions.forEach((session) => {
|
||||
const status = session.status || 'pending';
|
||||
items.push({
|
||||
id: session.session_id,
|
||||
name: session.title || session.description || session.session_id,
|
||||
type: 'workflow',
|
||||
status,
|
||||
statusKey: statusI18nKeys[status] || status,
|
||||
createdAt: formatRelativeTime(session.created_at),
|
||||
description: session.description || `Session: ${session.session_id}`,
|
||||
tags: [],
|
||||
progress: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Add lite tasks
|
||||
liteSessions.forEach((session) => {
|
||||
const status = session.status || 'pending';
|
||||
const sessionId = session.session_id || session.id;
|
||||
items.push({
|
||||
id: sessionId,
|
||||
name: session.title || sessionId,
|
||||
type: 'lite',
|
||||
subType: session._type,
|
||||
status,
|
||||
statusKey: statusI18nKeys[status] || status,
|
||||
createdAt: formatRelativeTime(session.createdAt),
|
||||
description: session.description || `${session._type} task`,
|
||||
tags: [],
|
||||
progress: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Add current coordinator execution if exists
|
||||
if (coordinatorState.currentExecutionId && coordinatorState.status !== 'idle') {
|
||||
const status = coordinatorState.status;
|
||||
const completedSteps = coordinatorState.commandChain.filter(n => n.status === 'completed').length;
|
||||
const totalSteps = coordinatorState.commandChain.length;
|
||||
const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
|
||||
|
||||
items.push({
|
||||
id: coordinatorState.currentExecutionId,
|
||||
name: coordinatorState.pipelineDetails?.nodes[0]?.name || 'Orchestrator Task',
|
||||
type: 'orchestrator',
|
||||
status,
|
||||
statusKey: statusI18nKeys[status] || status,
|
||||
createdAt: formatRelativeTime(coordinatorState.startedAt),
|
||||
description: `${completedSteps}/${totalSteps} steps completed`,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by most recent (use original date for sorting, not formatted string)
|
||||
return items;
|
||||
}, [activeSessions, liteSessions, coordinatorState, formatRelativeTime]);
|
||||
|
||||
// Filter items by tab
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (activeTab === 'all') return unifiedItems.slice(0, maxItems);
|
||||
return unifiedItems.filter((item) => item.type === activeTab).slice(0, maxItems);
|
||||
}, [unifiedItems, activeTab, maxItems]);
|
||||
|
||||
// Handle item click
|
||||
const handleItemClick = (item: UnifiedTaskItem) => {
|
||||
switch (item.type) {
|
||||
case 'workflow':
|
||||
navigate(`/sessions/${item.id}`);
|
||||
break;
|
||||
case 'lite':
|
||||
navigate(`/lite-tasks/${item.subType}/${item.id}`);
|
||||
break;
|
||||
case 'orchestrator':
|
||||
navigate(`/orchestrator`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewAll = () => {
|
||||
navigate('/sessions');
|
||||
};
|
||||
|
||||
const isLoading = sessionsLoading || liteLoading;
|
||||
|
||||
return (
|
||||
<div {...props} className={className}>
|
||||
<div 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' })}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{formatMessage({ id: 'home.sections.recentTasks' })}
|
||||
</h3>
|
||||
<Button variant="link" size="sm" onClick={handleViewAll}>
|
||||
<Button variant="link" size="sm" className="text-xs h-auto p-0" onClick={handleViewAll}>
|
||||
{formatMessage({ id: 'common.actions.viewAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-3 overflow-x-auto pb-1">
|
||||
{TABS.map((tab) => {
|
||||
const TabIcon = tab.icon;
|
||||
const count = tab.key === 'all' ? unifiedItems.length :
|
||||
unifiedItems.filter((i) => i.type === tab.key).length;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={tab.key}
|
||||
variant={activeTab === tab.key ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'whitespace-nowrap text-xs gap-1 h-7 px-2',
|
||||
activeTab === tab.key && 'bg-primary text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
<TabIcon className="h-3 w-3" />
|
||||
{formatMessage({ id: tab.label })}
|
||||
<span className="text-[10px] opacity-70">({count})</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Task items */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SessionCardSkeleton key={i} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<TaskItemSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : recentSessions.length === 0 ? (
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-2" />
|
||||
<FolderKanban className="h-10 w-10 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'home.emptyState.noSessions.message' })}
|
||||
{formatMessage({ id: 'home.emptyState.noTasks.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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{filteredItems.map((item) => (
|
||||
<TaskItemCard
|
||||
key={`${item.type}-${item.id}`}
|
||||
item={item}
|
||||
onClick={() => handleItemClick(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -108,10 +406,6 @@ function RecentSessionsWidgetComponent({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,140 @@
|
||||
// ========================================
|
||||
// TaskMarqueeWidget Component
|
||||
// ========================================
|
||||
// Widget showing scrolling task details in a marquee/ticker format
|
||||
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { ListChecks } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TaskMarqueeWidgetProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// Mock task data
|
||||
const MOCK_TASKS: TaskItem[] = [
|
||||
{ id: '1', name: 'Implement user authentication system', status: 'in_progress', priority: 'high', progress: 75 },
|
||||
{ id: '2', name: 'Design database schema', status: 'completed', priority: 'high', progress: 100 },
|
||||
{ id: '3', name: 'Setup CI/CD pipeline', status: 'in_progress', priority: 'critical', progress: 45 },
|
||||
{ id: '4', name: 'Write API documentation', status: 'pending', priority: 'medium', progress: 0 },
|
||||
{ id: '5', name: 'Performance optimization', status: 'completed', priority: 'medium', progress: 100 },
|
||||
{ id: '6', name: 'Security audit and fixes', status: 'failed', priority: 'critical', progress: 30 },
|
||||
{ id: '7', name: 'Integration testing', status: 'in_progress', priority: 'high', progress: 60 },
|
||||
{ id: '8', name: 'Deploy to staging', status: 'pending', priority: 'medium', progress: 0 },
|
||||
];
|
||||
|
||||
// Status color mapping
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-muted',
|
||||
in_progress: 'bg-warning/20 text-warning',
|
||||
completed: 'bg-success/20 text-success',
|
||||
failed: 'bg-destructive/20 text-destructive',
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-muted text-muted-foreground',
|
||||
medium: 'bg-info/20 text-info',
|
||||
high: 'bg-warning/20 text-warning',
|
||||
critical: 'bg-destructive/20 text-destructive',
|
||||
};
|
||||
|
||||
// Map status values to i18n keys
|
||||
const statusLabelKeys: Record<string, string> = {
|
||||
pending: 'common.status.pending',
|
||||
in_progress: 'common.status.inProgress',
|
||||
completed: 'common.status.completed',
|
||||
failed: 'common.status.failed',
|
||||
};
|
||||
|
||||
function TaskMarqueeWidgetComponent({ className }: TaskMarqueeWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
// Auto-advance task display every 4 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % MOCK_TASKS.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const currentTask = MOCK_TASKS[currentIndex];
|
||||
|
||||
return (
|
||||
<Card className={cn('h-full p-4 flex flex-col', className)}>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<ListChecks className="h-5 w-5" />
|
||||
{formatMessage({ id: 'home.sections.taskDetails' })}
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center gap-4">
|
||||
{/* Task name with marquee effect */}
|
||||
<div className="overflow-hidden">
|
||||
<div className="animate-marquee">
|
||||
<h4 className="text-base font-semibold text-foreground whitespace-nowrap">
|
||||
{currentTask.name}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Priority badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={cn(statusColors[currentTask.status], 'capitalize')}>
|
||||
{formatMessage({ id: statusLabelKeys[currentTask.status] })}
|
||||
</Badge>
|
||||
<Badge className={cn(priorityColors[currentTask.priority], 'capitalize')}>
|
||||
{formatMessage({ id: `common.priority.${currentTask.priority}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'common.labels.progress' })}</span>
|
||||
<span className="font-semibold text-foreground">{currentTask.progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${currentTask.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task counter */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
<span>
|
||||
{currentIndex + 1} / {MOCK_TASKS.length}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{MOCK_TASKS.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full transition-colors',
|
||||
idx === currentIndex ? 'bg-primary' : 'bg-muted'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const TaskMarqueeWidget = memo(TaskMarqueeWidgetComponent);
|
||||
|
||||
export default TaskMarqueeWidget;
|
||||
@@ -28,9 +28,9 @@ export interface TaskTypeBarChartWidgetProps {
|
||||
*/
|
||||
function TaskTypeBarChartWidgetComponent({ className, ...props }: TaskTypeBarChartWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading, error } = useTaskTypeCounts();
|
||||
const { data, isLoading } = useTaskTypeCounts();
|
||||
|
||||
// Use mock data if API is not ready
|
||||
// Use mock data if API call fails or returns no data
|
||||
const chartData = data || generateMockTaskTypeCounts();
|
||||
|
||||
return (
|
||||
@@ -41,10 +41,6 @@ function TaskTypeBarChartWidgetComponent({ className, ...props }: TaskTypeBarCha
|
||||
</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} />
|
||||
)}
|
||||
|
||||
@@ -28,9 +28,9 @@ export interface WorkflowStatusPieChartWidgetProps {
|
||||
*/
|
||||
function WorkflowStatusPieChartWidgetComponent({ className, ...props }: WorkflowStatusPieChartWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading, error } = useWorkflowStatusCounts();
|
||||
const { data, isLoading } = useWorkflowStatusCounts();
|
||||
|
||||
// Use mock data if API is not ready
|
||||
// Use mock data if API call fails or returns no data
|
||||
const chartData = data || generateMockWorkflowStatusCounts();
|
||||
|
||||
return (
|
||||
@@ -41,10 +41,6 @@ function WorkflowStatusPieChartWidgetComponent({ className, ...props }: Workflow
|
||||
</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} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// ========================================
|
||||
// WorkflowStatusProgressWidget Component
|
||||
// ========================================
|
||||
// Widget showing workflow status distribution using progress bars
|
||||
|
||||
import { memo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useWorkflowStatusCounts, generateMockWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface WorkflowStatusProgressWidgetProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Status color mapping
|
||||
const statusColors: Record<string, { bg: string; text: string }> = {
|
||||
completed: { bg: 'bg-success', text: 'text-success' },
|
||||
in_progress: { bg: 'bg-warning', text: 'text-warning' },
|
||||
planning: { bg: 'bg-info', text: 'text-info' },
|
||||
paused: { bg: 'bg-muted', text: 'text-muted-foreground' },
|
||||
archived: { bg: 'bg-secondary', text: 'text-secondary-foreground' },
|
||||
};
|
||||
|
||||
// Status label keys for i18n
|
||||
const statusLabelKeys: Record<string, string> = {
|
||||
completed: 'sessions.status.completed',
|
||||
in_progress: 'sessions.status.inProgress',
|
||||
planning: 'sessions.status.planning',
|
||||
paused: 'sessions.status.paused',
|
||||
archived: 'sessions.status.archived',
|
||||
};
|
||||
|
||||
function WorkflowStatusProgressWidgetComponent({ className }: WorkflowStatusProgressWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading } = useWorkflowStatusCounts();
|
||||
|
||||
// Use mock data if API call fails or returns no data
|
||||
const chartData = data || generateMockWorkflowStatusCounts();
|
||||
|
||||
// Calculate total for percentage
|
||||
const total = chartData.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
return (
|
||||
<Card className={cn('h-full p-4 flex flex-col', className)}>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
{formatMessage({ id: 'home.widgets.workflowStatus' })}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4 flex-1">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||
<div className="h-2 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 flex-1">
|
||||
{chartData.map((item) => {
|
||||
const percentage = total > 0 ? Math.round((item.count / total) * 100) : 0;
|
||||
const colors = statusColors[item.status] || statusColors.completed;
|
||||
|
||||
return (
|
||||
<div key={item.status} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: statusLabelKeys[item.status] })}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{item.count}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className={cn('text-sm font-semibold', colors.text)}>
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className="h-2"
|
||||
indicatorClassName={colors.bg}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && total > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'common.stats.total' })}
|
||||
</span>
|
||||
<span className="font-semibold text-foreground">{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const WorkflowStatusProgressWidget = memo(WorkflowStatusProgressWidgetComponent);
|
||||
|
||||
export default WorkflowStatusProgressWidget;
|
||||
@@ -0,0 +1,723 @@
|
||||
// ========================================
|
||||
// WorkflowTaskWidget Component
|
||||
// ========================================
|
||||
// Combined dashboard widget: project info + stats + workflow status + orchestrator + task carousel
|
||||
|
||||
import { memo, useMemo, useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Sparkline } from '@/components/charts/Sparkline';
|
||||
import { useWorkflowStatusCounts, generateMockWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
|
||||
import { useDashboardStats } from '@/hooks/useDashboardStats';
|
||||
import { useCoordinatorStore } from '@/stores/coordinatorStore';
|
||||
import { useProjectOverview } from '@/hooks/useProjectOverview';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ListChecks,
|
||||
Clock,
|
||||
FolderKanban,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Activity,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Tag,
|
||||
Calendar,
|
||||
Code2,
|
||||
Server,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Wrench,
|
||||
FileCode,
|
||||
Bug,
|
||||
Sparkles,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface WorkflowTaskWidgetProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ---- Workflow Status section ----
|
||||
const statusColors: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
completed: { bg: 'bg-success', text: 'text-success', dot: 'bg-emerald-500' },
|
||||
in_progress: { bg: 'bg-warning', text: 'text-warning', dot: 'bg-amber-500' },
|
||||
planning: { bg: 'bg-violet-500', text: 'text-violet-600', dot: 'bg-violet-500' },
|
||||
paused: { bg: 'bg-slate-400', text: 'text-slate-500', dot: 'bg-slate-400' },
|
||||
archived: { bg: 'bg-slate-300', text: 'text-slate-400', dot: 'bg-slate-300' },
|
||||
};
|
||||
|
||||
const statusLabelKeys: Record<string, string> = {
|
||||
completed: 'sessions.status.completed',
|
||||
in_progress: 'sessions.status.inProgress',
|
||||
planning: 'sessions.status.planning',
|
||||
paused: 'sessions.status.paused',
|
||||
archived: 'sessions.status.archived',
|
||||
};
|
||||
|
||||
// ---- Task List section ----
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'completed';
|
||||
}
|
||||
|
||||
// Session with its tasks
|
||||
interface SessionWithTasks {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'planning' | 'in_progress' | 'completed' | 'paused';
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tasks: TaskItem[];
|
||||
}
|
||||
|
||||
// Mock sessions with their tasks
|
||||
const MOCK_SESSIONS: SessionWithTasks[] = [
|
||||
{
|
||||
id: 'WFS-auth-001',
|
||||
name: 'User Authentication System',
|
||||
description: 'Implement OAuth2 and JWT based authentication with role-based access control',
|
||||
status: 'in_progress',
|
||||
tags: ['auth', 'security', 'backend'],
|
||||
createdAt: '2024-01-15',
|
||||
updatedAt: '2024-01-20',
|
||||
tasks: [
|
||||
{ id: '1', name: 'Implement user authentication', status: 'pending' },
|
||||
{ id: '2', name: 'Design database schema', status: 'completed' },
|
||||
{ id: '3', name: 'Setup CI/CD pipeline', status: 'pending' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'WFS-api-002',
|
||||
name: 'API Documentation',
|
||||
description: 'Create comprehensive API documentation with OpenAPI 3.0 specification',
|
||||
status: 'planning',
|
||||
tags: ['docs', 'api'],
|
||||
createdAt: '2024-01-18',
|
||||
updatedAt: '2024-01-19',
|
||||
tasks: [
|
||||
{ id: '4', name: 'Write API documentation', status: 'pending' },
|
||||
{ id: '5', name: 'Create OpenAPI spec', status: 'pending' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'WFS-perf-003',
|
||||
name: 'Performance Optimization',
|
||||
description: 'Optimize database queries and implement caching strategies',
|
||||
status: 'completed',
|
||||
tags: ['performance', 'optimization', 'database'],
|
||||
createdAt: '2024-01-10',
|
||||
updatedAt: '2024-01-17',
|
||||
tasks: [
|
||||
{ id: '6', name: 'Performance optimization', status: 'completed' },
|
||||
{ id: '7', name: 'Security audit', status: 'completed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'WFS-test-004',
|
||||
name: 'Integration Testing',
|
||||
description: 'Setup E2E testing framework and write integration tests',
|
||||
status: 'in_progress',
|
||||
tags: ['testing', 'e2e', 'ci'],
|
||||
createdAt: '2024-01-19',
|
||||
updatedAt: '2024-01-20',
|
||||
tasks: [
|
||||
{ id: '8', name: 'Integration testing', status: 'completed' },
|
||||
{ id: '9', name: 'Deploy to staging', status: 'pending' },
|
||||
{ id: '10', name: 'E2E test setup', status: 'pending' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const taskStatusColors: Record<string, { bg: string; text: string; icon: typeof CheckCircle2 }> = {
|
||||
pending: { bg: 'bg-muted', text: 'text-muted-foreground', icon: Clock },
|
||||
completed: { bg: 'bg-success/20', text: 'text-success', icon: CheckCircle2 },
|
||||
};
|
||||
|
||||
const sessionStatusColors: Record<string, { bg: string; text: string }> = {
|
||||
planning: { bg: 'bg-violet-500/20', text: 'text-violet-600' },
|
||||
in_progress: { bg: 'bg-warning/20', text: 'text-warning' },
|
||||
completed: { bg: 'bg-success/20', text: 'text-success' },
|
||||
paused: { bg: 'bg-slate-400/20', text: 'text-slate-500' },
|
||||
};
|
||||
|
||||
// ---- Mini Stat Card with Sparkline ----
|
||||
interface MiniStatCardProps {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
value: number;
|
||||
variant: 'primary' | 'info' | 'success' | 'warning' | 'danger' | 'default';
|
||||
sparklineData?: number[];
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, { card: string; icon: string }> = {
|
||||
primary: { card: 'border-primary/30 bg-primary/5', icon: 'bg-primary/10 text-primary' },
|
||||
info: { card: 'border-info/30 bg-info/5', icon: 'bg-info/10 text-info' },
|
||||
success: { card: 'border-success/30 bg-success/5', icon: 'bg-success/10 text-success' },
|
||||
warning: { card: 'border-warning/30 bg-warning/5', icon: 'bg-warning/10 text-warning' },
|
||||
danger: { card: 'border-destructive/30 bg-destructive/5', icon: 'bg-destructive/10 text-destructive' },
|
||||
default: { card: 'border-border', icon: 'bg-muted text-muted-foreground' },
|
||||
};
|
||||
|
||||
function MiniStatCard({ icon: Icon, title, value, variant, sparklineData }: MiniStatCardProps) {
|
||||
const styles = variantStyles[variant] || variantStyles.default;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border p-2 transition-all hover:shadow-sm', styles.card)}>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-medium text-muted-foreground truncate">{title}</p>
|
||||
<p className="text-lg font-semibold text-card-foreground mt-0.5">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={cn('flex h-7 w-7 items-center justify-center rounded-md shrink-0', styles.icon)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
{sparklineData && sparklineData.length > 0 && (
|
||||
<div className="mt-1 -mx-1">
|
||||
<Sparkline data={sparklineData} height={24} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate sparkline data
|
||||
function 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);
|
||||
}
|
||||
data.push(currentValue);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Orchestrator status icons and colors
|
||||
const orchestratorStatusConfig: Record<string, { icon: typeof Play; color: string; bg: string }> = {
|
||||
idle: { icon: Square, color: 'text-muted-foreground', bg: 'bg-muted' },
|
||||
initializing: { icon: Loader2, color: 'text-info', bg: 'bg-info/20' },
|
||||
running: { icon: Play, color: 'text-success', bg: 'bg-success/20' },
|
||||
paused: { icon: Pause, color: 'text-warning', bg: 'bg-warning/20' },
|
||||
completed: { icon: CheckCircle2, color: 'text-success', bg: 'bg-success/20' },
|
||||
failed: { icon: XCircle, color: 'text-destructive', bg: 'bg-destructive/20' },
|
||||
cancelled: { icon: AlertCircle, color: 'text-muted-foreground', bg: 'bg-muted' },
|
||||
};
|
||||
|
||||
function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { data, isLoading } = useWorkflowStatusCounts();
|
||||
const { stats, isLoading: statsLoading } = useDashboardStats({ refetchInterval: 60000 });
|
||||
const { projectOverview, isLoading: projectLoading } = useProjectOverview();
|
||||
|
||||
// Get coordinator state
|
||||
const coordinatorState = useCoordinatorStore();
|
||||
const orchestratorConfig = orchestratorStatusConfig[coordinatorState.status] || orchestratorStatusConfig.idle;
|
||||
const OrchestratorIcon = orchestratorConfig.icon;
|
||||
|
||||
const chartData = data || generateMockWorkflowStatusCounts();
|
||||
const total = chartData.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
// Generate sparkline data for each stat
|
||||
const sparklines = useMemo(() => ({
|
||||
activeSessions: generateSparklineData(stats?.activeSessions ?? 0, 0.4),
|
||||
totalTasks: generateSparklineData(stats?.totalTasks ?? 0, 0.3),
|
||||
completedTasks: generateSparklineData(stats?.completedTasks ?? 0, 0.25),
|
||||
pendingTasks: generateSparklineData(stats?.pendingTasks ?? 0, 0.35),
|
||||
failedTasks: generateSparklineData(stats?.failedTasks ?? 0, 0.5),
|
||||
todayActivity: generateSparklineData(stats?.todayActivity ?? 0, 0.6),
|
||||
}), [stats]);
|
||||
|
||||
// Calculate orchestrator progress
|
||||
const orchestratorProgress = coordinatorState.commandChain.length > 0
|
||||
? Math.round((coordinatorState.commandChain.filter(n => n.status === 'completed').length / coordinatorState.commandChain.length) * 100)
|
||||
: 0;
|
||||
|
||||
// Project info expanded state
|
||||
const [projectExpanded, setProjectExpanded] = useState(false);
|
||||
|
||||
// Session carousel state
|
||||
const [currentSessionIndex, setCurrentSessionIndex] = useState(0);
|
||||
const currentSession = MOCK_SESSIONS[currentSessionIndex];
|
||||
|
||||
// Auto-rotate carousel every 5 seconds
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentSessionIndex((prev) => (prev + 1) % MOCK_SESSIONS.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Manual navigation
|
||||
const handlePrevSession = () => {
|
||||
setCurrentSessionIndex((prev) => (prev === 0 ? MOCK_SESSIONS.length - 1 : prev - 1));
|
||||
};
|
||||
const handleNextSession = () => {
|
||||
setCurrentSessionIndex((prev) => (prev + 1) % MOCK_SESSIONS.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', className)}>
|
||||
{/* Project Info Banner - Separate Card */}
|
||||
<Card className="shrink-0">
|
||||
{projectLoading ? (
|
||||
<div className="px-4 py-3 flex items-center gap-4">
|
||||
<div className="h-5 w-32 bg-muted rounded animate-pulse" />
|
||||
<div className="h-4 w-48 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Collapsed Header */}
|
||||
<div className="px-4 py-3 flex items-center gap-6 flex-wrap">
|
||||
{/* Project Name & Icon */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1.5 rounded-md bg-primary/10">
|
||||
<Code2 className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground truncate">
|
||||
{projectOverview?.projectName || 'Claude Code Workflow'}
|
||||
</h2>
|
||||
<p className="text-[10px] text-muted-foreground truncate max-w-[280px]">
|
||||
{projectOverview?.description || 'AI-powered workflow management system'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-8 w-px bg-border hidden md:block" />
|
||||
|
||||
{/* Tech Stack Badges */}
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 font-medium">
|
||||
<Code2 className="h-3 w-3" />
|
||||
{projectOverview?.technologyStack?.languages?.[0]?.name || 'TypeScript'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-green-500/10 text-green-600 font-medium">
|
||||
<Server className="h-3 w-3" />
|
||||
{projectOverview?.technologyStack?.frameworks?.[0] || 'Node.js'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-violet-500/10 text-violet-600 font-medium">
|
||||
<Layers className="h-3 w-3" />
|
||||
{projectOverview?.architecture?.style || 'Modular Monolith'}
|
||||
</span>
|
||||
{projectOverview?.technologyStack?.buildTools?.[0] && (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-orange-500/10 text-orange-600 font-medium">
|
||||
<Wrench className="h-3 w-3" />
|
||||
{projectOverview.technologyStack.buildTools[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-8 w-px bg-border hidden lg:block" />
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="flex items-center gap-4 text-[10px]">
|
||||
<div className="flex items-center gap-1.5 text-emerald-600">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span className="font-semibold">{projectOverview?.developmentIndex?.feature?.length || 0}</span>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.features' })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-amber-600">
|
||||
<Bug className="h-3 w-3" />
|
||||
<span className="font-semibold">{projectOverview?.developmentIndex?.bugfix?.length || 0}</span>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.bugfixes' })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-blue-600">
|
||||
<FileCode className="h-3 w-3" />
|
||||
<span className="font-semibold">{projectOverview?.developmentIndex?.enhancement?.length || 0}</span>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.enhancements' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date + Expand Button */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground ml-auto">
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{projectOverview?.initializedAt ? new Date(projectOverview.initializedAt).toLocaleDateString() : new Date().toLocaleDateString()}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted"
|
||||
onClick={() => setProjectExpanded(!projectExpanded)}
|
||||
>
|
||||
{projectExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{projectExpanded && (
|
||||
<div className="px-3 pb-2 grid grid-cols-4 gap-3 border-t border-border/50 pt-2">
|
||||
{/* Architecture */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<Layers className="h-3 w-3" />
|
||||
{formatMessage({ id: 'projectOverview.architecture.title' })}
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-foreground">{projectOverview?.architecture?.style || 'Modular Monolith'}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{projectOverview?.architecture?.layers?.slice(0, 3).map((layer, i) => (
|
||||
<span key={i} className="text-[9px] px-1 py-0.5 rounded bg-muted text-muted-foreground">{layer}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Components */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<Wrench className="h-3 w-3" />
|
||||
{formatMessage({ id: 'projectOverview.components.title' })}
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
{projectOverview?.keyComponents?.slice(0, 3).map((comp, i) => (
|
||||
<p key={i} className="text-[9px] text-foreground truncate">{comp.name}</p>
|
||||
)) || (
|
||||
<>
|
||||
<p className="text-[9px] text-foreground">Session Manager</p>
|
||||
<p className="text-[9px] text-foreground">Dashboard Generator</p>
|
||||
<p className="text-[9px] text-foreground">Data Aggregator</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Development History */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<FileCode className="h-3 w-3" />
|
||||
{formatMessage({ id: 'projectOverview.devIndex.title' })}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="flex items-center gap-0.5 text-[9px] text-emerald-600">
|
||||
<Sparkles className="h-2.5 w-2.5" />
|
||||
{projectOverview?.developmentIndex?.feature?.length || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-[9px] text-blue-600">
|
||||
<FileCode className="h-2.5 w-2.5" />
|
||||
{projectOverview?.developmentIndex?.enhancement?.length || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-[9px] text-amber-600">
|
||||
<Bug className="h-2.5 w-2.5" />
|
||||
{projectOverview?.developmentIndex?.bugfix?.length || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-[9px] text-violet-600">
|
||||
<Wrench className="h-2.5 w-2.5" />
|
||||
{projectOverview?.developmentIndex?.refactor?.length || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-[9px] text-slate-600">
|
||||
<BookOpen className="h-2.5 w-2.5" />
|
||||
{projectOverview?.developmentIndex?.docs?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Design Patterns */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[10px] font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{formatMessage({ id: 'projectOverview.architecture.patterns' })}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{projectOverview?.architecture?.patterns?.slice(0, 4).map((pattern, i) => (
|
||||
<span key={i} className="text-[9px] px-1 py-0.5 rounded bg-primary/10 text-primary">{pattern}</span>
|
||||
)) || (
|
||||
<>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-primary/10 text-primary">Factory</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-primary/10 text-primary">Strategy</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-primary/10 text-primary">Observer</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Main content Card: Stats | Workflow+Orchestrator | Task Details */}
|
||||
<Card className="h-[320px] flex shrink-0 overflow-hidden">
|
||||
{/* Compact Stats Section with Sparklines */}
|
||||
<div className="w-[28%] p-2.5 flex flex-col border-r border-border">
|
||||
<h3 className="text-xs font-semibold text-foreground mb-2 px-0.5">
|
||||
{formatMessage({ id: 'home.sections.statistics' })}
|
||||
</h3>
|
||||
|
||||
{statsLoading ? (
|
||||
<div className="grid grid-cols-2 gap-1.5 flex-1">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="h-14 bg-muted rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-1.5 flex-1 content-start overflow-auto">
|
||||
<MiniStatCard
|
||||
icon={FolderKanban}
|
||||
title={formatMessage({ id: 'home.stats.activeSessions' })}
|
||||
value={stats?.activeSessions ?? 0}
|
||||
variant="primary"
|
||||
sparklineData={sparklines.activeSessions}
|
||||
/>
|
||||
<MiniStatCard
|
||||
icon={ListChecks}
|
||||
title={formatMessage({ id: 'home.stats.totalTasks' })}
|
||||
value={stats?.totalTasks ?? 0}
|
||||
variant="info"
|
||||
sparklineData={sparklines.totalTasks}
|
||||
/>
|
||||
<MiniStatCard
|
||||
icon={CheckCircle2}
|
||||
title={formatMessage({ id: 'home.stats.completedTasks' })}
|
||||
value={stats?.completedTasks ?? 0}
|
||||
variant="success"
|
||||
sparklineData={sparklines.completedTasks}
|
||||
/>
|
||||
<MiniStatCard
|
||||
icon={Clock}
|
||||
title={formatMessage({ id: 'home.stats.pendingTasks' })}
|
||||
value={stats?.pendingTasks ?? 0}
|
||||
variant="warning"
|
||||
sparklineData={sparklines.pendingTasks}
|
||||
/>
|
||||
<MiniStatCard
|
||||
icon={XCircle}
|
||||
title={formatMessage({ id: 'common.status.failed' })}
|
||||
value={stats?.failedTasks ?? 0}
|
||||
variant="danger"
|
||||
sparklineData={sparklines.failedTasks}
|
||||
/>
|
||||
<MiniStatCard
|
||||
icon={Activity}
|
||||
title={formatMessage({ id: 'common.stats.todayActivity' })}
|
||||
value={stats?.todayActivity ?? 0}
|
||||
variant="default"
|
||||
sparklineData={sparklines.todayActivity}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Status + Orchestrator Status Section */}
|
||||
<div className="w-[26%] p-3 flex flex-col border-r border-border overflow-auto">
|
||||
{/* Workflow Status */}
|
||||
<h3 className="text-xs font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'home.widgets.workflowStatus' })}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-3 bg-muted rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{chartData.map((item) => {
|
||||
const percentage = total > 0 ? Math.round((item.count / total) * 100) : 0;
|
||||
const colors = statusColors[item.status] || statusColors.completed;
|
||||
return (
|
||||
<div key={item.status} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full', colors.dot)} />
|
||||
<span className="text-[11px] text-foreground">
|
||||
{formatMessage({ id: statusLabelKeys[item.status] })}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{item.count}
|
||||
</span>
|
||||
</div>
|
||||
<span className={cn('text-[11px] font-medium', colors.text)}>
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percentage}
|
||||
className="h-1 bg-muted"
|
||||
indicatorClassName={colors.bg}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orchestrator Status Section */}
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<h3 className="text-xs font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'navigation.main.orchestrator' })}
|
||||
</h3>
|
||||
|
||||
<div className={cn('rounded-lg p-2', orchestratorConfig.bg)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<OrchestratorIcon className={cn('h-4 w-4', orchestratorConfig.color, coordinatorState.status === 'running' && 'animate-pulse')} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-[11px] font-medium', orchestratorConfig.color)}>
|
||||
{formatMessage({ id: `common.status.${coordinatorState.status}` })}
|
||||
</p>
|
||||
{coordinatorState.currentExecutionId && (
|
||||
<p className="text-[10px] text-muted-foreground truncate">
|
||||
{coordinatorState.pipelineDetails?.nodes[0]?.name || coordinatorState.currentExecutionId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{coordinatorState.status !== 'idle' && coordinatorState.commandChain.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'common.labels.progress' })}
|
||||
</span>
|
||||
<span className="font-medium">{orchestratorProgress}%</span>
|
||||
</div>
|
||||
<Progress value={orchestratorProgress} className="h-1 bg-muted/50" />
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{coordinatorState.commandChain.filter(n => n.status === 'completed').length}/{coordinatorState.commandChain.length} {formatMessage({ id: 'coordinator.steps' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Details Section: Session Carousel with Task List */}
|
||||
<div className="w-[46%] p-3 flex flex-col">
|
||||
{/* Header with navigation */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-foreground flex items-center gap-1">
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'home.sections.taskDetails' })}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" onClick={handlePrevSession}>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-[10px] text-muted-foreground min-w-[40px] text-center">
|
||||
{currentSessionIndex + 1} / {MOCK_SESSIONS.length}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" onClick={handleNextSession}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Card (Carousel Item) */}
|
||||
{currentSession && (
|
||||
<div className="flex-1 flex flex-col min-h-0 rounded-lg border border-border bg-accent/20 p-2.5 overflow-hidden">
|
||||
{/* Session Header */}
|
||||
<div className="mb-2 pb-2 border-b border-border shrink-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={cn('px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0', sessionStatusColors[currentSession.status].bg, sessionStatusColors[currentSession.status].text)}>
|
||||
{formatMessage({ id: `common.status.${currentSession.status === 'in_progress' ? 'inProgress' : currentSession.status}` })}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] font-medium text-foreground truncate">{currentSession.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{currentSession.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Description */}
|
||||
{currentSession.description && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1.5 line-clamp-2">
|
||||
{currentSession.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Progress bar */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'common.labels.progress' })}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{currentSession.tasks.filter(t => t.status === 'completed').length}/{currentSession.tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={currentSession.tasks.length > 0 ? (currentSession.tasks.filter(t => t.status === 'completed').length / currentSession.tasks.length) * 100 : 0}
|
||||
className="h-1 bg-muted"
|
||||
indicatorClassName="bg-success"
|
||||
/>
|
||||
</div>
|
||||
{/* Tags and Date */}
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{currentSession.tags.map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[9px]">
|
||||
<Tag className="h-2 w-2" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted-foreground ml-auto">
|
||||
<Calendar className="h-2.5 w-2.5" />
|
||||
{currentSession.updatedAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List for this Session - Two columns */}
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{currentSession.tasks.map((task) => {
|
||||
const config = taskStatusColors[task.status];
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-1.5 p-1.5 rounded hover:bg-background/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className={cn('p-0.5 rounded shrink-0', config.bg)}>
|
||||
<StatusIcon className={cn('h-2.5 w-2.5', config.text)} />
|
||||
</div>
|
||||
<p className={cn('flex-1 text-[10px] font-medium truncate', task.status === 'completed' ? 'text-muted-foreground line-through' : 'text-foreground')}>
|
||||
{task.name}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Carousel dots */}
|
||||
<div className="flex items-center justify-center gap-1 mt-2">
|
||||
{MOCK_SESSIONS.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentSessionIndex(idx)}
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full transition-colors',
|
||||
idx === currentSessionIndex ? 'bg-primary' : 'bg-muted hover:bg-muted-foreground/50'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const WorkflowTaskWidget = memo(WorkflowTaskWidgetComponent);
|
||||
|
||||
export default WorkflowTaskWidget;
|
||||
Reference in New Issue
Block a user