feat: Implement phases 6 to 9 of the review cycle fix process, including discovery, batching, parallel planning, execution, and completion

- Added Phase 6: Fix Discovery & Batching with intelligent grouping and batching of findings.
- Added Phase 7: Fix Parallel Planning to launch planning agents for concurrent analysis and aggregation of partial plans.
- Added Phase 8: Fix Execution for stage-based execution of fixes with conservative test verification.
- Added Phase 9: Fix Completion to aggregate results, generate summary reports, and handle session completion.
- Introduced new frontend components: ResizeHandle for draggable resizing of sidebar panels and useResizablePanel hook for managing panel sizes with localStorage persistence.
- Added PowerShell script for checking TypeScript errors in source code, excluding test files.
This commit is contained in:
catlog22
2026-02-07 19:28:33 +08:00
parent ba5f4eba84
commit d43696d756
90 changed files with 8462 additions and 616 deletions

View File

@@ -87,7 +87,7 @@ describe('CodexLensManagerPage', () => {
describe('when installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
@@ -97,7 +97,7 @@ describe('CodexLensManagerPage', () => {
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should render page title and description', () => {
@@ -134,7 +134,7 @@ describe('CodexLensManagerPage', () => {
it('should call refresh on button click', async () => {
const refetch = vi.fn();
vi.mocked(useCodexLensDashboard).mockReturnValue({
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
@@ -157,7 +157,7 @@ describe('CodexLensManagerPage', () => {
describe('when not installed', () => {
beforeEach(() => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
@@ -167,7 +167,7 @@ describe('CodexLensManagerPage', () => {
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should show bootstrap button', () => {
@@ -184,7 +184,7 @@ describe('CodexLensManagerPage', () => {
it('should call bootstrap on button click', async () => {
const bootstrap = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
bootstrap,
});
@@ -203,7 +203,7 @@ describe('CodexLensManagerPage', () => {
describe('uninstall flow', () => {
beforeEach(() => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
@@ -217,7 +217,7 @@ describe('CodexLensManagerPage', () => {
it('should show confirmation dialog on uninstall', async () => {
const uninstall = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
@@ -233,7 +233,7 @@ describe('CodexLensManagerPage', () => {
it('should call uninstall when confirmed', async () => {
const uninstall = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
@@ -252,7 +252,7 @@ describe('CodexLensManagerPage', () => {
it('should not call uninstall when cancelled', async () => {
(global.confirm as ReturnType<typeof vi.fn>).mockReturnValue(false);
const uninstall = vi.fn().mockResolvedValue({ success: true });
vi.mocked(useCodexLensMutations).mockReturnValue({
(vi.mocked(useCodexLensMutations) as any).mockReturnValue({
...mockMutations,
uninstall,
});
@@ -269,7 +269,7 @@ describe('CodexLensManagerPage', () => {
describe('loading states', () => {
it('should show loading skeleton when loading', () => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
@@ -279,7 +279,7 @@ describe('CodexLensManagerPage', () => {
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
@@ -289,7 +289,7 @@ describe('CodexLensManagerPage', () => {
});
it('should disable refresh button when fetching', () => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
@@ -299,7 +299,7 @@ describe('CodexLensManagerPage', () => {
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);
@@ -310,7 +310,7 @@ describe('CodexLensManagerPage', () => {
describe('i18n - Chinese locale', () => {
beforeEach(() => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: true,
status: mockDashboardData.status,
config: mockDashboardData.config,
@@ -320,7 +320,7 @@ describe('CodexLensManagerPage', () => {
error: null,
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
});
it('should display translated text in Chinese', () => {
@@ -343,7 +343,7 @@ describe('CodexLensManagerPage', () => {
describe('error states', () => {
it('should handle API errors gracefully', () => {
vi.mocked(useCodexLensDashboard).mockReturnValue({
(vi.mocked(useCodexLensDashboard) as any).mockReturnValue({
installed: false,
status: undefined,
config: undefined,
@@ -353,7 +353,7 @@ describe('CodexLensManagerPage', () => {
error: new Error('API Error'),
refetch: vi.fn(),
});
vi.mocked(useCodexLensMutations).mockReturnValue(mockMutations);
(vi.mocked(useCodexLensMutations) as any).mockReturnValue(mockMutations);
render(<CodexLensManagerPage />);

View File

@@ -15,6 +15,7 @@ import {
XCircle,
Folder,
User,
AlertCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -47,6 +48,7 @@ export function CommandsManagerPage() {
disabledCount,
isLoading,
isFetching,
error,
refetch,
} = useCommands({
filter: {
@@ -121,6 +123,20 @@ export function CommandsManagerPage() {
</Button>
</div>
{/* Error alert */}
{error && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'home.errors.retry' })}
</Button>
</div>
)}
{/* Location Tabs - styled like LiteTasksPage */}
<TabsNavigation
value={locationFilter}

View File

@@ -141,6 +141,21 @@ interface DiscussionRound {
};
}
interface ImplementationTask {
id: string;
title: string;
description?: string;
status?: string;
assignee?: string;
}
interface Milestone {
id: string;
name: string;
description?: string;
target_date?: string;
}
interface DiscussionSolution {
id: string;
name: string;
@@ -325,7 +340,7 @@ export function LiteTaskDetailPage() {
</div>
<Badge variant={isLitePlan ? 'info' : isLiteFix ? 'warning' : 'default'} className="gap-1">
{isLitePlan ? <FileEdit className="h-3 w-3" /> : isLiteFix ? <Wrench className="h-3 w-3" /> : <MessageSquare className="h-3 w-3" />}
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : isLiteFix ? 'liteTasks.type.fix' : 'liteTasks.type.multiCli' })}
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : isLiteFix ? 'liteTasks.type.fix' : 'liteTasks.type.multiCli' }) as React.ReactNode}
</Badge>
</div>
@@ -564,7 +579,7 @@ export function LiteTaskDetailPage() {
)}
{/* Tech Stack from Session Metadata */}
{session.metadata?.tech_stack && (
{!!session.metadata?.tech_stack && (
<div>
<h5 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<Settings className="h-4 w-4" />
@@ -579,7 +594,7 @@ export function LiteTaskDetailPage() {
)}
{/* Conventions from Session Metadata */}
{session.metadata?.conventions && (
{!!session.metadata?.conventions && (
<div>
<h5 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
<BookOpen className="h-4 w-4" />
@@ -604,7 +619,7 @@ export function LiteTaskDetailPage() {
</div>
{/* Session-Level Explorations (if available) */}
{session.metadata?.explorations && (
{!!session.metadata?.explorations && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">

View File

@@ -183,7 +183,7 @@ function ExpandedSessionPanel({
{depsCount > 0 && (
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground"></span>
{task.context.depends_on.map((depId, idx) => (
{task.context?.depends_on?.map((depId, idx) => (
<Badge key={idx} variant="outline" className="text-[10px] px-1.5 py-0 font-mono border-primary/30 text-primary whitespace-nowrap">
{depId}
</Badge>
@@ -514,7 +514,7 @@ function ExpandedMultiCliPanel({
{depsCount > 0 && (
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground"></span>
{task.context.depends_on.map((depId, idx) => (
{task.context?.depends_on?.map((depId, idx) => (
<Badge key={idx} variant="outline" className="text-[10px] px-1.5 py-0 font-mono border-primary/30 text-primary whitespace-nowrap">
{depId}
</Badge>

View File

@@ -23,6 +23,7 @@ import {
Star,
Archive,
ArchiveRestore,
AlertCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -394,6 +395,7 @@ export function MemoryPage() {
allTags,
isLoading,
isFetching,
error,
refetch,
} = useMemory({
filter: {
@@ -551,6 +553,20 @@ export function MemoryPage() {
]}
/>
{/* Error alert */}
{error && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'home.errors.retry' })}
</Button>
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4">

View File

@@ -170,15 +170,15 @@ export function PromptHistoryPage() {
setSelectedInsight(null);
// Show success toast
const successMessage = locale === 'zh' ? '洞察已删除' : 'Insight deleted';
if (window.showToast) {
window.showToast(successMessage, 'success');
if ((window as any).showToast) {
(window as any).showToast(successMessage, 'success');
}
} catch (err) {
console.error('Failed to delete insight:', err);
// Show error toast
const errorMessage = locale === 'zh' ? '删除洞察失败' : 'Failed to delete insight';
if (window.showToast) {
window.showToast(errorMessage, 'error');
if ((window as any).showToast) {
(window as any).showToast(errorMessage, 'error');
}
}
};

View File

@@ -10,13 +10,13 @@ import { useWorkflowStore } from '@/stores/workflowStore';
import type { IssueQueue } from '@/lib/api';
// Mock queue data
const mockQueueData: IssueQueue = {
const mockQueueData = {
tasks: ['task1', 'task2'],
solutions: ['solution1'],
conflicts: [],
execution_groups: { 'group-1': ['task1', 'task2'] },
grouped_items: { 'parallel-group': ['task1', 'task2'] },
};
execution_groups: ['group-1'],
grouped_items: { 'parallel-group': [] as any[] },
} satisfies IssueQueue;
// Mock hooks at top level
vi.mock('@/hooks', () => ({

View File

@@ -221,7 +221,7 @@ export function SessionDetailPage() {
{activeTab === 'impl-plan' && (
<div className="mt-4">
<ImplPlanTab implPlan={implPlan} />
<ImplPlanTab implPlan={implPlan as string | undefined} />
</div>
)}

View File

@@ -20,6 +20,7 @@ import {
Grid3x3,
Folder,
User,
AlertCircle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -129,6 +130,7 @@ export function SkillsManagerPage() {
userSkills,
isLoading,
isFetching,
error,
refetch,
} = useSkills({
filter: {
@@ -248,6 +250,20 @@ export function SkillsManagerPage() {
</div>
</div>
{/* Error alert */}
{error && (
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
<p className="text-xs mt-0.5">{error.message}</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{formatMessage({ id: 'home.errors.retry' })}
</Button>
</div>
)}
{/* Location Tabs - styled like LiteTasksPage */}
<TabsNavigation
value={locationFilter}

View File

@@ -1,34 +1,30 @@
// ========================================
// Execution Monitor
// ========================================
// Right-side slide-out panel for real-time execution monitoring
// Right-side slide-out panel for real-time execution monitoring with multi-panel layout
import { useEffect, useRef, useCallback, useState } from 'react';
import { useEffect, useCallback, useState, useRef } from 'react';
import { useIntl } from 'react-intl';
import {
Play,
Pause,
Square,
Clock,
AlertCircle,
CheckCircle2,
Loader2,
Terminal,
ArrowDownToLine,
X,
FileText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { useExecutionStore } from '@/stores/executionStore';
import {
useExecuteFlow,
usePauseExecution,
useResumeExecution,
useStopExecution,
} from '@/hooks/useFlows';
import { useFlowStore } from '@/stores';
import type { ExecutionStatus, LogLevel } from '@/types/execution';
import { useExecuteFlow, usePauseExecution, useResumeExecution, useStopExecution } from '@/hooks/useFlows';
import { ExecutionHeader } from '@/components/orchestrator/ExecutionHeader';
import { NodeExecutionChain } from '@/components/orchestrator/NodeExecutionChain';
import { NodeDetailPanel } from '@/components/orchestrator/NodeDetailPanel';
import type { LogLevel } from '@/types/execution';
// ========== Helper Functions ==========
@@ -43,36 +39,6 @@ function formatElapsedTime(ms: number): string {
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
}
function getStatusBadgeVariant(status: ExecutionStatus): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' {
switch (status) {
case 'running':
return 'default';
case 'paused':
return 'warning';
case 'completed':
return 'success';
case 'failed':
return 'destructive';
default:
return 'secondary';
}
}
function getStatusIcon(status: ExecutionStatus) {
switch (status) {
case 'running':
return <Loader2 className="h-3 w-3 animate-spin" />;
case 'paused':
return <Pause className="h-3 w-3" />;
case 'completed':
return <CheckCircle2 className="h-3 w-3" />;
case 'failed':
return <AlertCircle className="h-3 w-3" />;
default:
return <Clock className="h-3 w-3" />;
}
}
function getLogLevelColor(level: LogLevel): string {
switch (level) {
case 'error':
@@ -95,23 +61,21 @@ interface ExecutionMonitorProps {
}
export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
const logsEndRef = useRef<HTMLDivElement>(null);
const logsContainerRef = useRef<HTMLDivElement>(null);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const { formatMessage } = useIntl();
// Execution store state
const currentExecution = useExecutionStore((state) => state.currentExecution);
const logs = useExecutionStore((state) => state.logs);
const nodeStates = useExecutionStore((state) => state.nodeStates);
const selectedNodeId = useExecutionStore((state) => state.selectedNodeId);
const nodeOutputs = useExecutionStore((state) => state.nodeOutputs);
const nodeToolCalls = useExecutionStore((state) => state.nodeToolCalls);
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
const autoScrollLogs = useExecutionStore((state) => state.autoScrollLogs);
const setMonitorPanelOpen = useExecutionStore((state) => state.setMonitorPanelOpen);
const selectNode = useExecutionStore((state) => state.selectNode);
const toggleToolCallExpanded = useExecutionStore((state) => state.toggleToolCallExpanded);
const startExecution = useExecutionStore((state) => state.startExecution);
// Local state for elapsed time
const [elapsedMs, setElapsedMs] = useState(0);
// Flow store state
const currentFlow = useFlowStore((state) => state.currentFlow);
const nodes = useFlowStore((state) => state.nodes);
@@ -122,6 +86,12 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
const resumeExecution = useResumeExecution();
const stopExecution = useStopExecution();
// Local state
const [elapsedMs, setElapsedMs] = useState(0);
const [isUserScrollingLogs, setIsUserScrollingLogs] = useState(false);
const logsContainerRef = useRef<HTMLDivElement>(null);
const logsEndRef = useRef<HTMLDivElement>(null);
// Update elapsed time every second while running
useEffect(() => {
if (currentExecution?.status === 'running' && currentExecution.startedAt) {
@@ -139,25 +109,32 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
}
}, [currentExecution?.status, currentExecution?.startedAt, currentExecution?.completedAt, currentExecution?.elapsedMs]);
// Auto-scroll logs
// Auto-scroll global logs
useEffect(() => {
if (autoScrollLogs && !isUserScrolling && logsEndRef.current) {
if (!isUserScrollingLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, autoScrollLogs, isUserScrolling]);
}, [logs, isUserScrollingLogs]);
// Auto-select current executing node
useEffect(() => {
if (currentExecution?.currentNodeId && currentExecution.status === 'running') {
selectNode(currentExecution.currentNodeId);
}
}, [currentExecution?.currentNodeId, currentExecution?.status, selectNode]);
// Handle scroll to detect user scrolling
const handleScroll = useCallback(() => {
if (!logsContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setIsUserScrolling(!isAtBottom);
setIsUserScrollingLogs(!isAtBottom);
}, []);
// Scroll to bottom handler
const scrollToBottom = useCallback(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
setIsUserScrolling(false);
setIsUserScrollingLogs(false);
}, []);
// Handle execute
@@ -201,12 +178,30 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
}
}, [currentExecution, stopExecution]);
// Calculate node progress
const completedNodes = Object.values(nodeStates).filter(
(state) => state.status === 'completed'
).length;
const totalNodes = nodes.length;
const progressPercent = totalNodes > 0 ? (completedNodes / totalNodes) * 100 : 0;
// Handle node select
const handleNodeSelect = useCallback(
(nodeId: string) => {
selectNode(nodeId);
},
[selectNode]
);
// Handle toggle tool call expand
const handleToggleToolCallExpand = useCallback(
(callId: string) => {
if (selectedNodeId) {
toggleToolCallExpanded(selectedNodeId, callId);
}
},
[selectedNodeId, toggleToolCallExpanded]
);
// Get selected node data
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
const selectedNodeOutput = selectedNodeId ? nodeOutputs[selectedNodeId] : undefined;
const selectedNodeState = selectedNodeId ? nodeStates[selectedNodeId] : undefined;
const selectedNodeToolCalls = selectedNodeId ? (nodeToolCalls[selectedNodeId] ?? []) : [];
const isNodeExecuting = selectedNodeId ? nodeStates[selectedNodeId]?.status === 'running' : false;
const isExecuting = currentExecution?.status === 'running';
const isPaused = currentExecution?.status === 'paused';
@@ -228,11 +223,21 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
<Terminal className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{formatMessage({ id: 'orchestrator.monitor.title' })}</span>
{currentExecution && (
<Badge variant={getStatusBadgeVariant(currentExecution.status)} className="shrink-0">
<span className="flex items-center gap-1">
{getStatusIcon(currentExecution.status)}
{formatMessage({ id: `orchestrator.status.${currentExecution.status}` })}
</span>
<Badge
variant={
currentExecution.status === 'running'
? 'default'
: currentExecution.status === 'completed'
? 'success'
: currentExecution.status === 'failed'
? 'destructive'
: currentExecution.status === 'paused'
? 'warning'
: 'secondary'
}
className="shrink-0"
>
{formatMessage({ id: `orchestrator.status.${currentExecution.status}` })}
</Badge>
)}
</div>
@@ -313,98 +318,85 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
)}
</div>
{/* Progress bar */}
{currentExecution && (
<div className="h-1 bg-muted shrink-0">
{/* Multi-Panel Layout */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* 1. Execution Overview */}
<ExecutionHeader execution={currentExecution} nodeStates={nodeStates} />
{/* 2. Node Execution Chain */}
<NodeExecutionChain
nodes={nodes}
nodeStates={nodeStates}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
/>
{/* 3. Node Detail Panel */}
<NodeDetailPanel
node={selectedNode}
nodeOutput={selectedNodeOutput}
nodeState={selectedNodeState}
toolCalls={selectedNodeToolCalls}
isExecuting={isNodeExecuting}
onToggleToolCallExpand={handleToggleToolCallExpand}
/>
{/* 4. Global Logs */}
<div className="flex-1 flex flex-col min-h-0 border-t border-border relative">
<div className="px-3 py-1.5 border-b border-border bg-muted/30 shrink-0 flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">
Global Logs ({logs.length})
</span>
</div>
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
{/* Node status */}
{currentExecution && Object.keys(nodeStates).length > 0 && (
<div className="px-3 py-2 border-b border-border shrink-0">
<div className="text-xs font-medium text-muted-foreground mb-1.5">
{formatMessage({ id: 'orchestrator.node.statusCount' }, { completed: completedNodes, total: totalNodes })}
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{Object.entries(nodeStates).map(([nodeId, state]) => (
<div
key={nodeId}
className="flex items-center gap-2 text-xs p-1 rounded hover:bg-muted"
>
{state.status === 'running' && (
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
)}
{state.status === 'completed' && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
{state.status === 'failed' && (
<AlertCircle className="h-3 w-3 text-red-500 shrink-0" />
)}
{state.status === 'pending' && (
<Clock className="h-3 w-3 text-gray-400 shrink-0" />
)}
<span className="truncate" title={nodeId}>
{nodeId.slice(0, 24)}
</span>
ref={logsContainerRef}
className="flex-1 overflow-y-auto p-3 font-mono text-xs"
onScroll={handleScroll}
>
{logs.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-center">
{currentExecution
? formatMessage({ id: 'orchestrator.monitor.waitingForLogs' })
: formatMessage({ id: 'orchestrator.monitor.clickExecuteToStart' })}
</div>
))}
) : (
<div className="space-y-1">
{logs.map((log, index) => (
<div key={index} className="flex gap-1.5">
<span className="text-muted-foreground shrink-0 text-[10px]">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span
className={cn(
'uppercase w-10 shrink-0 text-[10px]',
getLogLevelColor(log.level)
)}
>
[{log.level}]
</span>
<span className="text-foreground break-all text-[11px]">
{log.message}
</span>
</div>
))}
<div ref={logsEndRef} />
</div>
)}
</div>
</div>
)}
{/* Logs */}
<div className="flex-1 flex flex-col min-h-0 relative">
<div
ref={logsContainerRef}
className="flex-1 overflow-y-auto p-3 font-mono text-xs"
onScroll={handleScroll}
>
{logs.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-center">
{currentExecution
? formatMessage({ id: 'orchestrator.monitor.waitingForLogs' })
: formatMessage({ id: 'orchestrator.monitor.clickExecuteToStart' })}
</div>
) : (
<div className="space-y-1">
{logs.map((log, index) => (
<div key={index} className="flex gap-1.5">
<span className="text-muted-foreground shrink-0 text-[10px]">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span
className={cn(
'uppercase w-10 shrink-0 text-[10px]',
getLogLevelColor(log.level)
)}
>
[{log.level}]
</span>
<span className="text-foreground break-all text-[11px]">
{log.message}
</span>
</div>
))}
<div ref={logsEndRef} />
</div>
{/* Scroll to bottom button */}
{isUserScrollingLogs && logs.length > 0 && (
<Button
size="sm"
variant="secondary"
className="absolute bottom-3 right-3"
onClick={scrollToBottom}
>
<ArrowDownToLine className="h-3 w-3" />
</Button>
)}
</div>
{/* Scroll to bottom button */}
{isUserScrolling && logs.length > 0 && (
<Button
size="sm"
variant="secondary"
className="absolute bottom-3 right-3"
onClick={scrollToBottom}
>
<ArrowDownToLine className="h-3 w-3" />
</Button>
)}
</div>
</div>
);

View File

@@ -3,7 +3,7 @@
// ========================================
// React Flow canvas with minimap, controls, and background
import { useCallback, useRef, DragEvent } from 'react';
import { useCallback, useRef, useState, useEffect, DragEvent } from 'react';
import { useIntl } from 'react-intl';
import {
ReactFlow,
@@ -20,6 +20,7 @@ import {
Edge,
ReactFlowProvider,
useReactFlow,
Panel,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
@@ -29,6 +30,7 @@ import type { FlowNode, FlowEdge } from '@/types/flow';
// Custom node types (enhanced with execution status in IMPL-A8)
import { nodeTypes } from './nodes';
import { InteractionModeToggle } from './InteractionModeToggle';
interface FlowCanvasProps {
className?: string;
@@ -53,6 +55,42 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
const setSelectedEdgeId = useFlowStore((state) => state.setSelectedEdgeId);
const markModified = useFlowStore((state) => state.markModified);
// Interaction mode from store
const interactionMode = useFlowStore((state) => state.interactionMode);
// Ctrl key state for temporary mode reversal
const [isCtrlPressed, setIsCtrlPressed] = useState(false);
// Listen for Ctrl/Meta key press for temporary mode reversal
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(false);
}
};
// Reset on blur (user switches window)
const handleBlur = () => setIsCtrlPressed(false);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
}, []);
// Calculate effective mode (Ctrl reverses the current mode)
const effectiveMode = isCtrlPressed
? (interactionMode === 'pan' ? 'selection' : 'pan')
: interactionMode;
// Handle node changes (position, selection, etc.)
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
@@ -163,6 +201,8 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
onDragOver={onDragOver}
onDrop={onDrop}
nodeTypes={nodeTypes}
panOnDrag={effectiveMode === 'pan'}
selectionOnDrag={effectiveMode === 'selection'}
nodesDraggable={!isExecuting}
nodesConnectable={!isExecuting}
elementsSelectable={!isExecuting}
@@ -172,6 +212,9 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
snapGrid={[15, 15]}
className="bg-background"
>
<Panel position="top-left" className="m-2">
<InteractionModeToggle disabled={isExecuting} />
</Panel>
<Controls
className="bg-card border border-border rounded-md shadow-sm"
showZoom={true}

View File

@@ -0,0 +1,51 @@
// ========================================
// Interaction Mode Toggle Component
// ========================================
// Pan/Selection mode toggle for the orchestrator canvas
import { useIntl } from 'react-intl';
import { Hand, MousePointerClick } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useFlowStore } from '@/stores';
interface InteractionModeToggleProps {
disabled?: boolean;
}
export function InteractionModeToggle({ disabled = false }: InteractionModeToggleProps) {
const { formatMessage } = useIntl();
const interactionMode = useFlowStore((state) => state.interactionMode);
const toggleInteractionMode = useFlowStore((state) => state.toggleInteractionMode);
return (
<div className={cn(
'flex items-center gap-1 bg-card/90 backdrop-blur-sm border border-border rounded-lg p-1 shadow-sm',
disabled && 'opacity-50 pointer-events-none'
)}>
<button
onClick={() => { if (interactionMode !== 'pan') toggleInteractionMode(); }}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-colors',
interactionMode === 'pan'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title={formatMessage({ id: 'orchestrator.canvas.panMode', defaultMessage: 'Pan mode (drag to move canvas)' })}
>
<Hand className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { if (interactionMode !== 'selection') toggleInteractionMode(); }}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-colors',
interactionMode === 'selection'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title={formatMessage({ id: 'orchestrator.canvas.selectionMode', defaultMessage: 'Selection mode (drag to select nodes)' })}
>
<MousePointerClick className="w-3.5 h-3.5" />
</button>
</div>
);
}

View File

@@ -4,12 +4,14 @@
// Container with tab switching between NodeLibrary and InlineTemplatePanel
import { useIntl } from 'react-intl';
import { ChevronRight, ChevronDown } from 'lucide-react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { useFlowStore } from '@/stores';
import { NodeLibrary } from './NodeLibrary';
import { InlineTemplatePanel } from './InlineTemplatePanel';
import { useResizablePanel } from './useResizablePanel';
import { ResizeHandle } from './ResizeHandle';
// ========== Tab Configuration ==========
@@ -30,30 +32,27 @@ interface LeftSidebarProps {
*/
export function LeftSidebar({ className }: LeftSidebarProps) {
const { formatMessage } = useIntl();
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
const leftPanelTab = useFlowStore((state) => state.leftPanelTab);
const setLeftPanelTab = useFlowStore((state) => state.setLeftPanelTab);
// Collapsed state
if (!isPaletteOpen) {
return (
<div className={cn('w-10 bg-card border-r border-border flex flex-col items-center py-4', className)}>
<Button
variant="ghost"
size="icon"
onClick={() => setIsPaletteOpen(true)}
title={formatMessage({ id: 'orchestrator.leftSidebar.expand' })}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
);
}
const { width, isResizing, handleMouseDown } = useResizablePanel({
minWidth: 200,
maxWidth: 400,
defaultWidth: 288, // w-72 = 18rem = 288px
storageKey: 'ccw-orchestrator.leftSidebar.width',
direction: 'right',
});
// Expanded state
return (
<div className={cn('w-72 bg-card border-r border-border flex flex-col', className)}>
<div
className={cn(
'bg-card border-r border-border flex flex-col relative',
isResizing && 'select-none',
className
)}
style={{ width }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="font-semibold text-foreground">{formatMessage({ id: 'orchestrator.leftSidebar.workbench' })}</h3>
@@ -100,6 +99,9 @@ export function LeftSidebar({ className }: LeftSidebarProps) {
<span className="font-medium">{formatMessage({ id: 'orchestrator.leftSidebar.tipLabel' })}</span> {formatMessage({ id: 'orchestrator.leftSidebar.dragOrDoubleClick' })}
</div>
</div>
{/* Resize handle on right edge */}
<ResizeHandle onMouseDown={handleMouseDown} position="right" />
</div>
);
}

View File

@@ -117,8 +117,7 @@ function QuickTemplateCard({
};
const onDoubleClick = () => {
const position = { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 };
useFlowStore.getState().addNodeFromTemplate(template.id, position);
useFlowStore.getState().addNodeFromTemplate(template.id, { x: 250, y: 200 });
};
return (
@@ -166,8 +165,7 @@ function BasicTemplateCard() {
};
const onDoubleClick = () => {
const position = { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 };
useFlowStore.getState().addNode(position);
useFlowStore.getState().addNode({ x: 250, y: 200 });
};
return (

View File

@@ -4,8 +4,11 @@
// Visual workflow editor with React Flow, drag-drop node palette, and property panel
import { useEffect, useState, useCallback } from 'react';
import * as Collapsible from '@radix-ui/react-collapsible';
import { ChevronRight, Settings } from 'lucide-react';
import { useFlowStore } from '@/stores';
import { useExecutionStore } from '@/stores/executionStore';
import { Button } from '@/components/ui/Button';
import { FlowCanvas } from './FlowCanvas';
import { LeftSidebar } from './LeftSidebar';
import { PropertyPanel } from './PropertyPanel';
@@ -15,6 +18,10 @@ import { ExecutionMonitor } from './ExecutionMonitor';
export function OrchestratorPage() {
const fetchFlows = useFlowStore((state) => state.fetchFlows);
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
const isPropertyPanelOpen = useFlowStore((state) => state.isPropertyPanelOpen);
const setIsPropertyPanelOpen = useFlowStore((state) => state.setIsPropertyPanelOpen);
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
@@ -35,16 +42,42 @@ export function OrchestratorPage() {
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Left Sidebar (Templates + Nodes) */}
<LeftSidebar />
{/* Left Sidebar with collapse toggle */}
{!isPaletteOpen && (
<div className="w-10 bg-card border-r border-border flex flex-col items-center py-4">
<Button variant="ghost" size="icon" onClick={() => setIsPaletteOpen(true)} title="Expand">
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
<Collapsible.Root open={isPaletteOpen} onOpenChange={setIsPaletteOpen}>
<Collapsible.Content className="overflow-hidden data-[state=open]:animate-collapsible-slide-down data-[state=closed]:animate-collapsible-slide-up">
<LeftSidebar />
</Collapsible.Content>
</Collapsible.Root>
{/* Flow Canvas (Center) */}
{/* Flow Canvas (Center) + PropertyPanel Overlay */}
<div className="flex-1 relative">
<FlowCanvas className="absolute inset-0" />
</div>
{/* Property Panel (Right) - hidden when monitor is open */}
{!isMonitorPanelOpen && <PropertyPanel />}
{/* Property Panel as overlay - hidden when monitor is open */}
{!isMonitorPanelOpen && (
<div className="absolute top-2 right-2 bottom-2 z-10">
{!isPropertyPanelOpen && (
<div className="w-10 h-full bg-card/90 backdrop-blur-sm border border-border rounded-lg flex flex-col items-center py-4 shadow-lg">
<Button variant="ghost" size="icon" onClick={() => setIsPropertyPanelOpen(true)} title="Open">
<Settings className="w-4 h-4" />
</Button>
</div>
)}
<Collapsible.Root open={isPropertyPanelOpen} onOpenChange={setIsPropertyPanelOpen}>
<Collapsible.Content className="overflow-hidden h-full data-[state=open]:animate-collapsible-slide-down data-[state=closed]:animate-collapsible-slide-up">
<PropertyPanel className="h-full" />
</Collapsible.Content>
</Collapsible.Root>
</div>
)}
</div>
{/* Execution Monitor Panel (Right) */}
<ExecutionMonitor />

View File

@@ -1238,7 +1238,6 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
const nodes = useFlowStore((state) => state.nodes);
const updateNode = useFlowStore((state) => state.updateNode);
const removeNode = useFlowStore((state) => state.removeNode);
const isPropertyPanelOpen = useFlowStore((state) => state.isPropertyPanelOpen);
const setIsPropertyPanelOpen = useFlowStore((state) => state.setIsPropertyPanelOpen);
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
@@ -1258,26 +1257,10 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
}
}, [selectedNodeId, removeNode]);
// Collapsed state
if (!isPropertyPanelOpen) {
return (
<div className={cn('w-10 bg-card border-l border-border flex flex-col items-center py-4', className)}>
<Button
variant="ghost"
size="icon"
onClick={() => setIsPropertyPanelOpen(true)}
title={formatMessage({ id: 'orchestrator.propertyPanel.open' })}
>
<Settings className="w-4 h-4" />
</Button>
</div>
);
}
// No node selected
if (!selectedNode) {
return (
<div className={cn('w-72 bg-card border-l border-border flex flex-col', className)}>
<div className={cn('w-72 bg-card/95 backdrop-blur-sm border border-border rounded-lg shadow-xl flex flex-col', className)}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="font-semibold text-foreground">{formatMessage({ id: 'orchestrator.propertyPanel.title' })}</h3>
<Button
@@ -1301,7 +1284,7 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
}
return (
<div className={cn('w-72 bg-card border-l border-border flex flex-col', className)}>
<div className={cn('w-72 bg-card/95 backdrop-blur-sm border border-border rounded-lg shadow-xl flex flex-col', className)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">

View File

@@ -0,0 +1,39 @@
// ========================================
// ResizeHandle Component
// ========================================
// Draggable vertical bar for resizing sidebar panels.
// Uses Tailwind CSS for styling.
import type React from 'react';
import { cn } from '@/lib/utils';
interface ResizeHandleProps {
onMouseDown: (e: React.MouseEvent) => void;
className?: string;
/** Position of the handle relative to the panel. Default: 'right' */
position?: 'left' | 'right';
}
/**
* ResizeHandle Component
*
* A 4px-wide transparent drag bar that highlights on hover.
* Placed on the edge of a sidebar panel for drag-to-resize.
*/
export function ResizeHandle({ onMouseDown, className, position = 'right' }: ResizeHandleProps) {
return (
<div
onMouseDown={onMouseDown}
className={cn(
'absolute top-0 bottom-0 w-1 cursor-ew-resize z-10',
'bg-transparent hover:bg-primary transition-colors duration-200',
position === 'right' ? 'right-0' : 'left-0',
className,
)}
role="separator"
aria-orientation="vertical"
aria-label="Resize panel"
tabIndex={0}
/>
);
}

View File

@@ -0,0 +1,136 @@
// ========================================
// useResizablePanel Hook
// ========================================
// Provides drag-to-resize functionality for sidebar panels.
// Adapted from cc-wf-studio with Tailwind-friendly approach.
import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_MIN_WIDTH = 200;
const DEFAULT_MAX_WIDTH = 600;
const DEFAULT_WIDTH = 300;
const DEFAULT_STORAGE_KEY = 'ccw-orchestrator.panelWidth';
interface UseResizablePanelOptions {
minWidth?: number;
maxWidth?: number;
defaultWidth?: number;
storageKey?: string;
/** Direction of drag relative to panel growth. 'left' means dragging left grows the panel (right-side panel). */
direction?: 'left' | 'right';
}
interface UseResizablePanelReturn {
width: number;
isResizing: boolean;
handleMouseDown: (e: React.MouseEvent) => void;
}
/**
* Custom hook for resizable panel functionality.
*
* Features:
* - Drag-to-resize with mouse events
* - Configurable min/max width constraints
* - localStorage persistence
* - Prevents text selection during drag
*/
export function useResizablePanel(options?: UseResizablePanelOptions): UseResizablePanelReturn {
const minWidth = options?.minWidth ?? DEFAULT_MIN_WIDTH;
const maxWidth = options?.maxWidth ?? DEFAULT_MAX_WIDTH;
const defaultWidth = options?.defaultWidth ?? DEFAULT_WIDTH;
const storageKey = options?.storageKey ?? DEFAULT_STORAGE_KEY;
const direction = options?.direction ?? 'right';
// Initialize width from localStorage or use default
const [width, setWidth] = useState<number>(() => {
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = Number.parseInt(saved, 10);
if (!Number.isNaN(parsed) && parsed >= minWidth && parsed <= maxWidth) {
return parsed;
}
}
} catch {
// localStorage unavailable
}
return defaultWidth;
});
const [isResizing, setIsResizing] = useState(false);
const startXRef = useRef<number>(0);
const startWidthRef = useRef<number>(0);
// Handle mouse move during resize
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const deltaX = e.clientX - startXRef.current;
// For 'right' direction (left panel), dragging right grows the panel
// For 'left' direction (right panel), dragging left grows the panel
const newWidth = direction === 'right'
? startWidthRef.current + deltaX
: startWidthRef.current - deltaX;
const constrainedWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
setWidth(constrainedWidth);
},
[minWidth, maxWidth, direction]
);
// Handle mouse up to end resize
const handleMouseUp = useCallback(() => {
setIsResizing(false);
}, []);
// Handle mouse down to start resize
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
startXRef.current = e.clientX;
startWidthRef.current = width;
},
[width]
);
// Set up global mouse event listeners
useEffect(() => {
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Prevent text selection during drag
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ew-resize';
} else {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
}, [isResizing, handleMouseMove, handleMouseUp]);
// Persist width to localStorage whenever it changes
useEffect(() => {
try {
localStorage.setItem(storageKey, width.toString());
} catch {
// localStorage unavailable
}
}, [width, storageKey]);
return {
width,
isResizing,
handleMouseDown,
};
}