mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -221,7 +221,7 @@ export function SessionDetailPage() {
|
||||
|
||||
{activeTab === 'impl-plan' && (
|
||||
<div className="mt-4">
|
||||
<ImplPlanTab implPlan={implPlan} />
|
||||
<ImplPlanTab implPlan={implPlan as string | undefined} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
39
ccw/frontend/src/pages/orchestrator/ResizeHandle.tsx
Normal file
39
ccw/frontend/src/pages/orchestrator/ResizeHandle.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
136
ccw/frontend/src/pages/orchestrator/useResizablePanel.ts
Normal file
136
ccw/frontend/src/pages/orchestrator/useResizablePanel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user