mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
feat: Implement workflow phases for test generation and execution
- Added Phase 1: Session Start to detect input mode and create test workflow session. - Added Phase 2: Test Context Gather to gather test context via coverage analysis or codebase scan. - Added Phase 3: Test Concept Enhanced to analyze test requirements using Gemini and generate multi-layered test requirements. - Added Phase 4: Test Task Generate to create test-specific tasks based on analysis results. - Added Phase 5: Test Cycle Execute to manage iterative test execution and fix cycles with adaptive strategies. - Introduced BottomPanel component for terminal dashboard with Queue and Inspector tabs.
This commit is contained in:
@@ -31,6 +31,7 @@ interface FlowchartNodeData extends Record<string, unknown> {
|
||||
type: 'pre-analysis' | 'implementation' | 'section';
|
||||
dependsOn?: string[];
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'blocked' | 'skipped';
|
||||
showStepStatus?: boolean;
|
||||
}
|
||||
|
||||
// Status icon component
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Badge } from '../ui/Badge';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/Tabs';
|
||||
import type { NormalizedTask, LiteTask } from '@/lib/api';
|
||||
import { buildFlowControl } from '@/lib/api';
|
||||
import { buildFlowControl, normalizeTask } from '@/lib/api';
|
||||
import type { TaskData } from '@/types/store';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -86,8 +86,11 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use NormalizedTask fields (works for both old nested and new flat formats)
|
||||
const nt = task as NormalizedTask;
|
||||
// Normalize task to unified flat format (handles old nested, new flat, and raw LiteTask/TaskData)
|
||||
const nt = React.useMemo(
|
||||
() => normalizeTask(task as unknown as Record<string, unknown>),
|
||||
[task],
|
||||
);
|
||||
const taskId = nt.task_id || 'N/A';
|
||||
const taskTitle = nt.title || 'Untitled Task';
|
||||
const taskDescription = nt.description;
|
||||
@@ -103,6 +106,13 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
const taskFiles = nt.files || flowControl?.target_files || [];
|
||||
const taskScope = nt.scope;
|
||||
|
||||
// Detect if task supports status tracking (new format has explicit status/status_history)
|
||||
const rawData = nt._raw as Record<string, unknown> | undefined;
|
||||
const sourceRaw = (rawData?._raw as Record<string, unknown>) || rawData;
|
||||
const hasStatusTracking = sourceRaw
|
||||
? (sourceRaw.status !== undefined || sourceRaw.status_history !== undefined)
|
||||
: false;
|
||||
|
||||
const statusConfig = taskStatusConfig[taskStatus] || taskStatusConfig.pending;
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
@@ -135,10 +145,12 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<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={statusConfig.variant} className="gap-1">
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{formatMessage({ id: statusConfig.label })}
|
||||
</Badge>
|
||||
{hasStatusTracking && (
|
||||
<Badge variant={statusConfig.variant} className="gap-1">
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{formatMessage({ id: statusConfig.label })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h2 id="drawer-title" className="text-lg font-semibold text-foreground">
|
||||
{taskTitle}
|
||||
@@ -352,9 +364,9 @@ export function TaskDrawer({ task, isOpen, onClose }: TaskDrawerProps) {
|
||||
|
||||
{/* Empty State */}
|
||||
{!taskDescription &&
|
||||
!(task as LiteTask).meta?.scope &&
|
||||
!((task as LiteTask).context?.acceptance?.length) &&
|
||||
!((task as LiteTask).context?.focus_paths?.length) &&
|
||||
!taskScope &&
|
||||
!acceptanceCriteria.length &&
|
||||
!focusPaths.length &&
|
||||
!(flowControl?.pre_analysis?.length) &&
|
||||
!(flowControl?.implementation_approach?.length) && (
|
||||
<div className="text-center py-12">
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// ========================================
|
||||
// BottomInspector Component
|
||||
// ========================================
|
||||
// Collapsible bottom panel showing the full association chain
|
||||
// (Issue -> Queue -> Session) for the currently selected entity.
|
||||
// Consumes issueQueueIntegrationStore for association chain data
|
||||
// and useAssociationHighlight context for the highlighted chain.
|
||||
// Association chain visualization (Issue -> Queue -> Session).
|
||||
// Exports InspectorContent (pure content) for embedding in BottomPanel,
|
||||
// and BottomInspector (legacy standalone collapsible wrapper).
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -90,7 +89,86 @@ function formatTimestamp(ts: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
// ========== InspectorContent (Pure content, no collapsible wrapper) ==========
|
||||
|
||||
export function InspectorContent() {
|
||||
const { formatMessage } = useIntl();
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const { chain: highlightedChain } = useAssociationHighlight();
|
||||
|
||||
const activeChain = highlightedChain ?? associationChain;
|
||||
|
||||
const chainDetails = useMemo(() => {
|
||||
if (!activeChain) return null;
|
||||
|
||||
const executions = Object.values(useQueueExecutionStore.getState().executions);
|
||||
const sessions = useCliSessionStore.getState().sessions;
|
||||
|
||||
let queueStatus: string | undefined;
|
||||
let executionTimestamp: string | undefined;
|
||||
if (activeChain.queueItemId) {
|
||||
const exec = executions.find((e) => e.queueItemId === activeChain.queueItemId);
|
||||
if (exec) {
|
||||
queueStatus = exec.status;
|
||||
executionTimestamp = exec.startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
let sessionStatus: string | undefined;
|
||||
let sessionTimestamp: string | undefined;
|
||||
if (activeChain.sessionId) {
|
||||
const session = sessions[activeChain.sessionId];
|
||||
if (session) {
|
||||
sessionStatus = 'active';
|
||||
sessionTimestamp = session.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
return { queueStatus, executionTimestamp, sessionStatus, sessionTimestamp };
|
||||
}, [activeChain]);
|
||||
|
||||
const hasChain = activeChain !== null;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{hasChain ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.inspector.associationChain' })}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<ChainNode
|
||||
icon={AlertCircle}
|
||||
label="Issue"
|
||||
entityId={activeChain.issueId}
|
||||
/>
|
||||
<ChainNode
|
||||
icon={ListChecks}
|
||||
label="Queue"
|
||||
entityId={activeChain.queueItemId}
|
||||
status={chainDetails?.queueStatus}
|
||||
timestamp={chainDetails?.executionTimestamp}
|
||||
/>
|
||||
<ChainNode
|
||||
icon={Terminal}
|
||||
label="Session"
|
||||
entityId={activeChain.sessionId}
|
||||
status={chainDetails?.sessionStatus}
|
||||
timestamp={chainDetails?.sessionTimestamp}
|
||||
isLast
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.inspector.noSelection' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Legacy Standalone Component ==========
|
||||
|
||||
export function BottomInspector() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
136
ccw/frontend/src/components/terminal-dashboard/BottomPanel.tsx
Normal file
136
ccw/frontend/src/components/terminal-dashboard/BottomPanel.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
// ========================================
|
||||
// BottomPanel Component
|
||||
// ========================================
|
||||
// Full-width collapsible bottom panel with Queue + Inspector tabs.
|
||||
// Replaces the separate BottomInspector + middle-column QueuePanel layout.
|
||||
// Queue tab shows inline count badge; Inspector tab shows chain indicator.
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ListChecks, Info, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { QueuePanel } from './QueuePanel';
|
||||
import { InspectorContent } from './BottomInspector';
|
||||
import { useIssueQueue } from '@/hooks/useIssues';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectAssociationChain,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
type TabId = 'queue' | 'inspector';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function BottomPanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('queue');
|
||||
|
||||
const queueQuery = useIssueQueue();
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
|
||||
// Count queue items for badge
|
||||
const queueCount = useMemo(() => {
|
||||
if (!queueQuery.data) return 0;
|
||||
const grouped = queueQuery.data.grouped_items ?? {};
|
||||
let count = 0;
|
||||
for (const items of Object.values(grouped)) {
|
||||
count += items.length;
|
||||
}
|
||||
return count;
|
||||
}, [queueQuery.data]);
|
||||
|
||||
const hasChain = associationChain !== null;
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleTabClick = useCallback((tab: TabId) => {
|
||||
setActiveTab(tab);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-border bg-muted/30 shrink-0 transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
{/* Tab bar (always visible, ~36px) */}
|
||||
<div className="flex items-center gap-0 shrink-0">
|
||||
{/* Queue tab */}
|
||||
<button
|
||||
onClick={() => handleTabClick('queue')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors border-b-2',
|
||||
activeTab === 'queue' && isOpen
|
||||
? 'border-b-primary text-foreground font-medium'
|
||||
: 'border-b-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<ListChecks className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.bottomPanel.queueTab' })}
|
||||
{queueCount > 0 && (
|
||||
<Badge variant="info" className="text-[10px] px-1.5 py-0 ml-0.5">
|
||||
{queueCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Inspector tab */}
|
||||
<button
|
||||
onClick={() => handleTabClick('inspector')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors border-b-2',
|
||||
activeTab === 'inspector' && isOpen
|
||||
? 'border-b-primary text-foreground font-medium'
|
||||
: 'border-b-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.bottomPanel.inspectorTab' })}
|
||||
{hasChain && (
|
||||
<span className="ml-1 w-2 h-2 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Collapse/expand toggle at right */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="ml-auto px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={formatMessage({
|
||||
id: isOpen
|
||||
? 'terminalDashboard.bottomPanel.collapse'
|
||||
: 'terminalDashboard.bottomPanel.expand',
|
||||
})}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapsible content area */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
isOpen ? 'max-h-[280px] opacity-100' : 'max-h-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="h-[280px] border-t border-border/50">
|
||||
{activeTab === 'queue' ? (
|
||||
<QueuePanel embedded />
|
||||
) : (
|
||||
<InspectorContent />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -131,12 +131,23 @@ function QueueItemRow({
|
||||
|
||||
// ========== Empty State ==========
|
||||
|
||||
function QueueEmptyState() {
|
||||
function QueueEmptyState({ compact = false }: { compact?: boolean }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground px-3 py-2">
|
||||
<ListChecks className="h-4 w-4 opacity-30 shrink-0" />
|
||||
<span className="text-xs">{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}</span>
|
||||
<span className="text-[10px] opacity-70">{formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc' })}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground p-4">
|
||||
<div className="text-center">
|
||||
<ListChecks className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||
<ListChecks className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc' })}
|
||||
@@ -163,7 +174,7 @@ function QueueErrorState({ error }: { error: Error }) {
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function QueuePanel() {
|
||||
export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const queueQuery = useIssueQueue();
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
@@ -200,12 +211,14 @@ export function QueuePanel() {
|
||||
if (queueQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -217,12 +230,14 @@ export function QueuePanel() {
|
||||
if (queueQuery.error) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<QueueErrorState error={queueQuery.error} />
|
||||
</div>
|
||||
);
|
||||
@@ -230,23 +245,25 @@ export function QueuePanel() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with flow indicator */}
|
||||
<div className="px-3 py-2 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4 text-muted-foreground" />
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
{activeCount > 0 && (
|
||||
<Badge variant="info" className="text-[10px] px-1.5 py-0">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Header with flow indicator (hidden when embedded) */}
|
||||
{!embedded && (
|
||||
<div className="px-3 py-2 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4 text-muted-foreground" />
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
{activeCount > 0 && (
|
||||
<Badge variant="info" className="text-[10px] px-1.5 py-0">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Queue Item List */}
|
||||
{allItems.length === 0 ? (
|
||||
<QueueEmptyState />
|
||||
<QueueEmptyState compact={embedded} />
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-1.5 space-y-0.5">
|
||||
{allItems.map((item) => (
|
||||
|
||||
@@ -2062,6 +2062,7 @@ export function normalizeTask(raw: Record<string, unknown>): NormalizedTask {
|
||||
const rawFlowControl = raw.flow_control as FlowControl | undefined;
|
||||
const rawMeta = raw.meta as LiteTask['meta'] | undefined;
|
||||
const rawConvergence = raw.convergence as NormalizedTask['convergence'] | undefined;
|
||||
const rawModPoints = raw.modification_points as Array<{ file?: string; target?: string; change?: string }> | undefined;
|
||||
|
||||
// Description: new flat field first, then join old context.requirements, then old details/scope
|
||||
const rawRequirements = rawContext?.requirements;
|
||||
@@ -2075,6 +2076,25 @@ export function normalizeTask(raw: Record<string, unknown>): NormalizedTask {
|
||||
: undefined)
|
||||
|| (raw.scope as string | undefined);
|
||||
|
||||
// Normalize files: new flat files > flow_control.target_files > modification_points
|
||||
const normalizedFiles = normalizeFilesField(raw.files)
|
||||
|| rawFlowControl?.target_files
|
||||
|| (rawModPoints?.length
|
||||
? rawModPoints.filter(m => m.file).map(m => ({ path: m.file!, name: m.target, change: m.change }))
|
||||
: undefined);
|
||||
|
||||
// Normalize focus_paths: top-level > context > files paths
|
||||
const focusPaths = (raw.focus_paths as string[])
|
||||
|| rawContext?.focus_paths
|
||||
|| (normalizedFiles?.length ? normalizedFiles.map(f => f.path).filter(Boolean) : undefined)
|
||||
|| [];
|
||||
|
||||
// Normalize acceptance: convergence > context.acceptance > top-level acceptance
|
||||
const rawAcceptance = raw.acceptance as string[] | undefined;
|
||||
const convergence = rawConvergence
|
||||
|| (rawContext?.acceptance?.length ? { criteria: rawContext.acceptance } : undefined)
|
||||
|| (rawAcceptance?.length ? { criteria: rawAcceptance } : undefined);
|
||||
|
||||
return {
|
||||
// Identity
|
||||
task_id: (raw.task_id as string) || (raw.id as string) || 'N/A',
|
||||
@@ -2089,15 +2109,13 @@ export function normalizeTask(raw: Record<string, unknown>): NormalizedTask {
|
||||
|
||||
// Promoted from context (new first, old fallback)
|
||||
depends_on: (raw.depends_on as string[]) || rawContext?.depends_on || [],
|
||||
focus_paths: (raw.focus_paths as string[]) || rawContext?.focus_paths || [],
|
||||
convergence: rawConvergence || (rawContext?.acceptance?.length
|
||||
? { criteria: rawContext.acceptance }
|
||||
: undefined),
|
||||
focus_paths: focusPaths,
|
||||
convergence,
|
||||
|
||||
// Promoted from flow_control (new first, old fallback)
|
||||
pre_analysis: (raw.pre_analysis as PreAnalysisStep[]) || rawFlowControl?.pre_analysis,
|
||||
implementation: (raw.implementation as (ImplementationStep | string)[]) || rawFlowControl?.implementation_approach,
|
||||
files: normalizeFilesField(raw.files) || rawFlowControl?.target_files,
|
||||
files: normalizedFiles,
|
||||
|
||||
// Promoted from meta (new first, old fallback)
|
||||
type: (raw.type as string) || rawMeta?.type,
|
||||
|
||||
@@ -2,49 +2,73 @@
|
||||
// Terminal Dashboard Page
|
||||
// ========================================
|
||||
// Three-column Allotment layout for terminal execution management.
|
||||
// Left: session groups + agent list
|
||||
// Middle: issue + queue workflow panels
|
||||
// Right: terminal workbench
|
||||
// Top: GlobalKpiBar (real component)
|
||||
// Bottom: BottomInspector (collapsible, real component)
|
||||
// Left: session groups + agent list (with active session count badge)
|
||||
// Middle: full-height IssuePanel
|
||||
// Right: terminal workbench (or issue detail preview)
|
||||
// Bottom: collapsible BottomPanel (Queue + Inspector tabs)
|
||||
// Cross-cutting: AssociationHighlightProvider wraps the layout
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Allotment } from 'allotment';
|
||||
import 'allotment/dist/style.css';
|
||||
import { FolderTree } from 'lucide-react';
|
||||
import { FolderTree, Activity } from 'lucide-react';
|
||||
import { SessionGroupTree } from '@/components/terminal-dashboard/SessionGroupTree';
|
||||
import { AgentList } from '@/components/terminal-dashboard/AgentList';
|
||||
import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel';
|
||||
import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel';
|
||||
import { TerminalWorkbench } from '@/components/terminal-dashboard/TerminalWorkbench';
|
||||
import { GlobalKpiBar } from '@/components/terminal-dashboard/GlobalKpiBar';
|
||||
import { BottomInspector } from '@/components/terminal-dashboard/BottomInspector';
|
||||
import { BottomPanel } from '@/components/terminal-dashboard/BottomPanel';
|
||||
import { AssociationHighlightProvider } from '@/components/terminal-dashboard/AssociationHighlight';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectGroups,
|
||||
selectTerminalMetas,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function TerminalDashboardPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
|
||||
// Active session count for left column header badge
|
||||
const sessionCount = useMemo(() => {
|
||||
const allSessionIds = groups.flatMap((g) => g.sessionIds);
|
||||
let activeCount = 0;
|
||||
for (const sid of allSessionIds) {
|
||||
const meta = terminalMetas[sid];
|
||||
const status: TerminalStatus = meta?.status ?? 'idle';
|
||||
if (status === 'active') {
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
return activeCount > 0 ? activeCount : allSessionIds.length;
|
||||
}, [groups, terminalMetas]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-56px)] overflow-hidden">
|
||||
{/* GlobalKpiBar at top (flex-shrink-0) */}
|
||||
<GlobalKpiBar />
|
||||
|
||||
{/* AssociationHighlightProvider wraps the three-column layout + bottom inspector */}
|
||||
{/* AssociationHighlightProvider wraps the three-column layout + bottom panel */}
|
||||
<AssociationHighlightProvider>
|
||||
{/* Three-column Allotment layout (flex-1) */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<Allotment proportionalLayout={true}>
|
||||
{/* Left column: Sessions */}
|
||||
<Allotment.Pane preferredSize={250} minSize={180} maxSize={400}>
|
||||
{/* Left column: Sessions + Agents */}
|
||||
<Allotment.Pane preferredSize={220} minSize={180} maxSize={320}>
|
||||
<div className="h-full border-r border-border bg-background flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<FolderTree className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.columns.sessions' })}
|
||||
</h2>
|
||||
{sessionCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
{sessionCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* SessionGroupTree takes remaining space */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
@@ -57,23 +81,10 @@ export function TerminalDashboardPage() {
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
|
||||
{/* Middle column: Workflow (IssuePanel + QueuePanel vertical split) */}
|
||||
<Allotment.Pane minSize={300}>
|
||||
{/* Middle column: Full-height IssuePanel */}
|
||||
<Allotment.Pane minSize={280}>
|
||||
<div className="h-full border-r border-border bg-background overflow-hidden">
|
||||
<Allotment vertical proportionalLayout={true}>
|
||||
{/* Top: IssuePanel */}
|
||||
<Allotment.Pane minSize={120}>
|
||||
<div className="h-full overflow-hidden">
|
||||
<IssuePanel />
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
{/* Bottom: QueuePanel */}
|
||||
<Allotment.Pane minSize={120}>
|
||||
<div className="h-full overflow-hidden border-t border-border">
|
||||
<QueuePanel />
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
<IssuePanel />
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
|
||||
@@ -86,8 +97,8 @@ export function TerminalDashboardPage() {
|
||||
</Allotment>
|
||||
</div>
|
||||
|
||||
{/* BottomInspector at bottom (flex-shrink-0) */}
|
||||
<BottomInspector />
|
||||
{/* BottomPanel: collapsible Queue + Inspector tabs (full-width) */}
|
||||
<BottomPanel />
|
||||
</AssociationHighlightProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user