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:
catlog22
2026-02-14 21:35:55 +08:00
parent 0d805efe87
commit d535ab4749
27 changed files with 2004 additions and 2363 deletions

View File

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

View File

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

View File

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

View 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>
);
}

View File

@@ -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) => (

View File

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

View File

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