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

@@ -10,7 +10,7 @@ import {
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import {
ProviderList,
ProviderModal,
@@ -198,26 +198,21 @@ export function ApiSettingsPage() {
</div>
{/* Tabbed Interface */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabType)}>
<TabsList>
<TabsTrigger value="providers">
{formatMessage({ id: 'apiSettings.tabs.providers' })}
</TabsTrigger>
<TabsTrigger value="endpoints">
{formatMessage({ id: 'apiSettings.tabs.endpoints' })}
</TabsTrigger>
<TabsTrigger value="cache">
{formatMessage({ id: 'apiSettings.tabs.cache' })}
</TabsTrigger>
<TabsTrigger value="modelPools">
{formatMessage({ id: 'apiSettings.tabs.modelPools' })}
</TabsTrigger>
<TabsTrigger value="cliSettings">
{formatMessage({ id: 'apiSettings.tabs.cliSettings' })}
</TabsTrigger>
</TabsList>
<TabsNavigation
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabType)}
tabs={[
{ value: 'providers', label: formatMessage({ id: 'apiSettings.tabs.providers' }) },
{ value: 'endpoints', label: formatMessage({ id: 'apiSettings.tabs.endpoints' }) },
{ value: 'cache', label: formatMessage({ id: 'apiSettings.tabs.cache' }) },
{ value: 'modelPools', label: formatMessage({ id: 'apiSettings.tabs.modelPools' }) },
{ value: 'cliSettings', label: formatMessage({ id: 'apiSettings.tabs.cliSettings' }) },
]}
/>
<TabsContent value="providers">
{/* Tab Content */}
{activeTab === 'providers' && (
<div className="mt-4">
<ProviderList
onAddProvider={handleAddProvider}
onEditProvider={handleEditProvider}
@@ -225,33 +220,41 @@ export function ApiSettingsPage() {
onSyncToCodexLens={handleSyncToCodexLens}
onManageModels={handleManageModels}
/>
</TabsContent>
</div>
)}
<TabsContent value="endpoints">
{activeTab === 'endpoints' && (
<div className="mt-4">
<EndpointList
onAddEndpoint={handleAddEndpoint}
onEditEndpoint={handleEditEndpoint}
/>
</TabsContent>
</div>
)}
<TabsContent value="cache">
{activeTab === 'cache' && (
<div className="mt-4">
<CacheSettings />
</TabsContent>
</div>
)}
<TabsContent value="modelPools">
{activeTab === 'modelPools' && (
<div className="mt-4">
<ModelPoolList
onAddPool={handleAddPool}
onEditPool={handleEditPool}
/>
</TabsContent>
</div>
)}
<TabsContent value="cliSettings">
{activeTab === 'cliSettings' && (
<div className="mt-4">
<CliSettingsList
onAddCliSettings={handleAddCliSettings}
onEditCliSettings={handleEditCliSettings}
/>
</TabsContent>
</Tabs>
</div>
)}
{/* Modals */}
<ProviderModal

View File

@@ -0,0 +1,266 @@
// ========================================
// CLI Viewer Page
// ========================================
// Multi-pane CLI output viewer with configurable layouts
// Integrates with viewerStore for state management
import { useEffect, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
Terminal,
LayoutGrid,
Columns,
Rows,
Square,
ChevronDown,
RotateCcw,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
import { cn } from '@/lib/utils';
import { LayoutContainer } from '@/components/cli-viewer';
import {
useViewerStore,
useViewerLayout,
useViewerPanes,
useFocusedPaneId,
type AllotmentLayout,
} from '@/stores/viewerStore';
// ========================================
// Types
// ========================================
export type LayoutType = 'single' | 'split-h' | 'split-v' | 'grid-2x2';
interface LayoutOption {
id: LayoutType;
icon: React.ElementType;
labelKey: string;
}
// ========================================
// Constants
// ========================================
const LAYOUT_OPTIONS: LayoutOption[] = [
{ id: 'single', icon: Square, labelKey: 'cliViewer.layout.single' },
{ id: 'split-h', icon: Columns, labelKey: 'cliViewer.layout.splitH' },
{ id: 'split-v', icon: Rows, labelKey: 'cliViewer.layout.splitV' },
{ id: 'grid-2x2', icon: LayoutGrid, labelKey: 'cliViewer.layout.grid' },
];
const DEFAULT_LAYOUT: LayoutType = 'split-h';
// ========================================
// Helper Functions
// ========================================
/**
* Detect layout type from AllotmentLayout structure
*/
function detectLayoutType(layout: AllotmentLayout): LayoutType {
const childCount = layout.children.length;
// Empty or single pane
if (childCount === 0 || childCount === 1) {
return 'single';
}
// Two panes at root level
if (childCount === 2) {
const hasNestedGroups = layout.children.some(
(child) => typeof child !== 'string'
);
// If no nested groups, it's a simple split
if (!hasNestedGroups) {
return layout.direction === 'horizontal' ? 'split-h' : 'split-v';
}
// Check for grid layout (2x2)
const allNested = layout.children.every(
(child) => typeof child !== 'string'
);
if (allNested) {
return 'grid-2x2';
}
}
// Default to current direction
return layout.direction === 'horizontal' ? 'split-h' : 'split-v';
}
/**
* Count total panes in layout
*/
function countPanes(layout: AllotmentLayout): number {
let count = 0;
const traverse = (children: (string | AllotmentLayout)[]) => {
for (const child of children) {
if (typeof child === 'string') {
count++;
} else {
traverse(child.children);
}
}
};
traverse(layout.children);
return count;
}
// ========================================
// Main Component
// ========================================
export function CliViewerPage() {
const { formatMessage } = useIntl();
const [searchParams, setSearchParams] = useSearchParams();
// Store hooks
const layout = useViewerLayout();
const panes = useViewerPanes();
const focusedPaneId = useFocusedPaneId();
const { initializeDefaultLayout, addTab, reset } = useViewerStore();
// Detect current layout type from store
const currentLayoutType = useMemo(() => detectLayoutType(layout), [layout]);
// Count active sessions (tabs across all panes)
const activeSessionCount = useMemo(() => {
return Object.values(panes).reduce((count, pane) => count + pane.tabs.length, 0);
}, [panes]);
// Initialize layout if empty
useEffect(() => {
const paneCount = countPanes(layout);
if (paneCount === 0) {
initializeDefaultLayout(DEFAULT_LAYOUT);
}
}, [layout, initializeDefaultLayout]);
// Handle executionId from URL params
useEffect(() => {
const executionId = searchParams.get('executionId');
if (executionId && focusedPaneId) {
// Add tab to focused pane
addTab(focusedPaneId, executionId, `Execution ${executionId.slice(0, 8)}`);
// Clear the URL param after processing
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('executionId');
return newParams;
});
}
}, [searchParams, focusedPaneId, addTab, setSearchParams]);
// Handle layout change
const handleLayoutChange = useCallback(
(layoutType: LayoutType) => {
initializeDefaultLayout(layoutType);
},
[initializeDefaultLayout]
);
// Handle reset
const handleReset = useCallback(() => {
reset();
initializeDefaultLayout(DEFAULT_LAYOUT);
}, [reset, initializeDefaultLayout]);
// Get current layout option for display
const currentLayoutOption =
LAYOUT_OPTIONS.find((l) => l.id === currentLayoutType) || LAYOUT_OPTIONS[1];
const CurrentLayoutIcon = currentLayoutOption.icon;
return (
<div className="h-full flex flex-col -m-4 md:-m-6">
{/* ======================================== */}
{/* Toolbar */}
{/* ======================================== */}
<div className="flex items-center justify-between gap-3 p-3 bg-card border-b border-border">
{/* Page Title */}
<div className="flex items-center gap-2 min-w-0">
<Terminal className="w-5 h-5 text-primary flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'cliViewer.page.title' })}
</span>
<span className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'cliViewer.page.subtitle' },
{ count: activeSessionCount }
)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Reset Button */}
<Button
variant="ghost"
size="sm"
onClick={handleReset}
title={formatMessage({ id: 'cliViewer.toolbar.clearAll' })}
>
<RotateCcw className="w-4 h-4" />
</Button>
{/* Layout Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CurrentLayoutIcon className="w-4 h-4" />
<span className="hidden sm:inline">
{formatMessage({ id: currentLayoutOption.labelKey })}
</span>
<ChevronDown className="w-4 h-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{formatMessage({ id: 'cliViewer.layout.title' })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{LAYOUT_OPTIONS.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.id}
onClick={() => handleLayoutChange(option.id)}
className={cn(
'gap-2',
currentLayoutType === option.id && 'bg-accent'
)}
>
<Icon className="w-4 h-4" />
{formatMessage({ id: option.labelKey })}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ======================================== */}
{/* Layout Container */}
{/* ======================================== */}
<div className="flex-1 min-h-0 bg-background">
<LayoutContainer />
</div>
</div>
);
}
export default CliViewerPage;

View File

@@ -15,7 +15,7 @@ import {
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import {
AlertDialog,
AlertDialogTrigger,
@@ -176,26 +176,21 @@ export function CodexLensManagerPage() {
)}
{/* Tabbed Interface */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="overview">
{formatMessage({ id: 'codexlens.tabs.overview' })}
</TabsTrigger>
<TabsTrigger value="settings">
{formatMessage({ id: 'codexlens.tabs.settings' })}
</TabsTrigger>
<TabsTrigger value="models">
{formatMessage({ id: 'codexlens.tabs.models' })}
</TabsTrigger>
<TabsTrigger value="search">
{formatMessage({ id: 'codexlens.tabs.search' })}
</TabsTrigger>
<TabsTrigger value="advanced">
{formatMessage({ id: 'codexlens.tabs.advanced' })}
</TabsTrigger>
</TabsList>
<TabsNavigation
value={activeTab}
onValueChange={setActiveTab}
tabs={[
{ value: 'overview', label: formatMessage({ id: 'codexlens.tabs.overview' }) },
{ value: 'settings', label: formatMessage({ id: 'codexlens.tabs.settings' }) },
{ value: 'models', label: formatMessage({ id: 'codexlens.tabs.models' }) },
{ value: 'search', label: formatMessage({ id: 'codexlens.tabs.search' }) },
{ value: 'advanced', label: formatMessage({ id: 'codexlens.tabs.advanced' }) },
]}
/>
<TabsContent value="overview">
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="mt-4">
<OverviewTab
installed={installed}
status={status}
@@ -203,24 +198,32 @@ export function CodexLensManagerPage() {
isLoading={isLoading}
onRefresh={handleRefresh}
/>
</TabsContent>
</div>
)}
<TabsContent value="settings">
{activeTab === 'settings' && (
<div className="mt-4">
<SettingsTab enabled={installed} />
</TabsContent>
</div>
)}
<TabsContent value="models">
{activeTab === 'models' && (
<div className="mt-4">
<ModelsTab installed={installed} />
</TabsContent>
</div>
)}
<TabsContent value="search">
{activeTab === 'search' && (
<div className="mt-4">
<SearchTab enabled={installed} />
</TabsContent>
</div>
)}
<TabsContent value="advanced">
{activeTab === 'advanced' && (
<div className="mt-4">
<AdvancedTab enabled={installed} />
</TabsContent>
</Tabs>
</div>
)}
{/* Semantic Install Dialog */}
<SemanticInstallDialog

View File

@@ -5,6 +5,7 @@
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom';
import {
Activity,
Clock,
@@ -16,10 +17,12 @@ import {
ListTree,
History,
List,
Monitor,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { ExecutionMonitor } from './orchestrator/ExecutionMonitor';
import { useExecutionStore } from '@/stores/executionStore';
import type { ExecutionStatus } from '@/types/execution';
@@ -86,9 +89,14 @@ function formatDateTime(dateString: string): string {
export function ExecutionMonitorPage() {
const { formatMessage } = useIntl();
const navigate = useNavigate();
const currentExecution = useExecutionStore((state) => state.currentExecution);
const [selectedView, setSelectedView] = useState<'workflow' | 'timeline' | 'list'>('workflow');
const handleOpenCliViewer = () => {
navigate('/cli-viewer');
};
// Calculate statistics
const stats = useMemo(() => {
const total = mockExecutionHistory.length;
@@ -126,14 +134,20 @@ export function ExecutionMonitorPage() {
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Activity className="w-6 h-6" />
{formatMessage({ id: 'executionMonitor.page.title' })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'executionMonitor.page.subtitle' })}
</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Activity className="w-6 h-6" />
{formatMessage({ id: 'executionMonitor.page.title' })}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'executionMonitor.page.subtitle' })}
</p>
</div>
<Button onClick={handleOpenCliViewer} className="gap-2">
<Monitor className="w-4 h-4" />
{formatMessage({ id: 'executionMonitor.actions.openCliViewer' })}
</Button>
</div>
{/* Current Execution Area */}
@@ -230,24 +244,31 @@ export function ExecutionMonitorPage() {
</CardTitle>
</CardHeader>
<CardContent>
<Tabs value={selectedView} onValueChange={(v) => setSelectedView(v as typeof selectedView)}>
<TabsList>
<TabsTrigger value="workflow">
<ListTree className="w-4 h-4 mr-2" />
{formatMessage({ id: 'executionMonitor.history.tabs.byWorkflow' })}
</TabsTrigger>
<TabsTrigger value="timeline">
<History className="w-4 h-4 mr-2" />
{formatMessage({ id: 'executionMonitor.history.tabs.timeline' })}
</TabsTrigger>
<TabsTrigger value="list">
<List className="w-4 h-4 mr-2" />
{formatMessage({ id: 'executionMonitor.history.tabs.list' })}
</TabsTrigger>
</TabsList>
<TabsNavigation
value={selectedView}
onValueChange={(v) => setSelectedView(v as typeof selectedView)}
tabs={[
{
value: 'workflow',
label: formatMessage({ id: 'executionMonitor.history.tabs.byWorkflow' }),
icon: <ListTree className="w-4 h-4" />,
},
{
value: 'timeline',
label: formatMessage({ id: 'executionMonitor.history.tabs.timeline' }),
icon: <History className="w-4 h-4" />,
},
{
value: 'list',
label: formatMessage({ id: 'executionMonitor.history.tabs.list' }),
icon: <List className="w-4 h-4" />,
},
]}
/>
{/* By Workflow View */}
<TabsContent value="workflow" className="mt-4">
{/* By Workflow View */}
{selectedView === 'workflow' && (
<div className="mt-4">
{workflowGroups.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{formatMessage({ id: 'executionMonitor.history.empty' })}
@@ -302,10 +323,11 @@ export function ExecutionMonitorPage() {
))}
</div>
)}
</TabsContent>
</div>
)}
{/* Timeline View */}
<TabsContent value="timeline" className="mt-4">
{/* Timeline View */}
{selectedView === 'timeline' && (
<div className="space-y-3">
{mockExecutionHistory.map((exec, index) => (
<div key={exec.execId} className="flex gap-4">
@@ -359,11 +381,11 @@ export function ExecutionMonitorPage() {
</div>
))}
</div>
</TabsContent>
)}
{/* List View */}
<TabsContent value="list" className="mt-4">
<div className="space-y-2">
{/* List View */}
{selectedView === 'list' && (
<div className="space-y-2">
{mockExecutionHistory.map((exec) => (
<Card key={exec.execId} className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
@@ -402,9 +424,8 @@ export function ExecutionMonitorPage() {
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
</div>
)}
</CardContent>
</Card>
</div>

View File

@@ -1,45 +1,29 @@
// ========================================
// HomePage Component
// ========================================
// Dashboard home page with stat cards and recent sessions
// Dashboard home page with combined stats, workflow status, and activity heatmap
import * as React from 'react';
import { lazy, Suspense } from 'react';
import { useIntl } from 'react-intl';
import { AlertCircle } from 'lucide-react';
import { DashboardHeader } from '@/components/dashboard/DashboardHeader';
import { DashboardGridContainer } from '@/components/dashboard/DashboardGridContainer';
import { DetailedStatsWidget } from '@/components/dashboard/widgets/DetailedStatsWidget';
import { WorkflowTaskWidget } from '@/components/dashboard/widgets/WorkflowTaskWidget';
import { RecentSessionsWidget } from '@/components/dashboard/widgets/RecentSessionsWidget';
import { ChartSkeleton } from '@/components/charts';
import { Button } from '@/components/ui/Button';
import { useUserDashboardLayout } from '@/hooks/useUserDashboardLayout';
import { WIDGET_IDS } from '@/components/dashboard/defaultLayouts';
// Code-split chart widgets for better initial load performance
const WorkflowStatusPieChartWidget = lazy(() => import('@/components/dashboard/widgets/WorkflowStatusPieChartWidget'));
const ActivityLineChartWidget = lazy(() => import('@/components/dashboard/widgets/ActivityLineChartWidget'));
const TaskTypeBarChartWidget = lazy(() => import('@/components/dashboard/widgets/TaskTypeBarChartWidget'));
/**
* HomePage component - Dashboard overview with widget-based layout
* HomePage component - Dashboard overview with fixed widget layout
*/
export function HomePage() {
const { formatMessage } = useIntl();
const { resetLayout } = useUserDashboardLayout();
// Track errors from widgets (optional, for future enhancements)
const [hasError, _setHasError] = React.useState(false);
const handleRefresh = () => {
// Trigger refetch by reloading the page or using React Query's invalidateQueries
window.location.reload();
};
const handleResetLayout = () => {
resetLayout();
};
return (
<div className="space-y-6">
{/* Header */}
@@ -47,7 +31,6 @@ export function HomePage() {
titleKey="home.dashboard.title"
descriptionKey="home.dashboard.description"
onRefresh={handleRefresh}
onResetLayout={handleResetLayout}
/>
{/* Error alert (optional, shown if widgets encounter critical errors) */}
@@ -66,29 +49,14 @@ export function HomePage() {
</div>
)}
{/* Dashboard Grid with Widgets */}
<DashboardGridContainer isDraggable={true} isResizable={true}>
{/* Widget 1: Detailed Stats */}
<DetailedStatsWidget key={WIDGET_IDS.STATS} />
{/* Dashboard Widgets - Simple flex layout for dynamic height */}
<div className="flex flex-col gap-4">
{/* Row 1: Combined Stats + Workflow Status + Task Details */}
<WorkflowTaskWidget />
{/* Widget 2: Recent Sessions */}
<RecentSessionsWidget key={WIDGET_IDS.RECENT_SESSIONS} />
{/* Widget 3: Workflow Status Pie Chart (code-split with Suspense fallback) */}
<Suspense fallback={<ChartSkeleton type="pie" height={280} />}>
<WorkflowStatusPieChartWidget key={WIDGET_IDS.WORKFLOW_STATUS} />
</Suspense>
{/* Widget 4: Activity Line Chart (code-split with Suspense fallback) */}
<Suspense fallback={<ChartSkeleton type="line" height={280} />}>
<ActivityLineChartWidget key={WIDGET_IDS.ACTIVITY} />
</Suspense>
{/* Widget 5: Task Type Bar Chart (code-split with Suspense fallback) */}
<Suspense fallback={<ChartSkeleton type="bar" height={280} />}>
<TaskTypeBarChartWidget key={WIDGET_IDS.TASK_TYPES} />
</Suspense>
</DashboardGridContainer>
{/* Row 2: Recent Sessions */}
<RecentSessionsWidget />
</div>
</div>
);
}

View File

@@ -41,7 +41,8 @@ import { Flowchart } from '@/components/shared/Flowchart';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { Tabs, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/Collapsible';
import type { LiteTask, LiteTaskSession } from '@/lib/api';
@@ -330,53 +331,68 @@ export function LiteTaskDetailPage() {
{/* Session Type-Specific Tabs */}
{isMultiCli ? (
<Tabs value={multiCliActiveTab} onValueChange={(v) => setMultiCliActiveTab(v as MultiCliTab)}>
<TabsList className="w-full">
<TabsTrigger value="tasks" className="flex-1 gap-1">
<ListTodo className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.tasks' })}
</TabsTrigger>
<TabsTrigger value="discussion" className="flex-1 gap-1">
<MessageSquare className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.discussion' })}
</TabsTrigger>
<TabsTrigger value="context" className="flex-1 gap-1">
<Package className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.context' })}
</TabsTrigger>
<TabsTrigger value="summary" className="flex-1 gap-1">
<FileText className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.summary' })}
</TabsTrigger>
</TabsList>
</Tabs>
<TabsNavigation
value={multiCliActiveTab}
onValueChange={(v) => setMultiCliActiveTab(v as MultiCliTab)}
tabs={[
{
value: 'tasks',
label: formatMessage({ id: 'liteTasksDetail.tabs.tasks' }),
icon: <ListTodo className="h-4 w-4" />,
},
{
value: 'discussion',
label: formatMessage({ id: 'liteTasksDetail.tabs.discussion' }),
icon: <MessageSquare className="h-4 w-4" />,
},
{
value: 'context',
label: formatMessage({ id: 'liteTasksDetail.tabs.context' }),
icon: <Package className="h-4 w-4" />,
},
{
value: 'summary',
label: formatMessage({ id: 'liteTasksDetail.tabs.summary' }),
icon: <FileText className="h-4 w-4" />,
},
]}
/>
) : (
<Tabs value={litePlanActiveTab} onValueChange={(v) => setLitePlanActiveTab(v as LitePlanTab)}>
<TabsList className="w-full">
<TabsTrigger value="tasks" className="flex-1 gap-1">
<ListTodo className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.tasks' })}
</TabsTrigger>
<TabsTrigger value="plan" className="flex-1 gap-1">
<Ruler className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.plan' })}
</TabsTrigger>
{isLiteFix && (
<TabsTrigger value="diagnoses" className="flex-1 gap-1">
<Stethoscope className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.diagnoses' })}
</TabsTrigger>
)}
<TabsTrigger value="context" className="flex-1 gap-1">
<Package className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.context' })}
</TabsTrigger>
<TabsTrigger value="summary" className="flex-1 gap-1">
<FileText className="h-4 w-4" />
{formatMessage({ id: 'liteTasksDetail.tabs.summary' })}
</TabsTrigger>
</TabsList>
</Tabs>
<TabsNavigation
value={litePlanActiveTab}
onValueChange={(v) => setLitePlanActiveTab(v as LitePlanTab)}
tabs={[
{
value: 'tasks',
label: formatMessage({ id: 'liteTasksDetail.tabs.tasks' }),
icon: <ListTodo className="h-4 w-4" />,
},
{
value: 'plan',
label: formatMessage({ id: 'liteTasksDetail.tabs.plan' }),
icon: <Ruler className="h-4 w-4" />,
},
...(isLiteFix
? [
{
value: 'diagnoses' as const,
label: formatMessage({ id: 'liteTasksDetail.tabs.diagnoses' }),
icon: <Stethoscope className="h-4 w-4" />,
},
]
: []),
{
value: 'context',
label: formatMessage({ id: 'liteTasksDetail.tabs.context' }),
icon: <Package className="h-4 w-4" />,
},
{
value: 'summary',
label: formatMessage({ id: 'liteTasksDetail.tabs.summary' }),
icon: <FileText className="h-4 w-4" />,
},
]}
/>
)}
{/* Task List with Multi-Tab Content */}
@@ -390,15 +406,11 @@ export function LiteTaskDetailPage() {
<Card key={taskId} className="overflow-hidden">
{/* Task Header */}
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-4">
{/* Left: Task ID, Title, Description */}
<div className="flex-1 min-w-0">
<CardTitle className="text-base font-medium flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">{taskId}</span>
<Badge
variant={task.status === 'completed' ? 'success' : task.status === 'in_progress' ? 'warning' : 'secondary'}
>
{task.status}
</Badge>
{task.priority && (
<Badge variant="outline" className="text-xs">{task.priority}</Badge>
)}
@@ -414,28 +426,77 @@ export function LiteTaskDetailPage() {
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{task.description}</p>
)}
</div>
{/* Right: Meta Information */}
<div className="flex flex-col items-end gap-2 text-xs text-muted-foreground flex-shrink-0">
{/* Row 1: Status Badge */}
<Badge
variant={task.status === 'completed' ? 'success' : task.status === 'in_progress' ? 'warning' : task.status === 'blocked' ? 'destructive' : 'secondary'}
className="w-fit"
>
{task.status}
</Badge>
{/* Row 2: Metadata */}
<div className="flex items-center gap-3 flex-wrap justify-end">
{/* Dependencies Count */}
{task.context?.depends_on && task.context.depends_on.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded bg-muted/50">
<span className="font-mono font-semibold text-foreground">{task.context.depends_on.length}</span>
<span>dep{task.context.depends_on.length > 1 ? 's' : ''}</span>
</span>
)}
{/* Target Files Count */}
{task.flow_control?.target_files && task.flow_control.target_files.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded bg-muted/50">
<span className="font-mono font-semibold text-foreground">{task.flow_control.target_files.length}</span>
<span>file{task.flow_control.target_files.length > 1 ? 's' : ''}</span>
</span>
)}
{/* Focus Paths Count */}
{task.context?.focus_paths && task.context.focus_paths.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded bg-muted/50">
<span className="font-mono font-semibold text-foreground">{task.context.focus_paths.length}</span>
<span>focus</span>
</span>
)}
{/* Acceptance Criteria Count */}
{task.context?.acceptance && task.context.acceptance.length > 0 && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded bg-muted/50">
<span className="font-mono font-semibold text-foreground">{task.context.acceptance.length}</span>
<span>criteria</span>
</span>
)}
</div>
</div>
</div>
</CardHeader>
{/* Multi-Tab Content */}
<Tabs
value={activeTaskTab}
onValueChange={(v) => handleTaskTabChange(taskId, v as TaskTabValue)}
className="w-full"
>
<TabsList className="w-full rounded-none border-y border-border bg-muted/50 px-4">
<TabsTrigger value="task" className="flex-1 gap-1.5">
<ListTodo className="h-4 w-4" />
Task
</TabsTrigger>
<TabsTrigger value="context" className="flex-1 gap-1.5">
<Package className="h-4 w-4" />
Context
</TabsTrigger>
</TabsList>
<div className="w-full">
<TabsNavigation
value={activeTaskTab}
onValueChange={(v) => handleTaskTabChange(taskId, v as TaskTabValue)}
tabs={[
{
value: 'task',
label: 'Task',
icon: <ListTodo className="h-4 w-4" />,
},
{
value: 'context',
label: 'Context',
icon: <Package className="h-4 w-4" />,
},
]}
/>
{/* Task Tab - Implementation Details */}
<TabsContent value="task" className="p-4 space-y-4">
{activeTaskTab === 'task' && (
<div className="p-4 space-y-4">
{/* Flowchart */}
{hasFlowchart && task.flow_control && (
<div>
@@ -478,10 +539,12 @@ export function LiteTaskDetailPage() {
</div>
</div>
)}
</TabsContent>
</div>
)}
{/* Context Tab - Planning Context */}
<TabsContent value="context" className="p-4 space-y-4">
{activeTaskTab === 'context' && (
<div className="p-4 space-y-4">
{/* Focus Paths */}
{task.context?.focus_paths && task.context.focus_paths.length > 0 && (
<div>
@@ -547,8 +610,9 @@ export function LiteTaskDetailPage() {
</ul>
</div>
)}
</TabsContent>
</Tabs>
</div>
)}
</div>
</Card>
);
})}

View File

@@ -30,12 +30,16 @@ import {
Stethoscope,
FolderOpen,
FileText,
CheckCircle2,
Clock,
AlertCircle,
} from 'lucide-react';
import { useLiteTasks } from '@/hooks/useLiteTasks';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent } from '@/components/ui/Card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { Tabs, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { TaskDrawer } from '@/components/shared/TaskDrawer';
import { fetchLiteSessionContext, type LiteTask, type LiteTaskSession, type LiteSessionContext } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
@@ -482,6 +486,19 @@ export function LiteTasksPage() {
const taskCount = session.tasks?.length || 0;
const isExpanded = expandedSessionId === session.id;
// Calculate task status distribution
const taskStats = React.useMemo(() => {
const tasks = session.tasks || [];
return {
completed: tasks.filter((t) => t.status === 'completed').length,
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
blocked: tasks.filter((t) => t.status === 'blocked').length,
pending: tasks.filter((t) => !t.status || t.status === 'pending').length,
};
}, [session.tasks]);
const firstTask = session.tasks?.[0];
return (
<div key={session.id}>
<Card
@@ -507,6 +524,43 @@ export function LiteTasksPage() {
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
</Badge>
</div>
{/* Task preview - first task title */}
{firstTask?.title && (
<div className="mb-3 pb-3 border-b border-border/50">
<p className="text-sm text-foreground line-clamp-1">{firstTask.title}</p>
</div>
)}
{/* Task status distribution */}
<div className="flex items-center flex-wrap gap-2 mb-3">
{taskStats.completed > 0 && (
<Badge variant="success" className="gap-1 text-xs">
<CheckCircle2 className="h-3 w-3" />
{taskStats.completed} {formatMessage({ id: 'liteTasks.status.completed' })}
</Badge>
)}
{taskStats.inProgress > 0 && (
<Badge variant="warning" className="gap-1 text-xs">
<Clock className="h-3 w-3" />
{taskStats.inProgress} {formatMessage({ id: 'liteTasks.status.inProgress' })}
</Badge>
)}
{taskStats.blocked > 0 && (
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{taskStats.blocked} {formatMessage({ id: 'liteTasks.status.blocked' })}
</Badge>
)}
{taskStats.pending > 0 && (
<Badge variant="secondary" className="gap-1 text-xs">
<Activity className="h-3 w-3" />
{taskStats.pending} {formatMessage({ id: 'liteTasks.status.pending' })}
</Badge>
)}
</div>
{/* Date and task count */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{session.createdAt && (
<span className="flex items-center gap-1">
@@ -546,6 +600,18 @@ export function LiteTasksPage() {
const status = latestSynthesis.status || session.status || 'analyzing';
const createdAt = (metadata.timestamp as string) || session.createdAt || '';
// Calculate task status distribution
const taskStats = React.useMemo(() => {
const tasks = session.tasks || [];
return {
completed: tasks.filter((t) => t.status === 'completed').length,
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
blocked: tasks.filter((t) => t.status === 'blocked').length,
pending: tasks.filter((t) => !t.status || t.status === 'pending').length,
total: tasks.length,
};
}, [session.tasks]);
return (
<Card
key={session.id}
@@ -575,6 +641,37 @@ export function LiteTasksPage() {
<MessageCircle className="h-4 w-4" />
<span className="line-clamp-1">{topicTitle}</span>
</div>
{/* Task status distribution for multi-cli */}
{taskStats.total > 0 && (
<div className="flex items-center flex-wrap gap-2 mb-3">
{taskStats.completed > 0 && (
<Badge variant="success" className="gap-1 text-xs">
<CheckCircle2 className="h-3 w-3" />
{taskStats.completed} {formatMessage({ id: 'liteTasks.status.completed' })}
</Badge>
)}
{taskStats.inProgress > 0 && (
<Badge variant="warning" className="gap-1 text-xs">
<Clock className="h-3 w-3" />
{taskStats.inProgress} {formatMessage({ id: 'liteTasks.status.inProgress' })}
</Badge>
)}
{taskStats.blocked > 0 && (
<Badge variant="destructive" className="gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{taskStats.blocked} {formatMessage({ id: 'liteTasks.status.blocked' })}
</Badge>
)}
{taskStats.pending > 0 && (
<Badge variant="secondary" className="gap-1 text-xs">
<Activity className="h-3 w-3" />
{taskStats.pending} {formatMessage({ id: 'liteTasks.status.pending' })}
</Badge>
)}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{createdAt && (
<span className="flex items-center gap-1">
@@ -651,30 +748,30 @@ export function LiteTasksPage() {
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as LiteTaskTab)}>
<TabsList>
<TabsTrigger value="lite-plan">
<FileEdit className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.plan' })}
<Badge variant="secondary" className="ml-2">
{litePlan.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="lite-fix">
<Wrench className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.fix' })}
<Badge variant="secondary" className="ml-2">
{liteFix.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="multi-cli-plan">
<MessagesSquare className="h-4 w-4 mr-2" />
{formatMessage({ id: 'liteTasks.type.multiCli' })}
<Badge variant="secondary" className="ml-2">
{multiCliPlan.length}
</Badge>
</TabsTrigger>
</TabsList>
<TabsNavigation
value={activeTab}
onValueChange={(v) => setActiveTab(v as LiteTaskTab)}
tabs={[
{
value: 'lite-plan',
label: formatMessage({ id: 'liteTasks.type.plan' }),
icon: <FileEdit className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{litePlan.length}</Badge>,
},
{
value: 'lite-fix',
label: formatMessage({ id: 'liteTasks.type.fix' }),
icon: <Wrench className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{liteFix.length}</Badge>,
},
{
value: 'multi-cli-plan',
label: formatMessage({ id: 'liteTasks.type.multiCli' }),
icon: <MessagesSquare className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{multiCliPlan.length}</Badge>,
},
]}
/>
{/* Search and Sort Toolbar */}
<div className="mt-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
@@ -729,86 +826,91 @@ export function LiteTasksPage() {
</div>
{/* Lite Plan Tab */}
<TabsContent value="lite-plan" className="mt-4">
{litePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredLitePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredLitePlan.map(renderLiteTaskCard)}</div>
)}
</TabsContent>
{activeTab === 'lite-plan' && (
<div className="mt-4">
{litePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredLitePlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredLitePlan.map(renderLiteTaskCard)}</div>
)}
</div>
)}
{/* Lite Fix Tab */}
<TabsContent value="lite-fix" className="mt-4">
{liteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-fix' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredLiteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredLiteFix.map(renderLiteTaskCard)}</div>
)}
</TabsContent>
{activeTab === 'lite-fix' && (
<div className="mt-4">
{liteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'lite-fix' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredLiteFix.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredLiteFix.map(renderLiteTaskCard)}</div>
)}
</div>
)}
{/* Multi-CLI Plan Tab */}
<TabsContent value="multi-cli-plan" className="mt-4">
{multiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'multi-cli-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredMultiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredMultiCliPlan.map(renderMultiCliCard)}</div>
)}
</TabsContent>
</Tabs>
{activeTab === 'multi-cli-plan' && (
<div className="mt-4">
{multiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.empty.title' }, { type: 'multi-cli-plan' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.empty.message' })}
</p>
</div>
) : filteredMultiCliPlan.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'liteTasks.noResults.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'liteTasks.noResults.message' })}
</p>
</div>
) : (
<div className="grid gap-3">{filteredMultiCliPlan.map(renderMultiCliCard)}</div>
)}
</div>
)}
{/* TaskDrawer */}
<TaskDrawer

View File

@@ -228,158 +228,161 @@ export function ProjectOverviewPage() {
const { technologyStack, architecture, keyComponents, developmentIndex, guidelines, metadata } = projectOverview;
return (
<div className="space-y-6">
{/* Project Header */}
<div className="space-y-4">
{/* Project Header + Technology Stack - Combined */}
<Card>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<CardContent className="p-4">
{/* Header Row */}
<div className="flex items-start justify-between mb-4 pb-3 border-b border-border">
<div className="flex-1">
<h1 className="text-2xl font-bold text-foreground mb-2">
<h1 className="text-base font-semibold text-foreground mb-1">
{projectOverview.projectName}
</h1>
<p className="text-muted-foreground">
<p className="text-xs text-muted-foreground">
{projectOverview.description || formatMessage({ id: 'projectOverview.noDescription' })}
</p>
</div>
<div className="text-sm text-muted-foreground text-right">
<div className="text-xs text-muted-foreground text-right">
<div>
{formatMessage({ id: 'projectOverview.header.initialized' })}:{' '}
{formatDate(projectOverview.initializedAt)}
</div>
{metadata?.analysis_mode && (
<div className="mt-1">
<span className="font-mono text-xs px-2 py-0.5 bg-muted rounded">
<span className="font-mono text-[10px] px-1.5 py-0.5 bg-muted rounded">
{metadata.analysis_mode}
</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Technology Stack */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Code2 className="w-5 h-5" />
{/* Technology Stack */}
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Code2 className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.techStack.title' })}
</h3>
{/* Languages */}
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{formatMessage({ id: 'projectOverview.techStack.languages' })}
</h4>
<div className="flex flex-wrap gap-3">
{technologyStack?.languages && technologyStack.languages.length > 0 ? (
technologyStack.languages.map((lang: { name: string; file_count: number; primary?: boolean }) => (
<div
key={lang.name}
className={`flex items-center gap-2 px-3 py-2 bg-background border border-border rounded-lg ${
lang.primary ? 'ring-2 ring-primary' : ''
}`}
>
<span className="font-semibold text-foreground">{lang.name}</span>
<span className="text-xs text-muted-foreground">{lang.file_count} files</span>
{lang.primary && (
<span className="text-xs px-1.5 py-0.5 bg-primary text-primary-foreground rounded">
{formatMessage({ id: 'projectOverview.techStack.primary' })}
</span>
)}
</div>
))
) : (
<span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noLanguages' })}
</span>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Languages */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.languages' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{technologyStack?.languages && technologyStack.languages.length > 0 ? (
technologyStack.languages.map((lang: { name: string; file_count: number; primary?: boolean }) => (
<div
key={lang.name}
className={`flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded text-xs ${
lang.primary ? 'ring-1 ring-primary' : ''
}`}
>
<span className="font-medium text-foreground">{lang.name}</span>
<span className="text-[10px] text-muted-foreground">{lang.file_count}</span>
{lang.primary && (
<span className="text-[9px] px-1 py-0.5 bg-primary text-primary-foreground rounded">
{formatMessage({ id: 'projectOverview.techStack.primary' })}
</span>
)}
</div>
))
) : (
<span className="text-muted-foreground text-xs">
{formatMessage({ id: 'projectOverview.techStack.noLanguages' })}
</span>
)}
</div>
</div>
</div>
{/* Frameworks */}
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{formatMessage({ id: 'projectOverview.techStack.frameworks' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? (
technologyStack.frameworks.map((fw: string) => (
<Badge key={fw} variant="success" className="px-3 py-1.5">
{fw}
</Badge>
))
) : (
<span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noFrameworks' })}
</span>
)}
{/* Frameworks */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.frameworks' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? (
technologyStack.frameworks.map((fw: string) => (
<Badge key={fw} variant="success" className="px-2 py-0.5 text-[10px]">
{fw}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">
{formatMessage({ id: 'projectOverview.techStack.noFrameworks' })}
</span>
)}
</div>
</div>
</div>
{/* Build Tools */}
{technologyStack?.build_tools && technologyStack.build_tools.length > 0 && (
<div className="mb-5">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{/* Build Tools */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.buildTools' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack.build_tools.map((tool: string) => (
<Badge key={tool} variant="warning" className="px-3 py-1.5">
{tool}
</Badge>
))}
<div className="flex flex-wrap gap-1.5">
{technologyStack?.build_tools && technologyStack.build_tools.length > 0 ? (
technologyStack.build_tools.map((tool: string) => (
<Badge key={tool} variant="warning" className="px-2 py-0.5 text-[10px]">
{tool}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</div>
</div>
)}
{/* Test Frameworks */}
{technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 && (
{/* Test Frameworks */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.testFrameworks' })}
</h4>
<div className="flex flex-wrap gap-2">
{technologyStack.test_frameworks.map((fw: string) => (
<Badge key={fw} variant="default" className="px-3 py-1.5">
{fw}
</Badge>
))}
<div className="flex flex-wrap gap-1.5">
{technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 ? (
technologyStack.test_frameworks.map((fw: string) => (
<Badge key={fw} variant="default" className="px-2 py-0.5 text-[10px]">
{fw}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Architecture */}
{architecture && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Blocks className="w-5 h-5" />
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Blocks className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.architecture.title' })}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Style */}
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.style' })}
</h4>
<div className="px-3 py-2 bg-background border border-border rounded-lg">
<span className="text-foreground font-medium">{architecture.style}</span>
<div className="px-2 py-1.5 bg-background border border-border rounded">
<span className="text-foreground font-medium text-xs">{architecture.style}</span>
</div>
</div>
{/* Layers */}
{architecture.layers && architecture.layers.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.layers' })}
</h4>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{architecture.layers.map((layer: string) => (
<span key={layer} className="px-2 py-1 bg-muted text-foreground rounded text-sm">
<span key={layer} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-[10px]">
{layer}
</span>
))}
@@ -390,12 +393,12 @@ export function ProjectOverviewPage() {
{/* Patterns */}
{architecture.patterns && architecture.patterns.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-2">
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.patterns' })}
</h4>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1.5">
{architecture.patterns.map((pattern: string) => (
<span key={pattern} className="px-2 py-1 bg-muted text-foreground rounded text-sm">
<span key={pattern} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-[10px]">
{pattern}
</span>
))}
@@ -410,33 +413,33 @@ export function ProjectOverviewPage() {
{/* Key Components */}
{keyComponents && keyComponents.length > 0 && (
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Component className="w-5 h-5" />
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Component className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.components.title' })}
</h3>
<div className="space-y-3">
<div className="space-y-2">
{keyComponents.map((comp: KeyComponent) => {
const importance = comp.importance || 'low';
const importanceColors: Record<string, string> = {
high: 'border-l-4 border-l-destructive bg-destructive/5',
medium: 'border-l-4 border-l-warning bg-warning/5',
low: 'border-l-4 border-l-muted-foreground bg-muted',
high: 'border-l-2 border-l-destructive bg-destructive/5',
medium: 'border-l-2 border-l-warning bg-warning/5',
low: 'border-l-2 border-l-muted-foreground bg-muted',
};
const importanceBadges: Record<string, React.ReactElement> = {
high: (
<Badge variant="destructive" className="text-xs">
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.high' })}
</Badge>
),
medium: (
<Badge variant="warning" className="text-xs">
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.medium' })}
</Badge>
),
low: (
<Badge variant="secondary" className="text-xs">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.low' })}
</Badge>
),
@@ -445,17 +448,17 @@ export function ProjectOverviewPage() {
return (
<div
key={comp.name}
className={`p-4 rounded-lg ${importanceColors[importance] || importanceColors.low}`}
className={`p-2.5 rounded ${importanceColors[importance] || importanceColors.low}`}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-foreground">{comp.name}</h4>
<div className="flex items-start justify-between mb-1">
<h4 className="font-medium text-foreground text-xs">{comp.name}</h4>
{importanceBadges[importance]}
</div>
{comp.description && (
<p className="text-sm text-muted-foreground mb-2">{comp.description}</p>
<p className="text-[10px] text-muted-foreground mb-1">{comp.description}</p>
)}
{comp.responsibility && comp.responsibility.length > 0 && (
<ul className="text-xs text-muted-foreground list-disc list-inside">
<ul className="text-[10px] text-muted-foreground list-disc list-inside">
{comp.responsibility.map((resp: string, i: number) => (
<li key={i}>{resp}</li>
))}
@@ -472,20 +475,20 @@ export function ProjectOverviewPage() {
{/* Development Index */}
{developmentIndex && totalEntries > 0 && (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<GitBranch className="w-5 h-5" />
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-1.5">
<GitBranch className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.devIndex.title' })}
</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{devIndexCategories.map((cat) => {
const count = devIndexTotals[cat.key];
if (count === 0) return null;
const Icon = cat.icon;
return (
<Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'}>
<Icon className="w-3 h-3 mr-1" />
<Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0">
<Icon className="w-2.5 h-2.5 mr-0.5" />
{count}
</Badge>
);
@@ -494,21 +497,21 @@ export function ProjectOverviewPage() {
</div>
<Tabs value={devIndexView} onValueChange={(v) => setDevIndexView(v as DevIndexView)}>
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="category">
<LayoutGrid className="w-3.5 h-3.5 mr-1" />
<div className="flex items-center justify-between mb-3">
<TabsList className="h-7">
<TabsTrigger value="category" className="text-xs px-2 py-1 h-6">
<LayoutGrid className="w-3 h-3 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.categories' })}
</TabsTrigger>
<TabsTrigger value="timeline">
<GitCommitHorizontal className="w-3.5 h-3.5 mr-1" />
<TabsTrigger value="timeline" className="text-xs px-2 py-1 h-6">
<GitCommitHorizontal className="w-3 h-3 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.timeline' })}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="category">
<div className="space-y-4">
<div className="space-y-3">
{devIndexCategories.map((cat) => {
const entries = developmentIndex?.[cat.key] || [];
if (entries.length === 0) return null;
@@ -516,38 +519,38 @@ export function ProjectOverviewPage() {
return (
<div key={cat.key}>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<Icon className="w-4 h-4" />
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" />
<span>{formatMessage({ id: cat.i18nKey })}</span>
<Badge variant="secondary">{entries.length}</Badge>
<Badge variant="secondary" className="text-[10px] px-1 py-0">{entries.length}</Badge>
</h4>
<div className="space-y-2">
<div className="space-y-1.5">
{entries.slice(0, 5).map((entry: DevelopmentIndexEntry & { type?: string; typeLabel?: string; typeIcon?: React.ElementType; typeColor?: string; date?: string }, i: number) => (
<div
key={i}
className="p-3 bg-background border border-border rounded-lg hover:shadow-sm transition-shadow"
className="p-2 bg-background border border-border rounded hover:shadow-sm transition-shadow"
>
<div className="flex items-start justify-between mb-1">
<h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
<span className="text-xs text-muted-foreground">
<div className="flex items-start justify-between mb-0.5">
<h5 className="font-medium text-foreground text-xs">{entry.title}</h5>
<span className="text-[10px] text-muted-foreground">
{formatDate(entry.archivedAt || entry.date || entry.implemented_at)}
</span>
</div>
{entry.description && (
<p className="text-sm text-muted-foreground mb-1">{entry.description}</p>
<p className="text-[10px] text-muted-foreground mb-1">{entry.description}</p>
)}
<div className="flex items-center gap-2 text-xs flex-wrap">
<div className="flex items-center gap-1.5 text-[10px] flex-wrap">
{entry.sessionId && (
<span className="px-2 py-0.5 bg-primary-light text-primary rounded font-mono">
<span className="px-1.5 py-0.5 bg-primary-light text-primary rounded font-mono">
{entry.sessionId}
</span>
)}
{entry.sub_feature && (
<span className="px-2 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
<span className="px-1.5 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
)}
{entry.status && (
<span
className={`px-2 py-0.5 rounded ${
className={`px-1.5 py-0.5 rounded ${
entry.status === 'completed'
? 'bg-success-light text-success'
: 'bg-warning-light text-warning'
@@ -560,7 +563,7 @@ export function ProjectOverviewPage() {
</div>
))}
{entries.length > 5 && (
<div className="text-sm text-muted-foreground text-center py-2">
<div className="text-xs text-muted-foreground text-center py-1">
... and {entries.length - 5} more
</div>
)}
@@ -572,24 +575,24 @@ export function ProjectOverviewPage() {
</TabsContent>
<TabsContent value="timeline">
<div className="space-y-4">
<div className="space-y-3">
{allDevEntries.slice(0, 20).map((entry, i) => {
const Icon = entry.typeIcon;
return (
<div key={i} className="flex gap-4">
<div key={i} className="flex gap-3">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full bg-${entry.typeColor}-light text-${entry.typeColor} flex items-center justify-center`}
className={`w-6 h-6 rounded-full bg-${entry.typeColor}-light text-${entry.typeColor} flex items-center justify-center`}
>
<Icon className="w-4 h-4" />
<Icon className="w-3 h-3" />
</div>
{i < Math.min(allDevEntries.length, 20) - 1 && (
<div className="w-0.5 flex-1 bg-border mt-2" />
<div className="w-0.5 flex-1 bg-border mt-1.5" />
)}
</div>
<div className="flex-1 pb-4">
<div className="flex items-start justify-between mb-1">
<div className="flex items-center gap-2">
<div className="flex-1 pb-3">
<div className="flex items-start justify-between mb-0.5">
<div className="flex items-center gap-1.5">
<Badge
variant={
entry.typeColor === 'primary'
@@ -598,31 +601,31 @@ export function ProjectOverviewPage() {
? 'destructive'
: 'secondary'
}
className="text-xs"
className="text-[10px] px-1.5 py-0"
>
{entry.typeLabel}
</Badge>
<h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
<h5 className="font-medium text-foreground text-xs">{entry.title}</h5>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatDate(entry.date)}
</span>
</div>
{entry.description && (
<p className="text-sm text-muted-foreground mb-2">{entry.description}</p>
<p className="text-[10px] text-muted-foreground mb-1">{entry.description}</p>
)}
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1.5 text-[10px]">
{entry.sessionId && (
<span className="px-2 py-0.5 bg-muted rounded font-mono">
<span className="px-1.5 py-0.5 bg-muted rounded font-mono">
{entry.sessionId}
</span>
)}
{entry.sub_feature && (
<span className="px-2 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
<span className="px-1.5 py-0.5 bg-muted rounded">{entry.sub_feature}</span>
)}
{entry.tags &&
entry.tags.slice(0, 3).map((tag) => (
<span key={tag} className="px-2 py-0.5 bg-accent rounded">
<span key={tag} className="px-1.5 py-0.5 bg-accent rounded">
{tag}
</span>
))}
@@ -632,7 +635,7 @@ export function ProjectOverviewPage() {
);
})}
{allDevEntries.length > 20 && (
<div className="text-sm text-muted-foreground text-center py-4">
<div className="text-xs text-muted-foreground text-center py-2">
... and {allDevEntries.length - 20} more entries
</div>
)}
@@ -646,26 +649,26 @@ export function ProjectOverviewPage() {
{/* Guidelines */}
{guidelines && (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<ScrollText className="w-5 h-5" />
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-1.5">
<ScrollText className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.guidelines.title' })}
</h3>
<div className="flex gap-2">
<div className="flex gap-1.5">
{!isEditMode ? (
<Button variant="outline" size="sm" onClick={handleEditStart}>
<Edit className="w-4 h-4 mr-1" />
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={handleEditStart}>
<Edit className="w-3 h-3 mr-1" />
{formatMessage({ id: 'projectOverview.guidelines.edit' })}
</Button>
) : (
<>
<Button variant="outline" size="sm" onClick={handleEditCancel} disabled={isUpdating}>
<X className="w-4 h-4 mr-1" />
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={handleEditCancel} disabled={isUpdating}>
<X className="w-3 h-3 mr-1" />
{formatMessage({ id: 'projectOverview.guidelines.cancel' })}
</Button>
<Button variant="default" size="sm" onClick={handleSave} disabled={isUpdating}>
<Save className="w-4 h-4 mr-1" />
<Button variant="default" size="sm" className="h-7 text-xs px-2" onClick={handleSave} disabled={isUpdating}>
<Save className="w-3 h-3 mr-1" />
{isUpdating ? formatMessage({ id: 'projectOverview.guidelines.saving' }) : formatMessage({ id: 'projectOverview.guidelines.save' })}
</Button>
</>
@@ -673,17 +676,17 @@ export function ProjectOverviewPage() {
</div>
</div>
<div className="space-y-6">
<div className="space-y-4">
{!isEditMode ? (
<>
{/* Read-only Mode - Conventions */}
{guidelines.conventions && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<BookMarked className="w-4 h-4" />
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-1.5">
<BookMarked className="w-3.5 h-3.5" />
<span>{formatMessage({ id: 'projectOverview.guidelines.conventions' })}</span>
</h4>
<div className="space-y-2">
<div className="space-y-1.5">
{Object.entries(guidelines.conventions).map(([key, items]) => {
const itemList = Array.isArray(items) ? items : [];
if (itemList.length === 0) return null;
@@ -692,12 +695,12 @@ export function ProjectOverviewPage() {
{itemList.map((item: string, i: number) => (
<div
key={i}
className="flex items-start gap-3 p-3 bg-background border border-border rounded-lg"
className="flex items-start gap-2 p-2 bg-background border border-border rounded"
>
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
<span className="text-[10px] px-1.5 py-0.5 bg-muted text-muted-foreground rounded">
{key}
</span>
<span className="text-sm text-foreground">{item}</span>
<span className="text-xs text-foreground">{item}</span>
</div>
))}
</div>
@@ -710,11 +713,11 @@ export function ProjectOverviewPage() {
{/* Read-only Mode - Constraints */}
{guidelines.constraints && (
<div>
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
<ShieldAlert className="w-4 h-4" />
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-1.5">
<ShieldAlert className="w-3.5 h-3.5" />
<span>{formatMessage({ id: 'projectOverview.guidelines.constraints' })}</span>
</h4>
<div className="space-y-2">
<div className="space-y-1.5">
{Object.entries(guidelines.constraints).map(([key, items]) => {
const itemList = Array.isArray(items) ? items : [];
if (itemList.length === 0) return null;
@@ -723,12 +726,12 @@ export function ProjectOverviewPage() {
{itemList.map((item: string, i: number) => (
<div
key={i}
className="flex items-start gap-3 p-3 bg-background border border-border rounded-lg"
className="flex items-start gap-2 p-2 bg-background border border-border rounded"
>
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
<span className="text-[10px] px-1.5 py-0.5 bg-muted text-muted-foreground rounded">
{key}
</span>
<span className="text-sm text-foreground">{item}</span>
<span className="text-xs text-foreground">{item}</span>
</div>
))}
</div>

View File

@@ -1,7 +1,7 @@
// ========================================
// ReviewSessionPage Component
// ========================================
// Review session detail page with findings display and multi-select
// Review session detail page with findings display, multi-select, dimension tabs, and fix progress carousel
import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
@@ -17,6 +17,8 @@ import {
Download,
ChevronDown,
ChevronRight,
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
} from 'lucide-react';
import { useReviewSession } from '@/hooks/useReviewSession';
import { Button } from '@/components/ui/Button';
@@ -42,6 +44,229 @@ interface FindingWithSelection {
impact?: string;
}
// Fix Progress Types
interface FixStage {
stage: number;
status: 'completed' | 'in-progress' | 'pending';
groups: string[];
}
interface FixProgressData {
fix_session_id: string;
phase: 'planning' | 'execution' | 'completion';
total_findings: number;
fixed_count: number;
failed_count: number;
in_progress_count: number;
pending_count: number;
percent_complete: number;
current_stage: number;
total_stages: number;
stages: FixStage[];
active_agents: Array<{
agent_id: string;
group_id: string;
current_finding: { finding_title: string } | null;
}>;
}
/**
* Fix Progress Carousel Component
* Displays fix progress with polling and carousel navigation
*/
function FixProgressCarousel({ sessionId }: { sessionId: string }) {
const { formatMessage } = useIntl();
const [fixProgressData, setFixProgressData] = React.useState<FixProgressData | null>(null);
const [currentSlide, setCurrentSlide] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(false);
// Fetch fix progress data
const fetchFixProgress = React.useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/fix-progress?sessionId=${encodeURIComponent(sessionId)}`);
if (!response.ok) {
if (response.status === 404) {
setFixProgressData(null);
}
return;
}
const data = await response.json();
setFixProgressData(data);
} catch (err) {
console.error('Failed to fetch fix progress:', err);
} finally {
setIsLoading(false);
}
}, [sessionId]);
// Poll for fix progress updates
React.useEffect(() => {
fetchFixProgress();
// Stop polling if phase is completion
if (fixProgressData?.phase === 'completion') {
return;
}
const interval = setInterval(() => {
fetchFixProgress();
}, 5000);
return () => clearInterval(interval);
}, [fetchFixProgress, fixProgressData?.phase]);
// Navigate carousel
const navigateSlide = (direction: 'prev' | 'next' | number) => {
if (!fixProgressData) return;
const totalSlides = fixProgressData.active_agents.length > 0 ? 3 : 2;
if (typeof direction === 'number') {
setCurrentSlide(direction);
} else if (direction === 'next') {
setCurrentSlide((prev) => (prev + 1) % totalSlides);
} else if (direction === 'prev') {
setCurrentSlide((prev) => (prev - 1 + totalSlides) % totalSlides);
}
};
if (isLoading && !fixProgressData) {
return (
<Card>
<CardContent className="p-4">
<div className="h-32 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
);
}
if (!fixProgressData) {
return null;
}
const { phase, total_findings, fixed_count, failed_count, in_progress_count, pending_count, percent_complete, current_stage, total_stages, stages, active_agents } = fixProgressData;
const phaseIcon = phase === 'planning' ? '📝' : phase === 'execution' ? '⚡' : '✅';
const totalSlides = active_agents.length > 0 ? 3 : 2;
return (
<Card>
<CardContent className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">🔧</span>
<span className="font-semibold text-sm">{formatMessage({ id: 'reviewSession.fixProgress.title' })}</span>
</div>
{/* Stage Dots */}
<div className="flex gap-1">
{stages.map((stage, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full ${
stage.status === 'completed' ? 'bg-green-500' :
stage.status === 'in-progress' ? 'bg-blue-500' :
'bg-gray-300 dark:bg-gray-600'
}`}
title={`Stage ${i + 1}: ${stage.status}`}
/>
))}
</div>
</div>
{/* Carousel */}
<div className="overflow-hidden">
<div
className="flex transition-transform duration-300 ease-in-out"
style={{ transform: `translateX(-${currentSlide * 100}%)` }}
>
{/* Slide 1: Overview */}
<div className="w-full flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<Badge variant={phase === 'planning' ? 'secondary' : phase === 'execution' ? 'default' : 'success'}>
{phaseIcon} {formatMessage({ id: `reviewSession.fixProgress.phase.${phase}` })}
</Badge>
<span className="text-xs text-muted-foreground">{fixProgressData.fix_session_id}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${percent_complete}%` }}
/>
</div>
<div className="text-xs text-muted-foreground text-center">
{formatMessage({ id: 'reviewSession.fixProgress.complete' }, { percent: percent_complete.toFixed(0) })} · {formatMessage({ id: 'reviewSession.fixProgress.stage' })} {current_stage}/{total_stages}
</div>
</div>
{/* Slide 2: Stats */}
<div className="w-full flex-shrink-0">
<div className="grid grid-cols-4 gap-2">
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold">{total_findings}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.total' })}</div>
</div>
<div className="text-center p-2 bg-green-100 dark:bg-green-900/20 rounded">
<div className="text-lg font-bold text-green-600 dark:text-green-400">{fixed_count}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.fixed' })}</div>
</div>
<div className="text-center p-2 bg-red-100 dark:bg-red-900/20 rounded">
<div className="text-lg font-bold text-red-600 dark:text-red-400">{failed_count}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.failed' })}</div>
</div>
<div className="text-center p-2 bg-yellow-100 dark:bg-yellow-900/20 rounded">
<div className="text-lg font-bold text-yellow-600 dark:text-yellow-400">{pending_count + in_progress_count}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.fixProgress.stats.pending' })}</div>
</div>
</div>
</div>
{/* Slide 3: Active Agents (if any) */}
{active_agents.length > 0 && (
<div className="w-full flex-shrink-0">
<div className="text-sm font-semibold mb-2">
{active_agents.length} {active_agents.length === 1 ? formatMessage({ id: 'reviewSession.fixProgress.activeAgents' }) : formatMessage({ id: 'reviewSession.fixProgress.activeAgentsPlural' })}
</div>
<div className="space-y-2">
{active_agents.slice(0, 2).map((agent, i) => (
<div key={i} className="flex items-center gap-2 p-2 bg-muted rounded">
<span>🤖</span>
<span className="text-sm">{agent.current_finding?.finding_title || formatMessage({ id: 'reviewSession.fixProgress.working' })}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Carousel Navigation */}
{totalSlides > 1 && (
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={() => navigateSlide('prev')}>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="flex gap-1">
{Array.from({ length: totalSlides }).map((_, i) => (
<button
key={i}
className={`w-2 h-2 rounded-full transition-colors ${
currentSlide === i ? 'bg-primary' : 'bg-gray-300 dark:bg-gray-600'
}`}
onClick={() => navigateSlide(i)}
/>
))}
</div>
<Button variant="outline" size="sm" onClick={() => navigateSlide('next')}>
<ChevronRightIcon className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
);
}
/**
* ReviewSessionPage component - Display review session findings
*/
@@ -61,11 +286,13 @@ export function ReviewSessionPage() {
const [severityFilter, setSeverityFilter] = React.useState<Set<SeverityFilter>>(
new Set(['critical', 'high', 'medium', 'low'])
);
const [dimensionFilter, setDimensionFilter] = React.useState<string>('all');
const [searchQuery, setSearchQuery] = React.useState('');
const [sortField, setSortField] = React.useState<SortField>('severity');
const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc');
const [selectedFindings, setSelectedFindings] = React.useState<Set<string>>(new Set());
const [expandedFindings, setExpandedFindings] = React.useState<Set<string>>(new Set());
const [selectedFindingId, setSelectedFindingId] = React.useState<string | null>(null);
const handleBack = () => {
navigate('/sessions');
@@ -83,6 +310,12 @@ export function ReviewSessionPage() {
});
};
const resetFilters = () => {
setSeverityFilter(new Set(['critical', 'high', 'medium', 'low']));
setDimensionFilter('all');
setSearchQuery('');
};
const toggleSelectFinding = (findingId: string) => {
setSelectedFindings(prev => {
const next = new Set(prev);
@@ -104,6 +337,22 @@ export function ReviewSessionPage() {
}
};
const selectVisibleFindings = () => {
const validIds = filteredFindings.map(f => f.id).filter((id): id is string => id !== undefined);
setSelectedFindings(new Set(validIds));
};
const selectBySeverity = (severity: FindingWithSelection['severity']) => {
const criticalIds = flattenedFindings
.filter(f => f.severity === severity && f.id !== undefined)
.map(f => f.id!);
setSelectedFindings(prev => {
const next = new Set(prev);
criticalIds.forEach(id => next.add(id));
return next;
});
};
const toggleExpandFinding = (findingId: string) => {
setExpandedFindings(prev => {
const next = new Set(prev);
@@ -116,6 +365,10 @@ export function ReviewSessionPage() {
});
};
const handleFindingClick = (findingId: string) => {
setSelectedFindingId(findingId);
};
const exportSelectedAsJson = () => {
const selected = flattenedFindings.filter(f => f.id !== undefined && selectedFindings.has(f.id));
if (selected.length === 0) return;
@@ -148,12 +401,26 @@ export function ReviewSessionPage() {
// Severity order for sorting
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
// Calculate dimension counts
const dimensionCounts = React.useMemo(() => {
const counts: Record<string, number> = { all: flattenedFindings.length };
flattenedFindings.forEach(f => {
counts[f.dimension] = (counts[f.dimension] || 0) + 1;
});
return counts;
}, [flattenedFindings]);
// Filter and sort findings
const filteredFindings = React.useMemo(() => {
let filtered = flattenedFindings;
// Apply dimension filter
if (dimensionFilter !== 'all') {
filtered = filtered.filter(f => f.dimension === dimensionFilter);
}
// Apply severity filter
if (severityFilter.size > 0 && !severityFilter.has('all' as SeverityFilter)) {
if (severityFilter.size > 0) {
filtered = filtered.filter(f => severityFilter.has(f.severity));
}
@@ -186,7 +453,7 @@ export function ReviewSessionPage() {
});
return filtered;
}, [flattenedFindings, severityFilter, searchQuery, sortField, sortOrder]);
}, [flattenedFindings, severityFilter, dimensionFilter, searchQuery, sortField, sortOrder]);
// Get severity badge props
const getSeverityBadge = (severity: FindingWithSelection['severity']) => {
@@ -256,6 +523,11 @@ export function ReviewSessionPage() {
const dimensions = reviewSession.reviewDimensions || [];
const totalFindings = flattenedFindings.length;
// Determine session status (ACTIVE or ARCHIVED)
const isActive = reviewSession._isActive !== false;
const sessionStatus = isActive ? 'ACTIVE' : 'ARCHIVED';
const phase = reviewSession.phase || 'in-progress';
return (
<div className="space-y-6">
{/* Header */}
@@ -266,65 +538,99 @@ export function ReviewSessionPage() {
{formatMessage({ id: 'common.actions.back' })}
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
{formatMessage({ id: 'reviewSession.title' })}
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
🔍 {reviewSession.session_id}
</h1>
<p className="text-sm text-muted-foreground">{reviewSession.session_id}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="review">Review</Badge>
<Badge variant={isActive ? "success" : "secondary"} className="text-xs">
{sessionStatus}
</Badge>
</div>
</div>
</div>
<Badge variant="info">
{formatMessage({ id: 'reviewSession.type' })}
</Badge>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{totalFindings}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.total' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-destructive">{severityCounts.critical}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.severity.critical' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-warning">{severityCounts.high}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.severity.high' })}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{dimensions.length}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.dimensions' })}</div>
</CardContent>
</Card>
</div>
{/* Review Progress Section */}
<Card>
<CardContent className="p-4 space-y-4">
{/* Review Progress Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">📊</span>
<span className="font-semibold">{formatMessage({ id: 'reviewSession.progress.title' })}</span>
</div>
<Badge variant="secondary">{phase.toUpperCase()}</Badge>
</div>
{/* Summary Cards Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<span className="text-2xl">📊</span>
<div>
<div className="text-lg font-bold">{totalFindings}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.totalFindings' })}</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-red-100 dark:bg-red-900/20 rounded-lg">
<span className="text-2xl">🔴</span>
<div>
<div className="text-lg font-bold text-red-600 dark:text-red-400">{severityCounts.critical}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.critical' })}</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-orange-100 dark:bg-orange-900/20 rounded-lg">
<span className="text-2xl">🟠</span>
<div>
<div className="text-lg font-bold text-orange-600 dark:text-orange-400">{severityCounts.high}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.progress.high' })}</div>
</div>
</div>
<div className="flex items-center gap-3 p-3 bg-blue-100 dark:bg-blue-900/20 rounded-lg">
<span className="text-2xl">🎯</span>
<div>
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{dimensions.length}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'reviewSession.stats.dimensions' })}</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Fix Progress Carousel */}
{sessionId && <FixProgressCarousel sessionId={sessionId} />}
{/* Filters and Controls */}
<Card>
<CardContent className="p-4 space-y-4">
{/* Severity Filters */}
<div className="flex flex-wrap gap-2">
{(['critical', 'high', 'medium', 'low'] as const).map(severity => {
const isEnabled = severityFilter.has(severity);
const badge = getSeverityBadge(severity);
return (
<Badge
key={severity}
variant={isEnabled ? badge.variant : 'outline'}
className={`cursor-pointer ${isEnabled ? '' : 'opacity-50'}`}
onClick={() => toggleSeverity(severity)}
>
<badge.icon className="h-3 w-3 mr-1" />
{badge.label}: {severityCounts[severity]}
</Badge>
);
})}
{/* Checkbox-style Severity Filters */}
<div className="space-y-3">
<div className="text-sm font-medium">{formatMessage({ id: 'reviewSession.filters.severity' })}</div>
<div className="flex flex-wrap gap-2">
{(['critical', 'high', 'medium', 'low'] as const).map(severity => {
const isEnabled = severityFilter.has(severity);
return (
<label
key={severity}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border cursor-pointer transition-colors ${
isEnabled
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border hover:bg-muted'
}`}
>
<input
type="checkbox"
checked={isEnabled}
onChange={() => toggleSeverity(severity)}
className="sr-only"
/>
<span className="text-sm font-medium">
{formatMessage({ id: `reviewSession.severity.${severity}` })}
</span>
</label>
);
})}
</div>
</div>
{/* Search and Sort */}
@@ -355,6 +661,9 @@ export function ReviewSessionPage() {
>
{sortOrder === 'asc' ? '↑' : '↓'}
</Button>
<Button variant="outline" size="sm" onClick={resetFilters}>
{formatMessage({ id: 'reviewSession.filters.reset' })}
</Button>
</div>
{/* Selection Controls */}
@@ -368,6 +677,12 @@ export function ReviewSessionPage() {
? formatMessage({ id: 'reviewSession.selection.clearAll' })
: formatMessage({ id: 'reviewSession.selection.selectAll' })}
</Button>
<Button variant="outline" size="sm" onClick={selectVisibleFindings}>
{formatMessage({ id: 'reviewSession.selection.selectVisible' })}
</Button>
<Button variant="outline" size="sm" onClick={() => selectBySeverity('critical')}>
{formatMessage({ id: 'reviewSession.selection.selectCritical' })}
</Button>
<Button
variant="outline"
size="sm"
@@ -384,12 +699,39 @@ export function ReviewSessionPage() {
className="gap-2"
>
<Download className="h-4 w-4" />
{formatMessage({ id: 'reviewSession.export' })}
🔧 {formatMessage({ id: 'reviewSession.export' })}
</Button>
</div>
</CardContent>
</Card>
{/* Dimension Tabs */}
<div className="flex flex-wrap gap-2">
<button
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
dimensionFilter === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
onClick={() => setDimensionFilter('all')}
>
{formatMessage({ id: 'reviewSession.dimensionTabs.all' })} ({dimensionCounts.all || 0})
</button>
{dimensions.map(dim => (
<button
key={dim.name}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
dimensionFilter === dim.name
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
onClick={() => setDimensionFilter(dim.name)}
>
{dim.name} ({dim.findings?.length || 0})
</button>
))}
</div>
{/* Findings List */}
{filteredFindings.length === 0 ? (
<Card>

View File

@@ -27,7 +27,7 @@ import { ReviewTab } from './session-detail/ReviewTab';
import { TaskDrawer } from '@/components/shared/TaskDrawer';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import type { TaskData } from '@/types/store';
type TabValue = 'tasks' | 'context' | 'summary' | 'impl-plan' | 'conflict' | 'review';
@@ -103,6 +103,43 @@ export function SessionDetailPage() {
const completedTasks = tasks.filter((t) => t.status === 'completed').length;
const hasReview = session.has_review || session.review;
const tabs: TabItem[] = [
{
value: 'tasks',
label: formatMessage({ id: 'sessionDetail.tabs.tasks' }),
icon: <ListChecks className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{tasks.length}</Badge>,
},
{
value: 'context',
label: formatMessage({ id: 'sessionDetail.tabs.context' }),
icon: <Package className="h-4 w-4" />,
},
{
value: 'summary',
label: formatMessage({ id: 'sessionDetail.tabs.summary' }),
icon: <FileText className="h-4 w-4" />,
},
{
value: 'impl-plan',
label: formatMessage({ id: 'sessionDetail.tabs.implPlan' }),
icon: <Ruler className="h-4 w-4" />,
},
{
value: 'conflict',
label: formatMessage({ id: 'sessionDetail.tabs.conflict' }),
icon: <Scale className="h-4 w-4" />,
},
];
if (hasReview) {
tabs.push({
value: 'review',
label: formatMessage({ id: 'sessionDetail.tabs.review' }),
icon: <Search className="h-4 w-4" />,
});
}
return (
<div className="space-y-6">
{/* Header */}
@@ -148,65 +185,48 @@ export function SessionDetailPage() {
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
<TabsTrigger value="tasks">
<ListChecks className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.tasks' })}
<Badge variant="secondary" className="ml-2">
{tasks.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="context">
<Package className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.context' })}
</TabsTrigger>
<TabsTrigger value="summary">
<FileText className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.summary' })}
</TabsTrigger>
<TabsTrigger value="impl-plan">
<Ruler className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.implPlan' })}
</TabsTrigger>
<TabsTrigger value="conflict">
<Scale className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.conflict' })}
</TabsTrigger>
{hasReview && (
<TabsTrigger value="review">
<Search className="h-4 w-4 mr-2" />
{formatMessage({ id: 'sessionDetail.tabs.review' })}
</TabsTrigger>
)}
</TabsList>
<TabsNavigation
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
tabs={tabs}
/>
<TabsContent value="tasks" className="mt-4">
{/* Tab Content */}
{activeTab === 'tasks' && (
<div className="mt-4">
<TaskListTab session={session} onTaskClick={setSelectedTask} />
</TabsContent>
</div>
)}
<TabsContent value="context" className="mt-4">
{activeTab === 'context' && (
<div className="mt-4">
<ContextTab context={context} />
</TabsContent>
</div>
)}
<TabsContent value="summary" className="mt-4">
{activeTab === 'summary' && (
<div className="mt-4">
<SummaryTab summary={summary} summaries={summaries} />
</TabsContent>
</div>
)}
<TabsContent value="impl-plan" className="mt-4">
{activeTab === 'impl-plan' && (
<div className="mt-4">
<ImplPlanTab implPlan={implPlan} />
</TabsContent>
</div>
)}
<TabsContent value="conflict" className="mt-4">
{activeTab === 'conflict' && (
<div className="mt-4">
<ConflictTab conflicts={conflicts as any} />
</TabsContent>
</div>
)}
{hasReview && (
<TabsContent value="review" className="mt-4">
<ReviewTab review={review as any} />
</TabsContent>
)}
</Tabs>
{hasReview && activeTab === 'review' && (
<div className="mt-4">
<ReviewTab review={review as any} />
</div>
)}
{/* Description (if exists) */}
{session.description && (

View File

@@ -40,7 +40,7 @@ import {
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/Dropdown';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { cn } from '@/lib/utils';
import type { SessionMetadata } from '@/types/store';
@@ -174,13 +174,15 @@ export function SessionsPage() {
{/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Location tabs */}
<Tabs value={locationFilter} onValueChange={(v) => setLocationFilter(v as LocationFilter)}>
<TabsList>
<TabsTrigger value="active">{formatMessage({ id: 'sessions.filters.active' })}</TabsTrigger>
<TabsTrigger value="archived">{formatMessage({ id: 'sessions.filters.archived' })}</TabsTrigger>
<TabsTrigger value="all">{formatMessage({ id: 'sessions.filters.all' })}</TabsTrigger>
</TabsList>
</Tabs>
<TabsNavigation
value={locationFilter}
onValueChange={(v) => setLocationFilter(v as LocationFilter)}
tabs={[
{ value: 'active', label: formatMessage({ id: 'sessions.filters.active' }) },
{ value: 'archived', label: formatMessage({ id: 'sessions.filters.archived' }) },
{ value: 'all', label: formatMessage({ id: 'sessions.filters.all' }) },
]}
/>
{/* Search input */}
<div className="flex-1 max-w-sm relative">

View File

@@ -1,18 +1,18 @@
// ========================================
// Coordinator Page
// Coordinator Page - Merged Layout
// ========================================
// Page for monitoring and managing coordinator workflow execution with timeline, logs, and node details
// Unified page for task list overview and execution details with timeline, logs, and node details
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Play } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Play, CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import {
CoordinatorInputModal,
CoordinatorTimeline,
CoordinatorLogStream,
NodeDetailsPanel,
CoordinatorEmptyState,
} from '@/components/coordinator';
import {
useCoordinatorStore,
@@ -21,11 +21,164 @@ import {
selectCoordinatorStatus,
selectIsPipelineLoaded,
} from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
// ========================================
// Types
// ========================================
interface CoordinatorTask {
id: string;
name: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: {
completed: number;
total: number;
};
startedAt: string;
completedAt?: string;
}
// ========================================
// Mock Data (temporary - will be replaced by store)
// ========================================
const MOCK_TASKS: CoordinatorTask[] = [
{
id: 'task-1',
name: 'Feature Auth',
status: 'running',
progress: { completed: 3, total: 5 },
startedAt: '2026-02-03T14:23:00Z',
},
{
id: 'task-2',
name: 'API Integration',
status: 'completed',
progress: { completed: 8, total: 8 },
startedAt: '2026-02-03T10:00:00Z',
completedAt: '2026-02-03T10:15:00Z',
},
{
id: 'task-3',
name: 'Performance Test',
status: 'failed',
progress: { completed: 2, total: 6 },
startedAt: '2026-02-03T09:00:00Z',
},
];
// ========================================
// Task Card Component (inline)
// ========================================
interface TaskCardProps {
task: CoordinatorTask;
isSelected: boolean;
onClick: () => void;
}
function TaskCard({ task, isSelected, onClick }: TaskCardProps) {
const { formatMessage } = useIntl();
const statusConfig = {
pending: {
icon: Clock,
color: 'text-muted-foreground',
bg: 'bg-muted/50',
},
running: {
icon: Loader2,
color: 'text-blue-500',
bg: 'bg-blue-500/10',
},
completed: {
icon: CheckCircle2,
color: 'text-green-500',
bg: 'bg-green-500/10',
},
failed: {
icon: XCircle,
color: 'text-red-500',
bg: 'bg-red-500/10',
},
};
const config = statusConfig[task.status];
const StatusIcon = config.icon;
const progressPercent = Math.round((task.progress.completed / task.progress.total) * 100);
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex flex-col p-3 rounded-lg border transition-all text-left w-full min-w-[160px] max-w-[200px]',
'hover:border-primary/50 hover:shadow-sm',
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-card'
)}
>
{/* Task Name */}
<div className="flex items-center gap-2 mb-2">
<StatusIcon
className={cn(
'w-4 h-4 flex-shrink-0',
config.color,
task.status === 'running' && 'animate-spin'
)}
/>
<span className="text-sm font-medium text-foreground truncate">
{task.name}
</span>
</div>
{/* Status Badge */}
<div
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium mb-2 w-fit',
config.bg,
config.color
)}
>
{formatMessage({ id: `coordinator.status.${task.status}` })}
</div>
{/* Progress */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{task.progress.completed}/{task.progress.total}
</span>
<span>{progressPercent}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
task.status === 'completed' && 'bg-green-500',
task.status === 'running' && 'bg-blue-500',
task.status === 'failed' && 'bg-red-500',
task.status === 'pending' && 'bg-muted-foreground'
)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
</button>
);
}
// ========================================
// Main Component
// ========================================
export function CoordinatorPage() {
const { formatMessage } = useIntl();
const [isInputModalOpen, setIsInputModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
// Store selectors
const commandChain = useCoordinatorStore(selectCommandChain);
@@ -33,7 +186,11 @@ export function CoordinatorPage() {
const status = useCoordinatorStore(selectCoordinatorStatus);
const isPipelineLoaded = useCoordinatorStore(selectIsPipelineLoaded);
const syncStateFromServer = useCoordinatorStore((state) => state.syncStateFromServer);
const reset = useCoordinatorStore((state) => state.reset);
// Mock tasks (temporary - will be replaced by store)
const tasks = useMemo(() => MOCK_TASKS, []);
const hasTasks = tasks.length > 0;
const selectedTask = tasks.find((t) => t.id === selectedTaskId);
// Sync state on mount (for page refresh scenarios)
useEffect(() => {
@@ -52,12 +209,21 @@ export function CoordinatorPage() {
setSelectedNode(nodeId);
}, []);
// Handle task selection
const handleTaskClick = useCallback((taskId: string) => {
setSelectedTaskId((prev) => (prev === taskId ? null : taskId));
setSelectedNode(null);
}, []);
// Get selected node object
const selectedNodeObject = commandChain.find((node) => node.id === selectedNode) || currentNode || null;
const selectedNodeObject =
commandChain.find((node) => node.id === selectedNode) || currentNode || null;
return (
<div className="h-full flex flex-col -m-4 md:-m-6">
{/* ======================================== */}
{/* Toolbar */}
{/* ======================================== */}
<div className="flex items-center gap-3 p-3 bg-card border-b border-border">
{/* Page Title and Status */}
<div className="flex items-center gap-2 min-w-0 flex-1">
@@ -68,9 +234,12 @@ export function CoordinatorPage() {
</span>
{isPipelineLoaded && (
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'coordinator.page.status' }, {
status: formatMessage({ id: `coordinator.status.${status}` }),
})}
{formatMessage(
{ id: 'coordinator.page.status' },
{
status: formatMessage({ id: `coordinator.status.${status}` }),
}
)}
</span>
)}
</div>
@@ -90,43 +259,90 @@ export function CoordinatorPage() {
</div>
</div>
{/* Main Content Area - 3 Panel Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Left Panel: Timeline */}
<div className="w-1/3 min-w-[300px] border-r border-border bg-card">
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
className="h-full"
{/* ======================================== */}
{/* Main Content Area */}
{/* ======================================== */}
{!hasTasks ? (
/* Empty State - No tasks */
<div className="flex-1 flex overflow-hidden">
<CoordinatorEmptyState
onStart={handleOpenInputModal}
disabled={status === 'running' || status === 'initializing'}
className="w-full"
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* ======================================== */}
{/* Task List Area */}
{/* ======================================== */}
<div className="p-4 border-b border-border bg-background">
<div className="flex gap-3 overflow-x-auto pb-2">
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onClick={() => handleTaskClick(task.id)}
/>
))}
</div>
</div>
{/* Center Panel: Log Stream */}
<div className="flex-1 min-w-0 bg-card">
<CoordinatorLogStream />
</div>
{/* ======================================== */}
{/* Task Detail Area (shown when task is selected) */}
{/* ======================================== */}
{selectedTask ? (
<div className="flex-1 flex overflow-hidden">
{/* Left Panel: Timeline */}
<div className="w-1/3 min-w-[300px] border-r border-border bg-card">
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
className="h-full"
/>
</div>
{/* Right Panel: Node Details */}
<div className="w-80 min-w-[320px] max-w-[400px] border-l border-border bg-card overflow-y-auto">
{selectedNodeObject ? (
<NodeDetailsPanel
node={selectedNodeObject}
isExpanded={true}
onToggle={(expanded) => {
if (!expanded) {
setSelectedNode(null);
}
}}
/>
{/* Center Panel: Log Stream */}
<div className="flex-1 min-w-0 flex flex-col bg-card">
<div className="flex-1 min-h-0">
<CoordinatorLogStream />
</div>
</div>
{/* Right Panel: Node Details */}
<div className="w-80 min-w-[320px] max-w-[400px] border-l border-border bg-card overflow-y-auto">
{selectedNodeObject ? (
<NodeDetailsPanel
node={selectedNodeObject}
isExpanded={true}
onToggle={(expanded) => {
if (!expanded) {
setSelectedNode(null);
}
}}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm p-4 text-center">
{formatMessage({ id: 'coordinator.page.noNodeSelected' })}
</div>
)}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm p-4 text-center">
{formatMessage({ id: 'coordinator.page.noNodeSelected' })}
/* No task selected - show selection prompt */
<div className="flex-1 flex items-center justify-center bg-muted/30">
<div className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.taskDetail.noSelection' })}
</div>
</div>
)}
</div>
</div>
)}
{/* ======================================== */}
{/* Coordinator Input Modal */}
{/* ======================================== */}
<CoordinatorInputModal
open={isInputModalOpen}
onClose={() => setIsInputModalOpen(false)}

View File

@@ -0,0 +1,6 @@
// ========================================
// Coordinator Page Export
// ========================================
// Barrel export for CoordinatorPage component
export { CoordinatorPage } from './CoordinatorPage';

View File

@@ -27,10 +27,10 @@ export { ReviewSessionPage } from './ReviewSessionPage';
export { McpManagerPage } from './McpManagerPage';
export { EndpointsPage } from './EndpointsPage';
export { InstallationsPage } from './InstallationsPage';
export { ExecutionMonitorPage } from './ExecutionMonitorPage';
export { RulesManagerPage } from './RulesManagerPage';
export { PromptHistoryPage } from './PromptHistoryPage';
export { ExplorerPage } from './ExplorerPage';
export { GraphExplorerPage } from './GraphExplorerPage';
export { CodexLensManagerPage } from './CodexLensManagerPage';
export { ApiSettingsPage } from './ApiSettingsPage';
export { CliViewerPage } from './CliViewerPage';

View File

@@ -257,12 +257,6 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
{/* Row 2: Meta info */}
<div className="flex items-center gap-3 flex-wrap justify-end text-xs text-muted-foreground">
{priority && (
<Badge variant={priority.variant} className="text-xs gap-1">
<Zap className="h-3 w-3" />
{priority.label}
</Badge>
)}
{taskType && (
<span className="bg-muted px-1.5 py-0.5 rounded">{taskType}</span>
)}