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:
catlog22
2026-02-03 17:28:26 +08:00
parent b63e254f36
commit 37ba849e75
101 changed files with 10422 additions and 1145 deletions

View File

@@ -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;