mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
Add orchestrator types and error handling configurations
- Introduced new TypeScript types for orchestrator functionality, including `SessionStrategy`, `ErrorHandlingStrategy`, and `OrchestrationStep`. - Defined interfaces for `OrchestrationPlan` and `ManualOrchestrationParams` to facilitate orchestration management. - Added a new PNG image file for visual representation. - Created a placeholder file named 'nul' for future use.
This commit is contained in:
@@ -18,7 +18,6 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
useQueueExecutionStore,
|
||||
selectExecutionStats,
|
||||
useTerminalPanelStore,
|
||||
} from '@/stores';
|
||||
import type { QueueExecution } from '@/stores/queueExecutionStore';
|
||||
@@ -47,7 +46,16 @@ function ExecutionEmptyState() {
|
||||
|
||||
function ExecutionStatsCards() {
|
||||
const { formatMessage } = useIntl();
|
||||
const stats = useQueueExecutionStore(selectExecutionStats);
|
||||
const executions = useQueueExecutionStore((s) => s.executions);
|
||||
const stats = useMemo(() => {
|
||||
const all = Object.values(executions);
|
||||
return {
|
||||
running: all.filter((e) => e.status === 'running').length,
|
||||
completed: all.filter((e) => e.status === 'completed').length,
|
||||
failed: all.filter((e) => e.status === 'failed').length,
|
||||
total: all.length,
|
||||
};
|
||||
}, [executions]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
// ========================================
|
||||
// Right-side issue detail drawer with Overview/Solutions/History tabs
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash, Terminal } from 'lucide-react';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, Tag, History, Hash, Terminal, Play } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { createCliSession } from '@/lib/api';
|
||||
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// ========== Types ==========
|
||||
export interface IssueDrawerProps {
|
||||
@@ -44,8 +46,15 @@ const priorityConfig: Record<string, { label: string; variant: 'default' | 'seco
|
||||
export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }: IssueDrawerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const openTerminal = useOpenTerminalPanel();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const [activeTab, setActiveTab] = useState<TabValue>(initialTab);
|
||||
|
||||
// Execution binding state
|
||||
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
|
||||
const [selectedTool, setSelectedTool] = useState<string>('claude');
|
||||
const [selectedMode, setSelectedMode] = useState<'analysis' | 'write'>('analysis');
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
|
||||
// Reset to initial tab when opening/switching issues
|
||||
useEffect(() => {
|
||||
if (!isOpen || !issue) return;
|
||||
@@ -62,6 +71,23 @@ export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }:
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleLaunchSession = useCallback(async () => {
|
||||
if (!projectPath || !issue || isLaunching) return;
|
||||
setIsLaunching(true);
|
||||
try {
|
||||
const created = await createCliSession(
|
||||
{ workingDir: projectPath, tool: selectedTool },
|
||||
projectPath
|
||||
);
|
||||
openTerminal(created.session.sessionKey);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('[IssueDrawer] createCliSession failed:', err);
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
}
|
||||
}, [projectPath, issue, isLaunching, selectedTool, openTerminal, onClose]);
|
||||
|
||||
if (!issue || !isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -225,20 +251,70 @@ export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }:
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Terminal Tab - Link to Terminal Panel */}
|
||||
{/* Terminal Tab - Execution Binding */}
|
||||
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p className="text-sm mb-4">{formatMessage({ id: 'home.terminalPanel.openInPanel' })}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
openTerminal(issue.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'home.terminalPanel.openInPanel' })}
|
||||
</Button>
|
||||
<div className="space-y-5">
|
||||
{/* Tool Selection */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'issues.terminal.exec.tool' })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CLI_TOOLS.map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={selectedTool === t ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTool(t)}
|
||||
>
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'issues.terminal.exec.mode' })}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['analysis', 'write'] as const).map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant={selectedMode === m ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMode(m)}
|
||||
>
|
||||
{m}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Launch Action */}
|
||||
<div className="pt-2 space-y-2">
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
disabled={isLaunching || !projectPath}
|
||||
onClick={handleLaunchSession}
|
||||
>
|
||||
{isLaunching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||
{formatMessage({ id: 'issues.terminal.launch' })}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={() => {
|
||||
openTerminal(issue.id);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
{formatMessage({ id: 'home.terminalPanel.openInPanel' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ export function QueueItemExecutor({ item, className }: QueueItemExecutorProps) {
|
||||
const flowForStore: Flow = {
|
||||
...flowDto,
|
||||
version: Number.isFinite(parsedVersion) ? parsedVersion : 1,
|
||||
} as Flow;
|
||||
} as unknown as Flow;
|
||||
useFlowStore.getState().setCurrentFlow(flowForStore);
|
||||
|
||||
// Execute the flow
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Terminal,
|
||||
Bell,
|
||||
Clock,
|
||||
Monitor,
|
||||
SquareTerminal,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -25,6 +27,7 @@ import { useTheme } from '@/hooks';
|
||||
import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector';
|
||||
import { useCliStreamStore, selectActiveExecutionCount } from '@/stores/cliStreamStore';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { useTerminalPanelStore, selectTerminalCount } from '@/stores/terminalPanelStore';
|
||||
|
||||
export interface HeaderProps {
|
||||
/** Callback for refresh action */
|
||||
@@ -43,6 +46,8 @@ export function Header({
|
||||
const { formatMessage } = useIntl();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
const activeCliCount = useCliStreamStore(selectActiveExecutionCount);
|
||||
const terminalCount = useTerminalPanelStore(selectTerminalCount);
|
||||
const toggleTerminalPanel = useTerminalPanelStore((s) => s.togglePanel);
|
||||
|
||||
// Notification state for badge
|
||||
const persistentNotifications = useNotificationStore((state) => state.persistentNotifications);
|
||||
@@ -106,6 +111,35 @@ export function Header({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Terminal Panel toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleTerminalPanel}
|
||||
className="gap-2"
|
||||
>
|
||||
<SquareTerminal className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{formatMessage({ id: 'home.terminalPanel.title' })}</span>
|
||||
{terminalCount > 0 && (
|
||||
<Badge variant="default" className="h-5 px-1.5 text-xs">
|
||||
{terminalCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* CLI Viewer page link */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="gap-2"
|
||||
>
|
||||
<Link to="/cli-viewer" className="inline-flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{formatMessage({ id: 'navigation.main.cliViewer' })}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* Workspace selector */}
|
||||
<WorkspaceSelector />
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
// ========================================
|
||||
// Orchestrator Control Panel Component
|
||||
// ========================================
|
||||
// Displays orchestration plan progress with step list, status badges,
|
||||
// and conditional control buttons (pause/resume/retry/skip/stop).
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Circle,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
SkipForward,
|
||||
Pause,
|
||||
Play,
|
||||
Square,
|
||||
RotateCcw,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
useOrchestratorStore,
|
||||
selectPlan,
|
||||
type StepRunState,
|
||||
} from '@/stores/orchestratorStore';
|
||||
import type { StepStatus, OrchestrationStatus } from '@/types/orchestrator';
|
||||
|
||||
// ========== Props ==========
|
||||
|
||||
interface OrchestratorControlPanelProps {
|
||||
/** Identifies which orchestration plan to display/control */
|
||||
planId: string;
|
||||
}
|
||||
|
||||
// ========== Status Badge ==========
|
||||
|
||||
const statusBadgeConfig: Record<
|
||||
OrchestrationStatus,
|
||||
{ labelKey: string; className: string }
|
||||
> = {
|
||||
pending: {
|
||||
labelKey: 'orchestrator.status.pending',
|
||||
className: 'bg-muted text-muted-foreground border-border',
|
||||
},
|
||||
running: {
|
||||
labelKey: 'orchestrator.status.running',
|
||||
className: 'bg-primary/10 text-primary border-primary/50',
|
||||
},
|
||||
paused: {
|
||||
labelKey: 'orchestrator.status.paused',
|
||||
className: 'bg-amber-500/10 text-amber-500 border-amber-500/50',
|
||||
},
|
||||
completed: {
|
||||
labelKey: 'orchestrator.status.completed',
|
||||
className: 'bg-green-500/10 text-green-500 border-green-500/50',
|
||||
},
|
||||
failed: {
|
||||
labelKey: 'orchestrator.status.failed',
|
||||
className: 'bg-destructive/10 text-destructive border-destructive/50',
|
||||
},
|
||||
cancelled: {
|
||||
labelKey: 'orchestrator.controlPanel.cancelled',
|
||||
className: 'bg-muted text-muted-foreground border-border',
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: OrchestrationStatus }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = statusBadgeConfig[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium border',
|
||||
config.className
|
||||
)}
|
||||
>
|
||||
{formatMessage({ id: config.labelKey })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Step Status Icon ==========
|
||||
|
||||
function StepStatusIcon({ status }: { status: StepStatus }) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle className="w-4 h-4 text-muted-foreground" />;
|
||||
case 'running':
|
||||
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||
case 'skipped':
|
||||
return <SkipForward className="w-4 h-4 text-muted-foreground" />;
|
||||
case 'paused':
|
||||
return <Pause className="w-4 h-4 text-amber-500" />;
|
||||
case 'cancelled':
|
||||
return <Square className="w-4 h-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <Circle className="w-4 h-4 text-muted-foreground opacity-50" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Step List Item ==========
|
||||
|
||||
interface StepListItemProps {
|
||||
stepId: string;
|
||||
name: string;
|
||||
runState: StepRunState;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
function StepListItem({ stepId: _stepId, name, runState, isCurrent }: StepListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2 rounded-md transition-colors',
|
||||
isCurrent && runState.status === 'running' && 'bg-primary/5 border border-primary/20',
|
||||
runState.status === 'failed' && 'bg-destructive/5',
|
||||
!isCurrent && runState.status !== 'failed' && 'hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="pt-0.5 shrink-0">
|
||||
<StepStatusIcon status={runState.status} />
|
||||
</div>
|
||||
|
||||
{/* Step info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium truncate block',
|
||||
runState.status === 'completed' && 'text-muted-foreground',
|
||||
runState.status === 'skipped' && 'text-muted-foreground line-through',
|
||||
runState.status === 'running' && 'text-foreground',
|
||||
runState.status === 'failed' && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{/* Error message inline */}
|
||||
{runState.status === 'failed' && runState.error && (
|
||||
<div className="flex items-start gap-1.5 mt-1">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-destructive/80 break-words">
|
||||
{runState.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retry count indicator */}
|
||||
{runState.retryCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground mt-0.5 block">
|
||||
Retry #{runState.retryCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Control Buttons ==========
|
||||
|
||||
interface ControlBarProps {
|
||||
planId: string;
|
||||
status: OrchestrationStatus;
|
||||
failedStepId: string | null;
|
||||
}
|
||||
|
||||
function ControlBar({ planId, status, failedStepId }: ControlBarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const pauseOrchestration = useOrchestratorStore((s) => s.pauseOrchestration);
|
||||
const resumeOrchestration = useOrchestratorStore((s) => s.resumeOrchestration);
|
||||
const stopOrchestration = useOrchestratorStore((s) => s.stopOrchestration);
|
||||
const retryStep = useOrchestratorStore((s) => s.retryStep);
|
||||
const skipStep = useOrchestratorStore((s) => s.skipStep);
|
||||
|
||||
if (status === 'completed') {
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<p className="text-sm text-green-500 text-center">
|
||||
{formatMessage({ id: 'orchestrator.controlPanel.completedMessage' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'failed' || status === 'cancelled') {
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<p className="text-sm text-destructive text-center">
|
||||
{formatMessage({ id: 'orchestrator.controlPanel.failedMessage' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if paused due to error (has a failed step)
|
||||
const isPausedOnError = status === 'paused' && failedStepId !== null;
|
||||
const isPausedByUser = status === 'paused' && failedStepId === null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-border flex items-center gap-2">
|
||||
{/* Running state: Pause + Stop */}
|
||||
{status === 'running' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pauseOrchestration(planId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'orchestrator.execution.pause' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => stopOrchestration(planId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'orchestrator.execution.stop' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User-paused state: Resume + Stop */}
|
||||
{isPausedByUser && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resumeOrchestration(planId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'orchestrator.execution.resume' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => stopOrchestration(planId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'orchestrator.execution.stop' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error-paused state: Retry + Skip + Stop */}
|
||||
{isPausedOnError && failedStepId && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => retryStep(planId, failedStepId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'orchestrator.controlPanel.retry' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => skipStep(planId, failedStepId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'orchestrator.controlPanel.skip' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => stopOrchestration(planId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'orchestrator.execution.stop' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* OrchestratorControlPanel displays plan progress and provides
|
||||
* pause/resume/retry/skip/stop controls for active orchestrations.
|
||||
*
|
||||
* Layout:
|
||||
* - Header: Plan name + status badge + progress count
|
||||
* - Progress bar: Completed/total ratio
|
||||
* - Step list: Scrollable with status icons and error messages
|
||||
* - Control bar: Conditional buttons based on orchestration status
|
||||
*/
|
||||
export const OrchestratorControlPanel = memo(function OrchestratorControlPanel({
|
||||
planId,
|
||||
}: OrchestratorControlPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const runState = useOrchestratorStore(selectPlan(planId));
|
||||
|
||||
// Compute progress counts
|
||||
const { completedCount, totalCount, progress } = useMemo(() => {
|
||||
if (!runState) return { completedCount: 0, totalCount: 0, progress: 0 };
|
||||
|
||||
const statuses = Object.values(runState.stepStatuses);
|
||||
const total = statuses.length;
|
||||
const completed = statuses.filter(
|
||||
(s) => s.status === 'completed' || s.status === 'skipped'
|
||||
).length;
|
||||
|
||||
return {
|
||||
completedCount: completed,
|
||||
totalCount: total,
|
||||
progress: total > 0 ? (completed / total) * 100 : 0,
|
||||
};
|
||||
}, [runState]);
|
||||
|
||||
// Find the first failed step ID (for error-paused controls)
|
||||
const failedStepId = useMemo(() => {
|
||||
if (!runState) return null;
|
||||
for (const [stepId, stepState] of Object.entries(runState.stepStatuses)) {
|
||||
if (stepState.status === 'failed') return stepId;
|
||||
}
|
||||
return null;
|
||||
}, [runState]);
|
||||
|
||||
// No plan found
|
||||
if (!runState) {
|
||||
return (
|
||||
<div className="p-4 border rounded-lg border-border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{formatMessage({ id: 'orchestrator.controlPanel.noPlan' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { plan, status, currentStepIndex } = runState;
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg border-border bg-card flex flex-col">
|
||||
{/* Header: Plan name + Status badge + Progress count */}
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate flex-1">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<StatusBadge status={status} />
|
||||
<span className="text-sm text-muted-foreground tabular-nums shrink-0">
|
||||
{formatMessage(
|
||||
{ id: 'orchestrator.controlPanel.progress' },
|
||||
{ completed: completedCount, total: totalCount }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-300 ease-out',
|
||||
status === 'failed' && 'bg-destructive',
|
||||
status === 'completed' && 'bg-green-500',
|
||||
status === 'cancelled' && 'bg-muted-foreground',
|
||||
(status === 'running' || status === 'pending') && 'bg-primary',
|
||||
status === 'paused' && 'bg-amber-500'
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step list */}
|
||||
<div className="flex-1 overflow-y-auto max-h-80 p-2 space-y-1">
|
||||
{plan.steps.map((step, index) => {
|
||||
const stepState = runState.stepStatuses[step.id];
|
||||
if (!stepState) return null;
|
||||
|
||||
return (
|
||||
<StepListItem
|
||||
key={step.id}
|
||||
stepId={step.id}
|
||||
name={step.name}
|
||||
runState={stepState}
|
||||
isCurrent={index === currentStepIndex && status === 'running'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Control bar */}
|
||||
<ControlBar
|
||||
planId={planId}
|
||||
status={status}
|
||||
failedStepId={failedStepId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
OrchestratorControlPanel.displayName = 'OrchestratorControlPanel';
|
||||
@@ -4,5 +4,6 @@
|
||||
|
||||
export { ExecutionHeader } from './ExecutionHeader';
|
||||
export { NodeExecutionChain } from './NodeExecutionChain';
|
||||
export { OrchestratorControlPanel } from './OrchestratorControlPanel';
|
||||
export { ToolCallCard, type ToolCallCardProps } from './ToolCallCard';
|
||||
export { ToolCallsTimeline, type ToolCallsTimelineProps } from './ToolCallsTimeline';
|
||||
|
||||
@@ -10,14 +10,14 @@ import { Flowchart } from './Flowchart';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/Tabs';
|
||||
import type { NormalizedTask } from '@/lib/api';
|
||||
import type { NormalizedTask, LiteTask } from '@/lib/api';
|
||||
import { buildFlowControl } from '@/lib/api';
|
||||
import type { TaskData } from '@/types/store';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface TaskDrawerProps {
|
||||
task: NormalizedTask | TaskData | null;
|
||||
task: NormalizedTask | TaskData | LiteTask | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
285
ccw/frontend/src/components/team/TeamArtifacts.tsx
Normal file
285
ccw/frontend/src/components/team/TeamArtifacts.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
// ========================================
|
||||
// TeamArtifacts Component
|
||||
// ========================================
|
||||
// Displays team artifacts grouped by pipeline phase (plan/impl/test/review)
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
FileText,
|
||||
ClipboardList,
|
||||
Code2,
|
||||
TestTube2,
|
||||
SearchCheck,
|
||||
Database,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import MarkdownModal from '@/components/shared/MarkdownModal';
|
||||
import { fetchFileContent } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMessage } from '@/types/team';
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
type ArtifactPhase = 'plan' | 'impl' | 'test' | 'review';
|
||||
|
||||
interface Artifact {
|
||||
id: string;
|
||||
message: TeamMessage;
|
||||
phase: ArtifactPhase;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
interface TeamArtifactsProps {
|
||||
messages: TeamMessage[];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Constants
|
||||
// ========================================
|
||||
|
||||
const PHASE_MESSAGE_MAP: Record<string, ArtifactPhase> = {
|
||||
plan_ready: 'plan',
|
||||
plan_approved: 'plan',
|
||||
plan_revision: 'plan',
|
||||
impl_complete: 'impl',
|
||||
impl_progress: 'impl',
|
||||
test_result: 'test',
|
||||
review_result: 'review',
|
||||
};
|
||||
|
||||
const PHASE_CONFIG: Record<ArtifactPhase, { icon: typeof FileText; color: string }> = {
|
||||
plan: { icon: ClipboardList, color: 'text-blue-500' },
|
||||
impl: { icon: Code2, color: 'text-green-500' },
|
||||
test: { icon: TestTube2, color: 'text-amber-500' },
|
||||
review: { icon: SearchCheck, color: 'text-purple-500' },
|
||||
};
|
||||
|
||||
const PHASE_ORDER: ArtifactPhase[] = ['plan', 'impl', 'test', 'review'];
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
function extractArtifacts(messages: TeamMessage[]): Artifact[] {
|
||||
const artifacts: Artifact[] = [];
|
||||
for (const msg of messages) {
|
||||
const phase = PHASE_MESSAGE_MAP[msg.type];
|
||||
if (!phase) continue;
|
||||
// Include messages that have ref OR data (inline artifacts)
|
||||
if (!msg.ref && !msg.data) continue;
|
||||
artifacts.push({
|
||||
id: msg.id,
|
||||
message: msg,
|
||||
phase,
|
||||
ref: msg.ref,
|
||||
});
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
function groupByPhase(artifacts: Artifact[]): Record<ArtifactPhase, Artifact[]> {
|
||||
const groups: Record<ArtifactPhase, Artifact[]> = {
|
||||
plan: [],
|
||||
impl: [],
|
||||
test: [],
|
||||
review: [],
|
||||
};
|
||||
for (const a of artifacts) {
|
||||
groups[a.phase].push(a);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function getContentType(ref: string): 'markdown' | 'json' | 'text' {
|
||||
if (ref.endsWith('.json')) return 'json';
|
||||
if (ref.endsWith('.md')) return 'markdown';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString();
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Sub-components
|
||||
// ========================================
|
||||
|
||||
function ArtifactCard({
|
||||
artifact,
|
||||
onView,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
onView: (artifact: Artifact) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = PHASE_CONFIG[artifact.phase];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={() => onView(artifact)}
|
||||
>
|
||||
<CardContent className="p-3 flex items-start gap-3">
|
||||
<div className={cn('mt-0.5', config.color)}>
|
||||
{artifact.ref ? (
|
||||
<Icon className="w-4 h-4" />
|
||||
) : (
|
||||
<Database className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{artifact.message.summary}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{artifact.ref ? (
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{artifact.ref.split('/').pop()}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{formatMessage({ id: 'team.artifacts.noRef' })}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
||||
{formatTimestamp(artifact.message.ts)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PhaseGroup({
|
||||
phase,
|
||||
artifacts,
|
||||
onView,
|
||||
}: {
|
||||
phase: ArtifactPhase;
|
||||
artifacts: Artifact[];
|
||||
onView: (artifact: Artifact) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
if (artifacts.length === 0) return null;
|
||||
|
||||
const config = PHASE_CONFIG[phase];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn('w-4 h-4', config.color)} />
|
||||
<h4 className="text-sm font-medium">
|
||||
{formatMessage({ id: `team.artifacts.${phase}` })}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{artifacts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 pl-6">
|
||||
{artifacts.map((artifact) => (
|
||||
<ArtifactCard key={artifact.id} artifact={artifact} onView={onView} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Main Component
|
||||
// ========================================
|
||||
|
||||
export function TeamArtifacts({ messages }: TeamArtifactsProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [selectedArtifact, setSelectedArtifact] = React.useState<Artifact | null>(null);
|
||||
const [modalContent, setModalContent] = React.useState('');
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const artifacts = React.useMemo(() => extractArtifacts(messages), [messages]);
|
||||
const grouped = React.useMemo(() => groupByPhase(artifacts), [artifacts]);
|
||||
|
||||
const handleView = React.useCallback(async (artifact: Artifact) => {
|
||||
setSelectedArtifact(artifact);
|
||||
|
||||
if (artifact.ref) {
|
||||
setIsLoading(true);
|
||||
setModalContent('');
|
||||
try {
|
||||
const result = await fetchFileContent(artifact.ref);
|
||||
setModalContent(result.content);
|
||||
} catch {
|
||||
setModalContent(`Failed to load: ${artifact.ref}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else if (artifact.message.data) {
|
||||
setModalContent(JSON.stringify(artifact.message.data, null, 2));
|
||||
} else {
|
||||
setModalContent(artifact.message.summary);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setSelectedArtifact(null);
|
||||
setModalContent('');
|
||||
}, []);
|
||||
|
||||
// Empty state
|
||||
if (artifacts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'team.artifacts.noArtifacts' })}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine content type for modal
|
||||
const modalContentType = selectedArtifact?.ref
|
||||
? getContentType(selectedArtifact.ref)
|
||||
: selectedArtifact?.message.data
|
||||
? 'json'
|
||||
: 'text';
|
||||
|
||||
const modalTitle = selectedArtifact?.ref
|
||||
? selectedArtifact.ref.split('/').pop() || 'File'
|
||||
: selectedArtifact?.message.summary || 'Data';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{PHASE_ORDER.map((phase) => (
|
||||
<PhaseGroup
|
||||
key={phase}
|
||||
phase={phase}
|
||||
artifacts={grouped[phase]}
|
||||
onView={handleView}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedArtifact && (
|
||||
<MarkdownModal
|
||||
isOpen={!!selectedArtifact}
|
||||
onClose={handleClose}
|
||||
title={modalTitle}
|
||||
content={modalContent}
|
||||
contentType={modalContentType}
|
||||
maxWidth="3xl"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
235
ccw/frontend/src/components/team/TeamCard.tsx
Normal file
235
ccw/frontend/src/components/team/TeamCard.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
// ========================================
|
||||
// TeamCard Component
|
||||
// ========================================
|
||||
// Team card with status badge and action menu
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import {
|
||||
Users,
|
||||
MessageSquare,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Trash2,
|
||||
Clock,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import type { TeamSummaryExtended, TeamStatus } from '@/types/team';
|
||||
|
||||
export interface TeamCardProps {
|
||||
team: TeamSummaryExtended;
|
||||
onClick?: (name: string) => void;
|
||||
onArchive?: (name: string) => void;
|
||||
onUnarchive?: (name: string) => void;
|
||||
onDelete?: (name: string) => void;
|
||||
showActions?: boolean;
|
||||
actionsDisabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusVariantConfig: Record<
|
||||
TeamStatus,
|
||||
{ variant: 'default' | 'secondary' | 'success' | 'info' }
|
||||
> = {
|
||||
active: { variant: 'info' },
|
||||
completed: { variant: 'success' },
|
||||
archived: { variant: 'secondary' },
|
||||
};
|
||||
|
||||
const statusLabelKeys: Record<TeamStatus, string> = {
|
||||
active: 'team.status.active',
|
||||
completed: 'team.status.completed',
|
||||
archived: 'team.status.archived',
|
||||
};
|
||||
|
||||
function formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function TeamCard({
|
||||
team,
|
||||
onClick,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onDelete,
|
||||
showActions = true,
|
||||
actionsDisabled = false,
|
||||
className,
|
||||
}: TeamCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const { variant: statusVariant } = statusVariantConfig[team.status] || { variant: 'default' as const };
|
||||
const statusLabel = statusLabelKeys[team.status]
|
||||
? formatMessage({ id: statusLabelKeys[team.status] })
|
||||
: team.status;
|
||||
|
||||
const isArchived = team.status === 'archived';
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('[data-radix-popper-content-wrapper]')) return;
|
||||
onClick?.(team.name);
|
||||
};
|
||||
|
||||
const handleAction = (e: React.MouseEvent, action: 'view' | 'archive' | 'unarchive' | 'delete') => {
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
case 'view':
|
||||
onClick?.(team.name);
|
||||
break;
|
||||
case 'archive':
|
||||
onArchive?.(team.name);
|
||||
break;
|
||||
case 'unarchive':
|
||||
onUnarchive?.(team.name);
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete?.(team.name);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group cursor-pointer transition-all duration-200 hover:shadow-md hover:border-primary/30',
|
||||
className
|
||||
)}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Header: team name + status + actions */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-bold text-card-foreground text-sm tracking-wide truncate">
|
||||
{team.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
{team.pipeline_mode && (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{team.pipeline_mode}
|
||||
</Badge>
|
||||
)}
|
||||
{showActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'view')}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'team.actions.viewDetails' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{isArchived ? (
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'unarchive')}>
|
||||
<ArchiveRestore className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'team.actions.unarchive' })}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'archive')}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'team.actions.archive' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleAction(e, 'delete')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'team.actions.delete' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{team.messageCount} {formatMessage({ id: 'team.card.messages' })}
|
||||
</span>
|
||||
{team.lastActivity && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatDate(team.lastActivity)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members row */}
|
||||
{team.members && team.members.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
{team.members.map((name) => (
|
||||
<Badge key={name} variant="outline" className="text-[10px] px-1.5 py-0 font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for TeamCard
|
||||
*/
|
||||
export function TeamCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Card className={cn('animate-pulse', className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-5 w-16 rounded-full bg-muted" />
|
||||
</div>
|
||||
<div className="mt-3 flex gap-4">
|
||||
<div className="h-4 w-24 rounded bg-muted" />
|
||||
<div className="h-4 w-20 rounded bg-muted" />
|
||||
<div className="h-4 w-20 rounded bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,19 @@
|
||||
// ========================================
|
||||
// TeamHeader Component
|
||||
// ========================================
|
||||
// Team selector, stats chips, and controls
|
||||
// Detail view header with back button, stats, and controls
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { Users, MessageSquare, RefreshCw, ArrowLeft } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import type { TeamSummary, TeamMember } from '@/types/team';
|
||||
import type { TeamMember } from '@/types/team';
|
||||
|
||||
interface TeamHeaderProps {
|
||||
teams: TeamSummary[];
|
||||
selectedTeam: string | null;
|
||||
onSelectTeam: (name: string | null) => void;
|
||||
onBack: () => void;
|
||||
members: TeamMember[];
|
||||
totalMessages: number;
|
||||
autoRefresh: boolean;
|
||||
@@ -28,9 +21,8 @@ interface TeamHeaderProps {
|
||||
}
|
||||
|
||||
export function TeamHeader({
|
||||
teams,
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
onBack,
|
||||
members,
|
||||
totalMessages,
|
||||
autoRefresh,
|
||||
@@ -41,35 +33,29 @@ export function TeamHeader({
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Team Selector */}
|
||||
<Select
|
||||
value={selectedTeam ?? ''}
|
||||
onValueChange={(v) => onSelectTeam(v || null)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.selectTeam' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teams.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Back button */}
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{formatMessage({ id: 'team.detail.backToList' })}
|
||||
</Button>
|
||||
|
||||
{/* Stats chips */}
|
||||
{/* Team name */}
|
||||
{selectedTeam && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.members' })}: {members.length}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.messages' })}: {totalMessages}
|
||||
</Badge>
|
||||
</div>
|
||||
<>
|
||||
<h2 className="text-lg font-semibold">{selectedTeam}</h2>
|
||||
|
||||
{/* Stats chips */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.members' })}: {members.length}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.messages' })}: {totalMessages}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
230
ccw/frontend/src/components/team/TeamListView.tsx
Normal file
230
ccw/frontend/src/components/team/TeamListView.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
// ========================================
|
||||
// TeamListView Component
|
||||
// ========================================
|
||||
// Team card grid with tabs, search, and actions
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TeamCard, TeamCardSkeleton } from './TeamCard';
|
||||
import { useTeamStore } from '@/stores/teamStore';
|
||||
import { useTeams, useArchiveTeam, useUnarchiveTeam, useDeleteTeam } from '@/hooks/useTeamData';
|
||||
|
||||
export function TeamListView() {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
locationFilter,
|
||||
setLocationFilter,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
selectTeamAndShowDetail,
|
||||
} = useTeamStore();
|
||||
|
||||
// Dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
const [teamToDelete, setTeamToDelete] = React.useState<string | null>(null);
|
||||
|
||||
// Data
|
||||
const { teams, isLoading, isFetching, refetch } = useTeams(locationFilter);
|
||||
const { archiveTeam, isArchiving } = useArchiveTeam();
|
||||
const { unarchiveTeam, isUnarchiving } = useUnarchiveTeam();
|
||||
const { deleteTeam, isDeleting } = useDeleteTeam();
|
||||
|
||||
const isMutating = isArchiving || isUnarchiving || isDeleting;
|
||||
|
||||
// Client-side search filter
|
||||
const filteredTeams = React.useMemo(() => {
|
||||
if (!searchQuery) return teams;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return teams.filter((t) => t.name.toLowerCase().includes(q));
|
||||
}, [teams, searchQuery]);
|
||||
|
||||
// Handlers
|
||||
const handleArchive = async (name: string) => {
|
||||
try {
|
||||
await archiveTeam(name);
|
||||
} catch (err) {
|
||||
console.error('Failed to archive team:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnarchive = async (name: string) => {
|
||||
try {
|
||||
await unarchiveTeam(name);
|
||||
} catch (err) {
|
||||
console.error('Failed to unarchive team:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (name: string) => {
|
||||
setTeamToDelete(name);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!teamToDelete) return;
|
||||
try {
|
||||
await deleteTeam(teamToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setTeamToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete team:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = () => setSearchQuery('');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{formatMessage({ id: 'team.title' })}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'team.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* Location tabs */}
|
||||
<TabsNavigation
|
||||
value={locationFilter}
|
||||
onValueChange={(v) => setLocationFilter(v as 'active' | 'archived' | 'all')}
|
||||
tabs={[
|
||||
{ value: 'active', label: formatMessage({ id: 'team.filters.active' }) },
|
||||
{ value: 'archived', label: formatMessage({ id: 'team.filters.archived' }) },
|
||||
{ value: 'all', label: formatMessage({ id: 'team.filters.all' }) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="flex-1 max-w-sm relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'team.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<TeamCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : filteredTeams.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 border border-dashed border-border rounded-lg">
|
||||
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-1">
|
||||
{searchQuery
|
||||
? formatMessage({ id: 'team.emptyState.noMatching' })
|
||||
: formatMessage({ id: 'team.emptyState.noTeams' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
|
||||
{searchQuery
|
||||
? formatMessage({ id: 'team.emptyState.noMatchingDescription' })
|
||||
: formatMessage({ id: 'team.emptyState.noTeamsDescription' })}
|
||||
</p>
|
||||
{searchQuery && (
|
||||
<Button variant="outline" onClick={handleClearSearch}>
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredTeams.map((team) => (
|
||||
<TeamCard
|
||||
key={team.name}
|
||||
team={team}
|
||||
onClick={selectTeamAndShowDetail}
|
||||
onArchive={handleArchive}
|
||||
onUnarchive={handleUnarchive}
|
||||
onDelete={handleDeleteClick}
|
||||
actionsDisabled={isMutating}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'team.dialog.deleteTeam' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'team.dialog.deleteConfirm' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setTeamToDelete(null);
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'team.dialog.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting
|
||||
? formatMessage({ id: 'team.dialog.deleting' })
|
||||
: formatMessage({ id: 'team.actions.delete' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,32 +109,34 @@ export function TeamPipeline({ messages }: TeamPipelineProps) {
|
||||
const stageStatus = derivePipelineStatus(messages);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'team.pipeline.title' })}
|
||||
</h3>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="space-y-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'team.pipeline.title' })}
|
||||
</h3>
|
||||
|
||||
{/* Desktop: horizontal layout */}
|
||||
<div className="hidden sm:flex items-center gap-0">
|
||||
<StageNode stage="plan" status={stageStatus.plan} />
|
||||
<Arrow />
|
||||
<StageNode stage="impl" status={stageStatus.impl} />
|
||||
<Arrow />
|
||||
<div className="flex flex-col gap-2">
|
||||
<StageNode stage="test" status={stageStatus.test} />
|
||||
<StageNode stage="review" status={stageStatus.review} />
|
||||
{/* Desktop: horizontal layout */}
|
||||
<div className="hidden sm:flex items-center gap-0">
|
||||
<StageNode stage="plan" status={stageStatus.plan} />
|
||||
<Arrow />
|
||||
<StageNode stage="impl" status={stageStatus.impl} />
|
||||
<Arrow />
|
||||
<div className="flex flex-col gap-2">
|
||||
<StageNode stage="test" status={stageStatus.test} />
|
||||
<StageNode stage="review" status={stageStatus.review} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical layout */}
|
||||
<div className="flex sm:hidden flex-col items-center gap-2">
|
||||
{STAGES.map((stage) => (
|
||||
<StageNode key={stage} stage={stage} status={stageStatus[stage]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical layout */}
|
||||
<div className="flex sm:hidden flex-col items-center gap-2">
|
||||
{STAGES.map((stage) => (
|
||||
<StageNode key={stage} stage={stage} status={stageStatus[stage]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 text-[10px] text-muted-foreground pt-1">
|
||||
{/* Legend - pinned to bottom */}
|
||||
<div className="flex flex-wrap gap-3 text-[10px] text-muted-foreground mt-auto pt-3 border-t border-border">
|
||||
{(['completed', 'in_progress', 'pending', 'blocked'] as PipelineStageStatus[]).map((s) => {
|
||||
const cfg = statusConfig[s];
|
||||
const Icon = cfg.icon;
|
||||
|
||||
@@ -6,19 +6,27 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, Terminal as TerminalIcon } from 'lucide-react';
|
||||
import {
|
||||
X,
|
||||
Terminal as TerminalIcon,
|
||||
Plus,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionStore, type CliSessionMeta } from '@/stores/cliSessionStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { QueueExecutionListView } from './QueueExecutionListView';
|
||||
import {
|
||||
createCliSession,
|
||||
fetchCliSessionBuffer,
|
||||
sendCliSessionText,
|
||||
resizeCliSession,
|
||||
executeInCliSession,
|
||||
closeCliSession,
|
||||
} from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -33,11 +41,15 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const panelView = useTerminalPanelStore((s) => s.panelView);
|
||||
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
|
||||
const openTerminal = useTerminalPanelStore((s) => s.openTerminal);
|
||||
const removeTerminal = useTerminalPanelStore((s) => s.removeTerminal);
|
||||
|
||||
const sessions = useCliSessionStore((s) => s.sessions);
|
||||
const outputChunks = useCliSessionStore((s) => s.outputChunks);
|
||||
const setBuffer = useCliSessionStore((s) => s.setBuffer);
|
||||
const clearOutput = useCliSessionStore((s) => s.clearOutput);
|
||||
const upsertSession = useCliSessionStore((s) => s.upsertSession);
|
||||
const removeSessionFromStore = useCliSessionStore((s) => s.removeSession);
|
||||
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
@@ -56,9 +68,12 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
const pendingInputRef = useRef<string>('');
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Command execution
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
// Toolbar state
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
// Available CLI tools
|
||||
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
|
||||
|
||||
const flushInput = useCallback(async () => {
|
||||
const sessionKey = activeTerminalId;
|
||||
@@ -187,34 +202,44 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
return () => ro.disconnect();
|
||||
}, [activeTerminalId, projectPath]);
|
||||
|
||||
// ========== Command Execution ==========
|
||||
// ========== CLI Session Actions ==========
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!activeTerminalId || !prompt.trim()) return;
|
||||
setIsExecuting(true);
|
||||
const sessionTool = (activeSession?.tool || 'claude') as 'claude' | 'codex' | 'gemini';
|
||||
const handleCreateSession = useCallback(async (tool: string) => {
|
||||
if (!projectPath || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await executeInCliSession(activeTerminalId, {
|
||||
tool: sessionTool,
|
||||
prompt: prompt.trim(),
|
||||
mode: 'analysis',
|
||||
category: 'user',
|
||||
}, projectPath || undefined);
|
||||
setPrompt('');
|
||||
const created = await createCliSession(
|
||||
{ workingDir: projectPath, tool },
|
||||
projectPath
|
||||
);
|
||||
upsertSession(created.session);
|
||||
openTerminal(created.session.sessionKey);
|
||||
} catch (err) {
|
||||
// Error shown in terminal output; log for DevTools debugging
|
||||
console.error('[TerminalMainArea] executeInCliSession failed:', err);
|
||||
console.error('[TerminalMainArea] createCliSession failed:', err);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
}, [projectPath, isCreating, upsertSession, openTerminal]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleExecute();
|
||||
const handleCloseSession = useCallback(async () => {
|
||||
if (!activeTerminalId || isClosing) return;
|
||||
setIsClosing(true);
|
||||
try {
|
||||
await closeCliSession(activeTerminalId, projectPath || undefined);
|
||||
removeTerminal(activeTerminalId);
|
||||
removeSessionFromStore(activeTerminalId);
|
||||
} catch (err) {
|
||||
console.error('[TerminalMainArea] closeCliSession failed:', err);
|
||||
} finally {
|
||||
setIsClosing(false);
|
||||
}
|
||||
};
|
||||
}, [activeTerminalId, isClosing, projectPath, removeTerminal, removeSessionFromStore]);
|
||||
|
||||
const handleClearTerminal = useCallback(() => {
|
||||
const term = xtermRef.current;
|
||||
if (!term) return;
|
||||
term.clear();
|
||||
}, []);
|
||||
|
||||
// ========== Render ==========
|
||||
|
||||
@@ -242,59 +267,90 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
{panelView === 'terminal' && (
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border bg-muted/30">
|
||||
{/* New CLI session buttons */}
|
||||
{CLI_TOOLS.map((tool) => (
|
||||
<Button
|
||||
key={tool}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
disabled={isCreating || !projectPath}
|
||||
onClick={() => handleCreateSession(tool)}
|
||||
title={`New ${tool} session`}
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
{tool}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Terminal actions */}
|
||||
{activeTerminalId && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleClearTerminal}
|
||||
title="Clear terminal"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||
disabled={isClosing}
|
||||
onClick={handleCloseSession}
|
||||
title="Close session"
|
||||
>
|
||||
{isClosing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{panelView === 'queue' ? (
|
||||
/* Queue View - Placeholder */
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.executionQueueDesc' })}</p>
|
||||
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.executionQueuePhase2' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
/* Queue View */
|
||||
<QueueExecutionListView />
|
||||
) : activeTerminalId ? (
|
||||
/* Terminal View */
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* xterm container */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div
|
||||
ref={terminalHostRef}
|
||||
className="h-full w-full bg-black/90 rounded-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Command Input */}
|
||||
<div className="border-t border-border p-3 bg-card">
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={formatMessage({ id: 'home.terminalPanel.commandPlaceholder' })}
|
||||
className={cn(
|
||||
'w-full min-h-[60px] p-2 bg-background border border-input rounded-md text-sm resize-none',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={!activeTerminalId || isExecuting || !prompt.trim()}
|
||||
>
|
||||
{formatMessage({ id: 'home.terminalPanel.execute' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<div
|
||||
ref={terminalHostRef}
|
||||
className="h-full w-full bg-black/90 rounded-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
/* Empty State - with quick launch */
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.noTerminalSelected' })}</p>
|
||||
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
|
||||
<p className="text-xs mt-1 mb-4">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
|
||||
{projectPath && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{CLI_TOOLS.map((tool) => (
|
||||
<Button
|
||||
key={tool}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={isCreating}
|
||||
onClick={() => handleCreateSession(tool)}
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||||
{tool}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
// Left-side icon navigation bar (w-16) inside TerminalPanel.
|
||||
// Shows fixed queue entry icon + dynamic terminal icons with status badges.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ClipboardList, Terminal, Loader2, CheckCircle, XCircle, Circle } from 'lucide-react';
|
||||
import { ClipboardList, Terminal, Loader2, CheckCircle, XCircle, Circle, Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionStore, type CliSessionMeta, type CliSessionOutputChunk } from '@/stores/cliSessionStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { createCliSession } from '@/lib/api';
|
||||
|
||||
// ========== Status Badge Mapping ==========
|
||||
|
||||
@@ -48,11 +51,37 @@ export function TerminalNavBar() {
|
||||
const terminalOrder = useTerminalPanelStore((s) => s.terminalOrder);
|
||||
const setPanelView = useTerminalPanelStore((s) => s.setPanelView);
|
||||
const setActiveTerminal = useTerminalPanelStore((s) => s.setActiveTerminal);
|
||||
const openTerminal = useTerminalPanelStore((s) => s.openTerminal);
|
||||
|
||||
const sessions = useCliSessionStore((s) => s.sessions);
|
||||
const outputChunks = useCliSessionStore((s) => s.outputChunks);
|
||||
const upsertSession = useCliSessionStore((s) => s.upsertSession);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showToolMenu, setShowToolMenu] = useState(false);
|
||||
|
||||
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
|
||||
|
||||
const handleCreateSession = useCallback(async (tool: string) => {
|
||||
if (!projectPath || isCreating) return;
|
||||
setIsCreating(true);
|
||||
setShowToolMenu(false);
|
||||
try {
|
||||
const created = await createCliSession(
|
||||
{ workingDir: projectPath, tool },
|
||||
projectPath
|
||||
);
|
||||
upsertSession(created.session);
|
||||
openTerminal(created.session.sessionKey);
|
||||
} catch (err) {
|
||||
console.error('[TerminalNavBar] createCliSession failed:', err);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [projectPath, isCreating, upsertSession, openTerminal]);
|
||||
|
||||
const handleQueueClick = () => {
|
||||
setPanelView('queue');
|
||||
};
|
||||
@@ -120,6 +149,44 @@ export function TerminalNavBar() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* New Terminal Button - Fixed at bottom */}
|
||||
<div className="relative">
|
||||
<div className="w-8 border-t border-border mb-2" />
|
||||
<button
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-md flex items-center justify-center transition-colors hover:bg-accent',
|
||||
!projectPath && 'opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => projectPath && setShowToolMenu(!showToolMenu)}
|
||||
disabled={isCreating || !projectPath}
|
||||
title={formatMessage({ id: 'home.terminalPanel.newSession' })}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-5 w-5 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tool Selection Popup */}
|
||||
{showToolMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowToolMenu(false)} />
|
||||
<div className="absolute left-full bottom-0 ml-1 z-50 bg-card border border-border rounded-md shadow-lg py-1 min-w-[120px]">
|
||||
{CLI_TOOLS.map((tool) => (
|
||||
<button
|
||||
key={tool}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
onClick={() => handleCreateSession(tool)}
|
||||
>
|
||||
{tool}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export type { UseWebSocketOptions, UseWebSocketReturn } from './useWebSocket';
|
||||
|
||||
export { useWebSocketNotifications } from './useWebSocketNotifications';
|
||||
|
||||
export { useCompletionCallbackChain } from './useCompletionCallbackChain';
|
||||
|
||||
export { useSystemNotifications } from './useSystemNotifications';
|
||||
export type { UseSystemNotificationsReturn, SystemNotificationOptions } from './useSystemNotifications';
|
||||
|
||||
@@ -54,7 +56,6 @@ export {
|
||||
useUpdateLoopStatus,
|
||||
useDeleteLoop,
|
||||
useLoopMutations,
|
||||
loopsKeys,
|
||||
} from './useLoops';
|
||||
export type {
|
||||
LoopsFilter,
|
||||
@@ -118,7 +119,6 @@ export {
|
||||
useCommands,
|
||||
useCommandSearch,
|
||||
useCommandMutations,
|
||||
commandsKeys,
|
||||
} from './useCommands';
|
||||
export type {
|
||||
CommandsFilter,
|
||||
|
||||
@@ -104,16 +104,13 @@ export function useActiveCliExecutions(
|
||||
enabled: boolean,
|
||||
refetchInterval: number = 5000
|
||||
) {
|
||||
const upsertExecution = useCliStreamStore(state => state.upsertExecution);
|
||||
const removeExecution = useCliStreamStore(state => state.removeExecution);
|
||||
const executions = useCliStreamStore(state => state.executions);
|
||||
const setCurrentExecution = useCliStreamStore(state => state.setCurrentExecution);
|
||||
const isExecutionClosedByUser = useCliStreamStore(state => state.isExecutionClosedByUser);
|
||||
const cleanupUserClosedExecutions = useCliStreamStore(state => state.cleanupUserClosedExecutions);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
// Access store state at execution time to avoid stale closures
|
||||
const store = useCliStreamStore.getState();
|
||||
const currentExecutions = store.executions;
|
||||
|
||||
const response = await fetch('/api/cli/active');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch active executions: ${response.statusText}`);
|
||||
@@ -124,16 +121,16 @@ export function useActiveCliExecutions(
|
||||
const serverIds = new Set(data.executions.map(e => e.id));
|
||||
|
||||
// Clean up userClosedExecutions - remove those no longer on server
|
||||
cleanupUserClosedExecutions(serverIds);
|
||||
store.cleanupUserClosedExecutions(serverIds);
|
||||
|
||||
// Remove executions that are no longer on server and were closed by user
|
||||
for (const [id, exec] of Object.entries(executions)) {
|
||||
if (isExecutionClosedByUser(id)) {
|
||||
for (const [id, exec] of Object.entries(currentExecutions)) {
|
||||
if (store.isExecutionClosedByUser(id)) {
|
||||
// User closed this execution, remove from local state
|
||||
removeExecution(id);
|
||||
store.removeExecution(id);
|
||||
} else if (exec.status !== 'running' && !serverIds.has(id) && exec.recovered) {
|
||||
// Not running, not on server, and was recovered (not user-created)
|
||||
removeExecution(id);
|
||||
store.removeExecution(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +140,11 @@ export function useActiveCliExecutions(
|
||||
|
||||
for (const exec of data.executions) {
|
||||
// Skip if user closed this execution
|
||||
if (isExecutionClosedByUser(exec.id)) {
|
||||
if (store.isExecutionClosedByUser(exec.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = executions[exec.id];
|
||||
const existing = currentExecutions[exec.id];
|
||||
const historicalOutput = parseHistoricalOutput(exec.output || '', exec.startTime);
|
||||
|
||||
if (!existing) {
|
||||
@@ -187,7 +184,7 @@ export function useActiveCliExecutions(
|
||||
];
|
||||
}
|
||||
|
||||
upsertExecution(exec.id, {
|
||||
store.upsertExecution(exec.id, {
|
||||
tool: exec.tool || 'cli',
|
||||
mode: exec.mode || 'analysis',
|
||||
status: exec.status || 'running',
|
||||
@@ -200,9 +197,9 @@ export function useActiveCliExecutions(
|
||||
|
||||
// Set current execution to first running execution if none selected
|
||||
if (hasNewExecution) {
|
||||
const runningExec = data.executions.find(e => e.status === 'running' && !isExecutionClosedByUser(e.id));
|
||||
if (runningExec && !executions[runningExec.id]) {
|
||||
setCurrentExecution(runningExec.id);
|
||||
const runningExec = data.executions.find(e => e.status === 'running' && !store.isExecutionClosedByUser(e.id));
|
||||
if (runningExec && !currentExecutions[runningExec.id]) {
|
||||
store.setCurrentExecution(runningExec.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface UseCliSessionCoreOptions {
|
||||
/** Default resumeKey used when creating sessions via ensureSession/handleCreateSession. */
|
||||
resumeKey?: string;
|
||||
/** Shell to use when creating new sessions. Defaults to 'bash'. */
|
||||
preferredShell?: string;
|
||||
preferredShell?: 'bash' | 'pwsh';
|
||||
/** Additional createCliSession fields (cols, rows, tool, model). */
|
||||
createSessionDefaults?: {
|
||||
cols?: number;
|
||||
|
||||
@@ -14,13 +14,7 @@ import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useNotifications } from './useNotifications';
|
||||
import { sanitizeErrorMessage } from '@/utils/errorSanitizer';
|
||||
import { formatMessage } from '@/lib/i18n';
|
||||
|
||||
// Query key factory
|
||||
export const commandsKeys = {
|
||||
all: ['commands'] as const,
|
||||
lists: () => [...commandsKeys.all, 'list'] as const,
|
||||
list: (filters?: CommandsFilter) => [...commandsKeys.lists(), filters] as const,
|
||||
};
|
||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
||||
|
||||
// Default stale time: 10 minutes (commands are static)
|
||||
const STALE_TIME = 10 * 60 * 1000;
|
||||
@@ -84,7 +78,7 @@ export function useCommandMutations(): UseCommandMutationsReturn {
|
||||
const { loadingId } = context ?? { loadingId: '' };
|
||||
if (loadingId) removeToast(loadingId);
|
||||
success(formatMessage('feedback.commandToggle.success'));
|
||||
queryClient.invalidateQueries({ queryKey: commandsKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.commands(projectPath) });
|
||||
},
|
||||
onError: (err, __, context) => {
|
||||
const { loadingId } = context ?? { loadingId: '' };
|
||||
@@ -105,7 +99,7 @@ export function useCommandMutations(): UseCommandMutationsReturn {
|
||||
const { loadingId } = context ?? { loadingId: '' };
|
||||
if (loadingId) removeToast(loadingId);
|
||||
success(formatMessage('feedback.commandToggle.success'));
|
||||
queryClient.invalidateQueries({ queryKey: commandsKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.commands(projectPath) });
|
||||
},
|
||||
onError: (err, __, context) => {
|
||||
const { loadingId } = context ?? { loadingId: '' };
|
||||
@@ -129,10 +123,10 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: commandsKeys.list(filter),
|
||||
queryKey: workspaceQueryKeys.commandsList(projectPath),
|
||||
queryFn: () => fetchCommands(projectPath),
|
||||
staleTime,
|
||||
enabled: enabled, // Remove projectPath requirement
|
||||
enabled: enabled,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
@@ -213,7 +207,7 @@ export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: commandsKeys.all });
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.commands(projectPath) });
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
199
ccw/frontend/src/hooks/useCompletionCallbackChain.ts
Normal file
199
ccw/frontend/src/hooks/useCompletionCallbackChain.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// ========================================
|
||||
// useCompletionCallbackChain Hook
|
||||
// ========================================
|
||||
// Watches for WebSocket CLI_COMPLETED and CLI_ERROR events and connects
|
||||
// them to the orchestratorStore for automated step advancement.
|
||||
//
|
||||
// This hook bridges the WebSocket event layer with the orchestration
|
||||
// state machine, enabling the feedback loop:
|
||||
// CLI execution completes -> WebSocket event -> store update -> next step
|
||||
//
|
||||
// Usage: Mount this hook once at the App level to enable callback chain processing.
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { useOrchestratorStore } from '@/stores/orchestratorStore';
|
||||
import type { WebSocketMessage } from '@/types/store';
|
||||
import type { ErrorHandlingStrategy } from '@/types/orchestrator';
|
||||
|
||||
// ========== Payload Types ==========
|
||||
|
||||
interface CliCompletedPayload {
|
||||
executionId: string;
|
||||
success: boolean;
|
||||
duration?: number;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
interface CliErrorPayload {
|
||||
executionId: string;
|
||||
error: string;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
// ========== Hook ==========
|
||||
|
||||
/**
|
||||
* Hook that subscribes to WebSocket completion events and updates
|
||||
* the orchestratorStore accordingly. Enables automated step advancement
|
||||
* by connecting CLI execution results to the orchestration state machine.
|
||||
*
|
||||
* On CLI_COMPLETED:
|
||||
* 1. Look up executionId in all active plans' executionIdMap
|
||||
* 2. Call updateStepStatus(planId, stepId, 'completed')
|
||||
* 3. Call _advanceToNextStep(planId) to identify the next ready step
|
||||
*
|
||||
* On CLI_ERROR / CLI_COMPLETED with success=false:
|
||||
* 1. Look up executionId in all active plans' executionIdMap
|
||||
* 2. Call updateStepStatus(planId, stepId, 'failed', error)
|
||||
* 3. Apply error handling strategy from the step or plan defaults
|
||||
*/
|
||||
export function useCompletionCallbackChain(): void {
|
||||
const wsLastMessage = useNotificationStore((state) => state.wsLastMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsLastMessage) return;
|
||||
|
||||
const { type, payload } = wsLastMessage as WebSocketMessage & {
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
// Only process CLI completion/error events
|
||||
if (type !== 'CLI_COMPLETED' && type !== 'CLI_ERROR') return;
|
||||
|
||||
// Access store state directly (zustand pattern for non-rendering access)
|
||||
const store = useOrchestratorStore.getState();
|
||||
|
||||
if (type === 'CLI_COMPLETED') {
|
||||
handleCliCompleted(store, payload as CliCompletedPayload | undefined);
|
||||
} else if (type === 'CLI_ERROR') {
|
||||
handleCliError(store, payload as CliErrorPayload | undefined);
|
||||
}
|
||||
}, [wsLastMessage]);
|
||||
}
|
||||
|
||||
// ========== Event Handlers ==========
|
||||
|
||||
function handleCliCompleted(
|
||||
store: ReturnType<typeof useOrchestratorStore.getState>,
|
||||
payload: CliCompletedPayload | undefined
|
||||
): void {
|
||||
if (!payload?.executionId) return;
|
||||
|
||||
const { executionId, success, result } = payload;
|
||||
|
||||
// Find which plan/step this execution belongs to
|
||||
const match = findPlanStepByExecutionId(store, executionId);
|
||||
if (!match) return; // Not an orchestrated execution
|
||||
|
||||
const { planId, stepId } = match;
|
||||
|
||||
if (success) {
|
||||
// Step completed successfully
|
||||
store.updateStepStatus(planId, stepId, 'completed', { data: result });
|
||||
// Advance to the next ready step (does not execute, only identifies)
|
||||
store._advanceToNextStep(planId);
|
||||
} else {
|
||||
// CLI_COMPLETED with success=false is treated as a failure
|
||||
handleStepFailure(store, planId, stepId, 'CLI execution completed with failure status');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCliError(
|
||||
store: ReturnType<typeof useOrchestratorStore.getState>,
|
||||
payload: CliErrorPayload | undefined
|
||||
): void {
|
||||
if (!payload?.executionId) return;
|
||||
|
||||
const { executionId, error } = payload;
|
||||
|
||||
// Find which plan/step this execution belongs to
|
||||
const match = findPlanStepByExecutionId(store, executionId);
|
||||
if (!match) return; // Not an orchestrated execution
|
||||
|
||||
const { planId, stepId } = match;
|
||||
handleStepFailure(store, planId, stepId, error);
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* Look up which plan and step an executionId belongs to by scanning
|
||||
* all active plans' executionIdMap.
|
||||
*/
|
||||
function findPlanStepByExecutionId(
|
||||
store: ReturnType<typeof useOrchestratorStore.getState>,
|
||||
executionId: string
|
||||
): { planId: string; stepId: string } | undefined {
|
||||
for (const [planId, runState] of Object.entries(store.activePlans)) {
|
||||
const stepId = runState.executionIdMap[executionId];
|
||||
if (stepId) {
|
||||
return { planId, stepId };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective error handling strategy for a step.
|
||||
* Step-level errorHandling overrides plan-level defaults.
|
||||
*/
|
||||
function getEffectiveErrorStrategy(
|
||||
store: ReturnType<typeof useOrchestratorStore.getState>,
|
||||
planId: string,
|
||||
stepId: string
|
||||
): ErrorHandlingStrategy {
|
||||
const runState = store.activePlans[planId];
|
||||
if (!runState) return 'pause_on_error';
|
||||
|
||||
// Find the step definition
|
||||
const stepDef = runState.plan.steps.find((s) => s.id === stepId);
|
||||
|
||||
// Step-level strategy overrides plan-level default
|
||||
return (
|
||||
stepDef?.errorHandling?.strategy ??
|
||||
runState.plan.defaultErrorHandling.strategy ??
|
||||
'pause_on_error'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle step failure by applying the appropriate error handling strategy.
|
||||
*/
|
||||
function handleStepFailure(
|
||||
store: ReturnType<typeof useOrchestratorStore.getState>,
|
||||
planId: string,
|
||||
stepId: string,
|
||||
errorMessage: string
|
||||
): void {
|
||||
// Mark the step as failed
|
||||
store.updateStepStatus(planId, stepId, 'failed', { error: errorMessage });
|
||||
|
||||
// Determine error handling strategy
|
||||
const strategy = getEffectiveErrorStrategy(store, planId, stepId);
|
||||
|
||||
switch (strategy) {
|
||||
case 'pause_on_error':
|
||||
// Pause the orchestration for user intervention
|
||||
store.pauseOrchestration(planId);
|
||||
break;
|
||||
|
||||
case 'skip':
|
||||
// Skip this step and advance to the next
|
||||
store.skipStep(planId, stepId);
|
||||
store._advanceToNextStep(planId);
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
// Stop the entire orchestration
|
||||
store.stopOrchestration(planId, `Step "${stepId}" failed: ${errorMessage}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Fallback: pause on error
|
||||
store.pauseOrchestration(planId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export default useCompletionCallbackChain;
|
||||
@@ -13,13 +13,7 @@ import {
|
||||
type HistoryResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// Query key factory
|
||||
export const historyKeys = {
|
||||
all: ['history'] as const,
|
||||
lists: () => [...historyKeys.all, 'list'] as const,
|
||||
list: (filter?: HistoryFilter) => [...historyKeys.lists(), filter] as const,
|
||||
};
|
||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export interface HistoryFilter {
|
||||
search?: string;
|
||||
@@ -75,7 +69,7 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: historyKeys.list(filter),
|
||||
queryKey: workspaceQueryKeys.cliHistoryList(projectPath),
|
||||
queryFn: () => fetchHistory(projectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
@@ -113,7 +107,7 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
|
||||
const deleteSingleMutation = useMutation({
|
||||
mutationFn: deleteExecution,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: historyKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.cliHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,7 +115,7 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
|
||||
const deleteByToolMutation = useMutation({
|
||||
mutationFn: deleteExecutionsByTool,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: historyKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.cliHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -129,7 +123,7 @@ export function useHistory(options: UseHistoryOptions = {}): UseHistoryReturn {
|
||||
const deleteAllMutation = useMutation({
|
||||
mutationFn: deleteAllHistory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: historyKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.cliHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
type IndexRebuildRequest,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// ========== Query Keys ==========
|
||||
|
||||
export const indexKeys = {
|
||||
all: ['index'] as const,
|
||||
status: () => [...indexKeys.all, 'status'] as const,
|
||||
};
|
||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
||||
|
||||
// ========== Stale Time ==========
|
||||
|
||||
@@ -57,7 +51,7 @@ export function useIndexStatus(options: UseIndexStatusOptions = {}): UseIndexSta
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: indexKeys.status(),
|
||||
queryKey: workspaceQueryKeys.indexStatus(projectPath),
|
||||
queryFn: () => fetchIndexStatus(projectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
@@ -70,7 +64,7 @@ export function useIndexStatus(options: UseIndexStatusOptions = {}): UseIndexSta
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: indexKeys.all });
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.index(projectPath) });
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -105,12 +99,13 @@ export interface UseRebuildIndexReturn {
|
||||
*/
|
||||
export function useRebuildIndex(): UseRebuildIndexReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: rebuildIndex,
|
||||
onSuccess: (updatedStatus) => {
|
||||
// Update the status query cache
|
||||
queryClient.setQueryData(indexKeys.status(), updatedStatus);
|
||||
queryClient.setQueryData(workspaceQueryKeys.indexStatus(projectPath), updatedStatus);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,15 +14,7 @@ import {
|
||||
type LoopsResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// Query key factory
|
||||
export const loopsKeys = {
|
||||
all: ['loops'] as const,
|
||||
lists: () => [...loopsKeys.all, 'list'] as const,
|
||||
list: (filters?: LoopsFilter) => [...loopsKeys.lists(), filters] as const,
|
||||
details: () => [...loopsKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...loopsKeys.details(), id] as const,
|
||||
};
|
||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
||||
|
||||
// Default stale time: 10 seconds (loops update frequently)
|
||||
const STALE_TIME = 10 * 1000;
|
||||
@@ -63,7 +55,7 @@ export function useLoops(options: UseLoopsOptions = {}): UseLoopsReturn {
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: loopsKeys.list(filter),
|
||||
queryKey: workspaceQueryKeys.loopsList(projectPath),
|
||||
queryFn: () => fetchLoops(projectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
@@ -112,7 +104,7 @@ export function useLoops(options: UseLoopsOptions = {}): UseLoopsReturn {
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: loopsKeys.all });
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.loops(projectPath) });
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -137,7 +129,7 @@ export function useLoop(loopId: string, options: { enabled?: boolean } = {}) {
|
||||
const queryEnabled = (options.enabled ?? !!loopId) && !!projectPath;
|
||||
|
||||
return useQuery({
|
||||
queryKey: loopsKeys.detail(loopId),
|
||||
queryKey: workspaceQueryKeys.loopDetail(projectPath, loopId),
|
||||
queryFn: () => fetchLoop(loopId, projectPath),
|
||||
enabled: queryEnabled,
|
||||
staleTime: STALE_TIME,
|
||||
@@ -154,11 +146,12 @@ export interface UseCreateLoopReturn {
|
||||
|
||||
export function useCreateLoop(): UseCreateLoopReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createLoop,
|
||||
onSuccess: (newLoop) => {
|
||||
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
|
||||
queryClient.setQueryData<LoopsResponse>(workspaceQueryKeys.loopsList(projectPath), (old) => {
|
||||
if (!old) return { loops: [newLoop], total: 1 };
|
||||
return {
|
||||
loops: [newLoop, ...old.loops],
|
||||
@@ -183,19 +176,20 @@ export interface UseUpdateLoopStatusReturn {
|
||||
|
||||
export function useUpdateLoopStatus(): UseUpdateLoopStatusReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ loopId, action }: { loopId: string; action: 'pause' | 'resume' | 'stop' }) =>
|
||||
updateLoopStatus(loopId, action),
|
||||
onSuccess: (updatedLoop) => {
|
||||
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
|
||||
queryClient.setQueryData<LoopsResponse>(workspaceQueryKeys.loopsList(projectPath), (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
loops: old.loops.map((l) => (l.id === updatedLoop.id ? updatedLoop : l)),
|
||||
};
|
||||
});
|
||||
queryClient.setQueryData(loopsKeys.detail(updatedLoop.id), updatedLoop);
|
||||
queryClient.setQueryData(workspaceQueryKeys.loopDetail(projectPath, updatedLoop.id), updatedLoop);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -214,14 +208,15 @@ export interface UseDeleteLoopReturn {
|
||||
|
||||
export function useDeleteLoop(): UseDeleteLoopReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteLoop,
|
||||
onMutate: async (loopId) => {
|
||||
await queryClient.cancelQueries({ queryKey: loopsKeys.all });
|
||||
const previousLoops = queryClient.getQueryData<LoopsResponse>(loopsKeys.list());
|
||||
await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.loops(projectPath) });
|
||||
const previousLoops = queryClient.getQueryData<LoopsResponse>(workspaceQueryKeys.loopsList(projectPath));
|
||||
|
||||
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
|
||||
queryClient.setQueryData<LoopsResponse>(workspaceQueryKeys.loopsList(projectPath), (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
@@ -234,11 +229,11 @@ export function useDeleteLoop(): UseDeleteLoopReturn {
|
||||
},
|
||||
onError: (_error, _loopId, context) => {
|
||||
if (context?.previousLoops) {
|
||||
queryClient.setQueryData(loopsKeys.list(), context.previousLoops);
|
||||
queryClient.setQueryData(workspaceQueryKeys.loopsList(projectPath), context.previousLoops);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: loopsKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.loops(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,15 +18,7 @@ import {
|
||||
type InsightsHistoryResponse,
|
||||
} from '../lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// Query key factory
|
||||
export const promptHistoryKeys = {
|
||||
all: ['promptHistory'] as const,
|
||||
lists: () => [...promptHistoryKeys.all, 'list'] as const,
|
||||
list: (filters?: PromptHistoryFilter) => [...promptHistoryKeys.lists(), filters] as const,
|
||||
insights: () => [...promptHistoryKeys.all, 'insights'] as const,
|
||||
insightsHistory: () => [...promptHistoryKeys.all, 'insightsHistory'] as const,
|
||||
};
|
||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
||||
|
||||
// Default stale time: 30 seconds (prompts update less frequently)
|
||||
const STALE_TIME = 30 * 1000;
|
||||
@@ -78,7 +70,7 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: promptHistoryKeys.list(filter),
|
||||
queryKey: workspaceQueryKeys.promptsList(projectPath),
|
||||
queryFn: () => fetchPrompts(projectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
@@ -179,7 +171,7 @@ export function usePromptHistory(options: UsePromptHistoryOptions = {}): UseProm
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
|
||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -214,7 +206,7 @@ export function usePromptInsights(options: { enabled?: boolean; staleTime?: numb
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
return useQuery({
|
||||
queryKey: promptHistoryKeys.insights(),
|
||||
queryKey: workspaceQueryKeys.promptsInsights(projectPath),
|
||||
queryFn: () => fetchPromptInsights(projectPath),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
@@ -236,7 +228,7 @@ export function useInsightsHistory(options: {
|
||||
const queryEnabled = enabled && !!projectPath;
|
||||
|
||||
return useQuery({
|
||||
queryKey: promptHistoryKeys.insightsHistory(),
|
||||
queryKey: workspaceQueryKeys.promptsInsightsHistory(projectPath),
|
||||
queryFn: () => fetchInsightsHistory(projectPath, limit),
|
||||
staleTime,
|
||||
enabled: queryEnabled,
|
||||
@@ -254,12 +246,12 @@ export interface UseAnalyzePromptsReturn {
|
||||
|
||||
export function useAnalyzePrompts(): UseAnalyzePromptsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: analyzePrompts,
|
||||
onSuccess: () => {
|
||||
// Invalidate insights query after analysis
|
||||
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.insights() });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.promptsInsights(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -278,14 +270,15 @@ export interface UseDeletePromptReturn {
|
||||
|
||||
export function useDeletePrompt(): UseDeletePromptReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deletePrompt,
|
||||
onMutate: async (promptId) => {
|
||||
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.all });
|
||||
const previousPrompts = queryClient.getQueryData<PromptsResponse>(promptHistoryKeys.list());
|
||||
await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
|
||||
const previousPrompts = queryClient.getQueryData<PromptsResponse>(workspaceQueryKeys.promptsList(projectPath));
|
||||
|
||||
queryClient.setQueryData<PromptsResponse>(promptHistoryKeys.list(), (old) => {
|
||||
queryClient.setQueryData<PromptsResponse>(workspaceQueryKeys.promptsList(projectPath), (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
@@ -298,11 +291,11 @@ export function useDeletePrompt(): UseDeletePromptReturn {
|
||||
},
|
||||
onError: (_error, _promptId, context) => {
|
||||
if (context?.previousPrompts) {
|
||||
queryClient.setQueryData(promptHistoryKeys.list(), context.previousPrompts);
|
||||
queryClient.setQueryData(workspaceQueryKeys.promptsList(projectPath), context.previousPrompts);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -321,14 +314,15 @@ export interface UseBatchDeletePromptsReturn {
|
||||
|
||||
export function useBatchDeletePrompts(): UseBatchDeletePromptsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: batchDeletePrompts,
|
||||
onMutate: async (promptIds) => {
|
||||
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.all });
|
||||
const previousPrompts = queryClient.getQueryData<PromptsResponse>(promptHistoryKeys.list());
|
||||
await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
|
||||
const previousPrompts = queryClient.getQueryData<PromptsResponse>(workspaceQueryKeys.promptsList(projectPath));
|
||||
|
||||
queryClient.setQueryData<PromptsResponse>(promptHistoryKeys.list(), (old) => {
|
||||
queryClient.setQueryData<PromptsResponse>(workspaceQueryKeys.promptsList(projectPath), (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
@@ -341,11 +335,11 @@ export function useBatchDeletePrompts(): UseBatchDeletePromptsReturn {
|
||||
},
|
||||
onError: (_error, _promptIds, context) => {
|
||||
if (context?.previousPrompts) {
|
||||
queryClient.setQueryData(promptHistoryKeys.list(), context.previousPrompts);
|
||||
queryClient.setQueryData(workspaceQueryKeys.promptsList(projectPath), context.previousPrompts);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.prompts(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -369,10 +363,10 @@ export function useDeleteInsight(): UseDeleteInsightReturn {
|
||||
const mutation = useMutation({
|
||||
mutationFn: (insightId: string) => deleteInsight(insightId, projectPath),
|
||||
onMutate: async (insightId) => {
|
||||
await queryClient.cancelQueries({ queryKey: promptHistoryKeys.insightsHistory() });
|
||||
const previousInsights = queryClient.getQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory());
|
||||
await queryClient.cancelQueries({ queryKey: workspaceQueryKeys.promptsInsightsHistory(projectPath) });
|
||||
const previousInsights = queryClient.getQueryData<InsightsHistoryResponse>(workspaceQueryKeys.promptsInsightsHistory(projectPath));
|
||||
|
||||
queryClient.setQueryData<InsightsHistoryResponse>(promptHistoryKeys.insightsHistory(), (old) => {
|
||||
queryClient.setQueryData<InsightsHistoryResponse>(workspaceQueryKeys.promptsInsightsHistory(projectPath), (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
@@ -384,11 +378,11 @@ export function useDeleteInsight(): UseDeleteInsightReturn {
|
||||
},
|
||||
onError: (_error, _insightId, context) => {
|
||||
if (context?.previousInsights) {
|
||||
queryClient.setQueryData(promptHistoryKeys.insightsHistory(), context.previousInsights);
|
||||
queryClient.setQueryData(workspaceQueryKeys.promptsInsightsHistory(projectPath), context.previousInsights);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: promptHistoryKeys.insightsHistory() });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.promptsInsightsHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
// ========================================
|
||||
// TanStack Query hooks for team execution visualization
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchTeams, fetchTeamMessages, fetchTeamStatus } from '@/lib/api';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchTeams, fetchTeamMessages, fetchTeamStatus, archiveTeam, unarchiveTeam, deleteTeam } from '@/lib/api';
|
||||
import { useTeamStore } from '@/stores/teamStore';
|
||||
import type {
|
||||
TeamSummary,
|
||||
TeamSummaryExtended,
|
||||
TeamMessage,
|
||||
TeamMember,
|
||||
TeamMessageFilter,
|
||||
@@ -20,30 +20,33 @@ import type {
|
||||
export const teamKeys = {
|
||||
all: ['teams'] as const,
|
||||
lists: () => [...teamKeys.all, 'list'] as const,
|
||||
listByLocation: (location: string) => [...teamKeys.lists(), location] as const,
|
||||
messages: (team: string, filter?: TeamMessageFilter) =>
|
||||
[...teamKeys.all, 'messages', team, filter] as const,
|
||||
status: (team: string) => [...teamKeys.all, 'status', team] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook: list all teams
|
||||
* Hook: list all teams with location filter
|
||||
*/
|
||||
export function useTeams() {
|
||||
export function useTeams(location?: string) {
|
||||
const autoRefresh = useTeamStore((s) => s.autoRefresh);
|
||||
const effectiveLocation = location || 'active';
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: teamKeys.lists(),
|
||||
queryKey: teamKeys.listByLocation(effectiveLocation),
|
||||
queryFn: async (): Promise<TeamsListResponse> => {
|
||||
const data = await fetchTeams();
|
||||
return { teams: data.teams ?? [] };
|
||||
const data = await fetchTeams(effectiveLocation);
|
||||
return { teams: (data.teams ?? []) as TeamSummaryExtended[] };
|
||||
},
|
||||
staleTime: 10_000,
|
||||
refetchInterval: autoRefresh ? 10_000 : false,
|
||||
});
|
||||
|
||||
return {
|
||||
teams: (query.data?.teams ?? []) as TeamSummary[],
|
||||
teams: (query.data?.teams ?? []) as TeamSummaryExtended[],
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
@@ -125,3 +128,65 @@ export function useInvalidateTeamData() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: teamKeys.all });
|
||||
}
|
||||
|
||||
// ========== Mutation Hooks ==========
|
||||
|
||||
/**
|
||||
* Hook: archive a team
|
||||
*/
|
||||
export function useArchiveTeam() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: archiveTeam,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
archiveTeam: mutation.mutateAsync,
|
||||
isArchiving: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: unarchive a team
|
||||
*/
|
||||
export function useUnarchiveTeam() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: unarchiveTeam,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
unarchiveTeam: mutation.mutateAsync,
|
||||
isUnarchiving: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: delete a team
|
||||
*/
|
||||
export function useDeleteTeam() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteTeam,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.all });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deleteTeam: mutation.mutateAsync,
|
||||
isDeleting: mutation.isPending,
|
||||
error: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,8 +434,8 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WebSocket] Error:', error);
|
||||
ws.onerror = () => {
|
||||
console.warn('[WebSocket] Connection error');
|
||||
getStoreState().setWsStatus('error');
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -2039,6 +2039,14 @@ export interface NormalizedTask extends TaskData {
|
||||
_raw?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize files field: handles both old string[] format and new {path}[] format.
|
||||
*/
|
||||
function normalizeFilesField(files: unknown): Array<{ path: string; name?: string }> | undefined {
|
||||
if (!Array.isArray(files) || files.length === 0) return undefined;
|
||||
return files.map((f: unknown) => typeof f === 'string' ? { path: f } : f) as Array<{ path: string; name?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw task object (old 6-field or new unified flat) into NormalizedTask.
|
||||
* Reads new flat fields first, falls back to old nested paths.
|
||||
@@ -2049,18 +2057,23 @@ export function normalizeTask(raw: Record<string, unknown>): NormalizedTask {
|
||||
return { task_id: 'N/A', status: 'pending', _raw: raw } as NormalizedTask;
|
||||
}
|
||||
|
||||
// Type-safe access helpers
|
||||
const rawContext = raw.context as LiteTask['context'] | undefined;
|
||||
// Type-safe access helpers (use intersection for broad compat with old/new schemas)
|
||||
const rawContext = raw.context as (LiteTask['context'] & { requirements?: string[] }) | undefined;
|
||||
const rawFlowControl = raw.flow_control as FlowControl | undefined;
|
||||
const rawMeta = raw.meta as LiteTask['meta'] | undefined;
|
||||
const rawConvergence = raw.convergence as NormalizedTask['convergence'] | undefined;
|
||||
|
||||
// Description: new flat field first, then join old context.requirements
|
||||
// Description: new flat field first, then join old context.requirements, then old details/scope
|
||||
const rawRequirements = rawContext?.requirements;
|
||||
const rawDetails = raw.details as string[] | undefined;
|
||||
const description = (raw.description as string | undefined)
|
||||
|| (Array.isArray(rawRequirements) && rawRequirements.length > 0
|
||||
? rawRequirements.join('; ')
|
||||
: undefined);
|
||||
: undefined)
|
||||
|| (Array.isArray(rawDetails) && rawDetails.length > 0
|
||||
? rawDetails.join('; ')
|
||||
: undefined)
|
||||
|| (raw.scope as string | undefined);
|
||||
|
||||
return {
|
||||
// Identity
|
||||
@@ -2084,7 +2097,7 @@ export function normalizeTask(raw: Record<string, unknown>): NormalizedTask {
|
||||
// Promoted from flow_control (new first, old fallback)
|
||||
pre_analysis: (raw.pre_analysis as PreAnalysisStep[]) || rawFlowControl?.pre_analysis,
|
||||
implementation: (raw.implementation as (ImplementationStep | string)[]) || rawFlowControl?.implementation_approach,
|
||||
files: (raw.files as Array<{ path: string; name?: string }>) || rawFlowControl?.target_files,
|
||||
files: normalizeFilesField(raw.files) || rawFlowControl?.target_files,
|
||||
|
||||
// Promoted from meta (new first, old fallback)
|
||||
type: (raw.type as string) || rawMeta?.type,
|
||||
@@ -5964,8 +5977,23 @@ export async function fetchCcwTools(): Promise<CcwToolInfo[]> {
|
||||
|
||||
// ========== Team API ==========
|
||||
|
||||
export async function fetchTeams(): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string }> }> {
|
||||
return fetchApi('/api/teams');
|
||||
export async function fetchTeams(location?: string): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string; status: string; created_at: string; updated_at: string; archived_at?: string; pipeline_mode?: string; memberCount: number; members?: string[] }> }> {
|
||||
const params = new URLSearchParams();
|
||||
if (location) params.set('location', location);
|
||||
const qs = params.toString();
|
||||
return fetchApi(`/api/teams${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function archiveTeam(teamName: string): Promise<{ success: boolean; team: string; status: string }> {
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/archive`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function unarchiveTeam(teamName: string): Promise<{ success: boolean; team: string; status: string }> {
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/unarchive`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function deleteTeam(teamName: string): Promise<void> {
|
||||
return fetchApi<void>(`/api/teams/${encodeURIComponent(teamName)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchTeamMessages(
|
||||
|
||||
@@ -92,6 +92,7 @@ export const workspaceQueryKeys = {
|
||||
prompts: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'prompts'] as const,
|
||||
promptsList: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'list'] as const,
|
||||
promptsInsights: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'insights'] as const,
|
||||
promptsInsightsHistory: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'insightsHistory'] as const,
|
||||
|
||||
// ========== Index ==========
|
||||
index: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'index'] as const,
|
||||
|
||||
203
ccw/frontend/src/lib/unifiedExecutionDispatcher.ts
Normal file
203
ccw/frontend/src/lib/unifiedExecutionDispatcher.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// ========================================
|
||||
// Unified Execution Dispatcher
|
||||
// ========================================
|
||||
// Stateless dispatcher that resolves session strategy and dispatches
|
||||
// OrchestrationStep execution to the CLI session API.
|
||||
|
||||
import type { OrchestrationStep, SessionStrategy } from '../types/orchestrator';
|
||||
import { createCliSession, executeInCliSession } from './api';
|
||||
import type { ExecuteInCliSessionInput } from './api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/**
|
||||
* Options for dispatch execution.
|
||||
* These supplement the step's own configuration with runtime context.
|
||||
*/
|
||||
export interface DispatchOptions {
|
||||
/** Working directory for the CLI session (used when creating new sessions). */
|
||||
workingDir?: string;
|
||||
/** Execution category for tracking/filtering. */
|
||||
category?: ExecuteInCliSessionInput['category'];
|
||||
/** Resume key for session continuity. */
|
||||
resumeKey?: string;
|
||||
/** Resume strategy for the CLI execution. */
|
||||
resumeStrategy?: ExecuteInCliSessionInput['resumeStrategy'];
|
||||
/** Project path for API routing. */
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a dispatched execution.
|
||||
* Provides the execution ID for callback registration and the resolved session key.
|
||||
*/
|
||||
export interface DispatchResult {
|
||||
/** Unique execution ID returned by the API, used for tracking and callback chains. */
|
||||
executionId: string;
|
||||
/** The session key used for execution (may differ from input if strategy created a new session). */
|
||||
sessionKey: string;
|
||||
/** Whether a new CLI session was created for this dispatch. */
|
||||
isNewSession: boolean;
|
||||
}
|
||||
|
||||
// ========== Session Strategy Resolution ==========
|
||||
|
||||
interface ResolvedSession {
|
||||
sessionKey: string;
|
||||
isNewSession: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the session key based on the step's session strategy.
|
||||
*
|
||||
* - 'reuse_default': Use the provided defaultSessionKey directly.
|
||||
* - 'new_session': Create a new PTY session via the API.
|
||||
* - 'specific_session': Use the step's targetSessionKey (must be provided).
|
||||
*/
|
||||
async function resolveSessionKey(
|
||||
strategy: SessionStrategy,
|
||||
defaultSessionKey: string,
|
||||
step: OrchestrationStep,
|
||||
options: DispatchOptions
|
||||
): Promise<ResolvedSession> {
|
||||
switch (strategy) {
|
||||
case 'reuse_default':
|
||||
return { sessionKey: defaultSessionKey, isNewSession: false };
|
||||
|
||||
case 'new_session': {
|
||||
const result = await createCliSession(
|
||||
{
|
||||
workingDir: options.workingDir,
|
||||
tool: step.tool,
|
||||
},
|
||||
options.projectPath
|
||||
);
|
||||
return { sessionKey: result.session.sessionKey, isNewSession: true };
|
||||
}
|
||||
|
||||
case 'specific_session': {
|
||||
const targetKey = step.targetSessionKey;
|
||||
if (!targetKey) {
|
||||
throw new DispatchError(
|
||||
`Step "${step.id}" uses 'specific_session' strategy but no targetSessionKey is provided.`,
|
||||
'MISSING_TARGET_SESSION_KEY'
|
||||
);
|
||||
}
|
||||
return { sessionKey: targetKey, isNewSession: false };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new DispatchError(
|
||||
`Unknown session strategy: "${strategy}" on step "${step.id}".`,
|
||||
'UNKNOWN_SESSION_STRATEGY'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Error Type ==========
|
||||
|
||||
/**
|
||||
* Typed error for dispatch failures with an error code for programmatic handling.
|
||||
*/
|
||||
export class DispatchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: DispatchErrorCode
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DispatchError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DispatchErrorCode =
|
||||
| 'MISSING_TARGET_SESSION_KEY'
|
||||
| 'UNKNOWN_SESSION_STRATEGY'
|
||||
| 'SESSION_CREATION_FAILED'
|
||||
| 'EXECUTION_FAILED';
|
||||
|
||||
// ========== Dispatcher ==========
|
||||
|
||||
/**
|
||||
* Dispatch an orchestration step for execution in a CLI session.
|
||||
*
|
||||
* This is a stateless utility function that:
|
||||
* 1. Resolves the session key based on the step's sessionStrategy.
|
||||
* 2. Calls executeInCliSession() with the resolved session and step parameters.
|
||||
* 3. Returns the executionId for callback chain registration.
|
||||
*
|
||||
* @param step - The orchestration step to execute.
|
||||
* @param sessionKey - The default session key (used when strategy is 'reuse_default').
|
||||
* @param options - Additional dispatch options.
|
||||
* @returns The dispatch result containing executionId and resolved sessionKey.
|
||||
* @throws {DispatchError} When session resolution or execution fails.
|
||||
*/
|
||||
export async function dispatch(
|
||||
step: OrchestrationStep,
|
||||
sessionKey: string,
|
||||
options: DispatchOptions = {}
|
||||
): Promise<DispatchResult> {
|
||||
const strategy: SessionStrategy = step.sessionStrategy ?? 'reuse_default';
|
||||
|
||||
// Step 1: Resolve session key
|
||||
let resolved: ResolvedSession;
|
||||
try {
|
||||
resolved = await resolveSessionKey(strategy, sessionKey, step, options);
|
||||
} catch (err) {
|
||||
if (err instanceof DispatchError) throw err;
|
||||
throw new DispatchError(
|
||||
`Failed to resolve session for step "${step.id}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
'SESSION_CREATION_FAILED'
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Build execution input from step + options
|
||||
const executionInput: ExecuteInCliSessionInput = {
|
||||
tool: step.tool ?? 'gemini',
|
||||
prompt: step.instruction,
|
||||
mode: mapExecutionMode(step.mode),
|
||||
workingDir: options.workingDir,
|
||||
category: options.category,
|
||||
resumeKey: options.resumeKey ?? step.resumeKey,
|
||||
resumeStrategy: options.resumeStrategy,
|
||||
};
|
||||
|
||||
// Step 3: Execute in the resolved session
|
||||
try {
|
||||
const result = await executeInCliSession(
|
||||
resolved.sessionKey,
|
||||
executionInput,
|
||||
options.projectPath
|
||||
);
|
||||
|
||||
return {
|
||||
executionId: result.executionId,
|
||||
sessionKey: resolved.sessionKey,
|
||||
isNewSession: resolved.isNewSession,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new DispatchError(
|
||||
`Execution failed for step "${step.id}" in session "${resolved.sessionKey}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
'EXECUTION_FAILED'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the orchestrator's ExecutionMode to the API's mode parameter.
|
||||
* The API accepts 'analysis' | 'write' | 'auto', while the orchestrator
|
||||
* uses a broader set including 'mainprocess' and 'async'.
|
||||
*/
|
||||
function mapExecutionMode(
|
||||
mode?: OrchestrationStep['mode']
|
||||
): ExecuteInCliSessionInput['mode'] {
|
||||
if (!mode) return undefined;
|
||||
switch (mode) {
|
||||
case 'analysis':
|
||||
return 'analysis';
|
||||
case 'write':
|
||||
return 'write';
|
||||
default:
|
||||
// 'mainprocess', 'async', and any future modes default to 'auto'
|
||||
return 'auto';
|
||||
}
|
||||
}
|
||||
@@ -112,15 +112,22 @@
|
||||
"executionQueueDesc": "Execution Queue Management",
|
||||
"executionQueuePhase2": "Coming in Phase 2",
|
||||
"noTerminalSelected": "No terminal selected",
|
||||
"selectTerminalHint": "Select a terminal from the sidebar",
|
||||
"selectTerminalHint": "Select a terminal from the sidebar, or click + to create one",
|
||||
"commandPlaceholder": "Enter command... (Ctrl+Enter to execute)",
|
||||
"execute": "Execute",
|
||||
"openInPanel": "Open in Terminal Panel",
|
||||
"newSession": "New Terminal",
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"idle": "Idle"
|
||||
},
|
||||
"queueView": {
|
||||
"session": "Session",
|
||||
"orchestrator": "Orchestrator",
|
||||
"emptyTitle": "No executions yet",
|
||||
"emptyDesc": "Executions started from the issue queue will appear here"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"launch": "Launch Session",
|
||||
"session": {
|
||||
"select": "Select session",
|
||||
"none": "No sessions",
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"controlPanel": {
|
||||
"progress": "{completed}/{total} steps",
|
||||
"noPlan": "No orchestration plan found",
|
||||
"completedMessage": "Orchestration completed successfully",
|
||||
"failedMessage": "Orchestration stopped",
|
||||
"cancelled": "Cancelled",
|
||||
"retry": "Retry",
|
||||
"skip": "Skip"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node",
|
||||
"nodes": "Nodes",
|
||||
|
||||
@@ -10,6 +10,58 @@
|
||||
"filterByType": "Filter by type",
|
||||
"filterAll": "All Types",
|
||||
"stage": "Stage",
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"completed": "Completed",
|
||||
"archived": "Archived"
|
||||
},
|
||||
"filters": {
|
||||
"active": "Active",
|
||||
"archived": "Archived",
|
||||
"all": "All"
|
||||
},
|
||||
"searchPlaceholder": "Search teams...",
|
||||
"card": {
|
||||
"members": "Members",
|
||||
"messages": "Messages",
|
||||
"lastActivity": "Last Activity",
|
||||
"created": "Created"
|
||||
},
|
||||
"actions": {
|
||||
"viewDetails": "View Details",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"delete": "Delete Team"
|
||||
},
|
||||
"dialog": {
|
||||
"deleteTeam": "Delete Team",
|
||||
"deleteConfirm": "This action cannot be undone. This will permanently delete the team and all its messages.",
|
||||
"cancel": "Cancel",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Back to Teams"
|
||||
},
|
||||
"tabs": {
|
||||
"artifacts": "Artifacts",
|
||||
"messages": "Messages"
|
||||
},
|
||||
"artifacts": {
|
||||
"title": "Team Artifacts",
|
||||
"plan": "Plan",
|
||||
"impl": "Implementation",
|
||||
"test": "Test",
|
||||
"review": "Review",
|
||||
"noArtifacts": "No artifacts found",
|
||||
"viewFile": "View File",
|
||||
"noRef": "Inline data"
|
||||
},
|
||||
"emptyState": {
|
||||
"noTeams": "No Teams",
|
||||
"noTeamsDescription": "Use /team:coordinate to create a team and start collaborating",
|
||||
"noMatching": "No Matching Teams",
|
||||
"noMatchingDescription": "Try adjusting your search or filter criteria"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No Active Teams",
|
||||
"description": "Use /team:coordinate to create a team and start collaborating",
|
||||
|
||||
@@ -112,15 +112,22 @@
|
||||
"executionQueueDesc": "执行队列管理",
|
||||
"executionQueuePhase2": "将在 Phase 2 实现",
|
||||
"noTerminalSelected": "未选择终端",
|
||||
"selectTerminalHint": "从侧边栏选择一个终端",
|
||||
"selectTerminalHint": "从侧边栏选择一个终端,或点击 + 新建",
|
||||
"commandPlaceholder": "输入命令... (Ctrl+Enter 执行)",
|
||||
"execute": "执行",
|
||||
"openInPanel": "在终端面板中查看",
|
||||
"newSession": "新建终端",
|
||||
"status": {
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败",
|
||||
"idle": "空闲"
|
||||
},
|
||||
"queueView": {
|
||||
"session": "会话",
|
||||
"orchestrator": "编排器",
|
||||
"emptyTitle": "暂无执行任务",
|
||||
"emptyDesc": "从问题队列发起执行后将在此显示"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"launch": "启动会话",
|
||||
"session": {
|
||||
"select": "选择会话",
|
||||
"none": "暂无会话",
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
"completed": "已完成",
|
||||
"failed": "失败"
|
||||
},
|
||||
"controlPanel": {
|
||||
"progress": "{completed}/{total} 步",
|
||||
"noPlan": "未找到编排计划",
|
||||
"completedMessage": "编排已成功完成",
|
||||
"failedMessage": "编排已停止",
|
||||
"cancelled": "已取消",
|
||||
"retry": "重试",
|
||||
"skip": "跳过"
|
||||
},
|
||||
"node": {
|
||||
"title": "节点",
|
||||
"nodes": "节点列表",
|
||||
|
||||
@@ -10,6 +10,58 @@
|
||||
"filterByType": "按类型筛选",
|
||||
"filterAll": "所有类型",
|
||||
"stage": "阶段",
|
||||
"status": {
|
||||
"active": "活跃",
|
||||
"completed": "已完成",
|
||||
"archived": "已归档"
|
||||
},
|
||||
"filters": {
|
||||
"active": "活跃",
|
||||
"archived": "已归档",
|
||||
"all": "全部"
|
||||
},
|
||||
"searchPlaceholder": "搜索团队...",
|
||||
"card": {
|
||||
"members": "成员",
|
||||
"messages": "消息",
|
||||
"lastActivity": "最近活动",
|
||||
"created": "创建时间"
|
||||
},
|
||||
"actions": {
|
||||
"viewDetails": "查看详情",
|
||||
"archive": "归档",
|
||||
"unarchive": "取消归档",
|
||||
"delete": "删除团队"
|
||||
},
|
||||
"dialog": {
|
||||
"deleteTeam": "删除团队",
|
||||
"deleteConfirm": "此操作不可撤销。这将永久删除该团队及其所有消息。",
|
||||
"cancel": "取消",
|
||||
"deleting": "删除中..."
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "返回团队列表"
|
||||
},
|
||||
"tabs": {
|
||||
"artifacts": "产物",
|
||||
"messages": "消息"
|
||||
},
|
||||
"artifacts": {
|
||||
"title": "团队产物",
|
||||
"plan": "计划",
|
||||
"impl": "实现",
|
||||
"test": "测试",
|
||||
"review": "审查",
|
||||
"noArtifacts": "暂无产物",
|
||||
"viewFile": "查看文件",
|
||||
"noRef": "内联数据"
|
||||
},
|
||||
"emptyState": {
|
||||
"noTeams": "暂无团队",
|
||||
"noTeamsDescription": "使用 /team:coordinate 创建团队以开始协作",
|
||||
"noMatching": "没有匹配的团队",
|
||||
"noMatchingDescription": "尝试调整搜索条件或筛选条件"
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无活跃团队",
|
||||
"description": "使用 /team:coordinate 创建团队以开始协作",
|
||||
|
||||
344
ccw/frontend/src/orchestrator/OrchestrationPlanBuilder.ts
Normal file
344
ccw/frontend/src/orchestrator/OrchestrationPlanBuilder.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
OrchestrationPlan,
|
||||
OrchestrationStep,
|
||||
SessionStrategy,
|
||||
ErrorHandling,
|
||||
ExecutionType,
|
||||
OrchestrationMetadata,
|
||||
ManualOrchestrationParams,
|
||||
} from '../types/orchestrator';
|
||||
import { Flow, FlowNode, PromptTemplateNodeData } from '../types/flow';
|
||||
import { IssueQueue } from '../lib/api';
|
||||
import { buildQueueItemContext } from '../lib/queue-prompt'; // Assuming this function is available
|
||||
|
||||
/**
|
||||
* Builds OrchestrationPlan objects from various sources (Flow, IssueQueue, Manual Input).
|
||||
* This class is responsible for transforming source data into a standardized OrchestrationPlan,
|
||||
* including dependency resolution, context mapping, and basic plan metadata generation.
|
||||
*/
|
||||
export class OrchestrationPlanBuilder {
|
||||
private static DEFAULT_SESSION_STRATEGY: SessionStrategy = 'reuse_default';
|
||||
private static DEFAULT_ERROR_HANDLING: ErrorHandling = {
|
||||
strategy: 'pause_on_error',
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Flow DAG into a topologically-sorted OrchestrationPlan.
|
||||
*
|
||||
* @param flow The Flow object to convert.
|
||||
* @returns An OrchestrationPlan.
|
||||
*/
|
||||
public static fromFlow(flow: Flow): OrchestrationPlan {
|
||||
const steps: OrchestrationStep[] = [];
|
||||
const nodeMap = new Map<string, FlowNode>(flow.nodes.map((node) => [node.id, node]));
|
||||
const adjacencyList = new Map<string, string[]>(); // node.id -> list of dependent node.ids
|
||||
const inDegree = new Map<string, number>(); // node.id -> count of incoming edges
|
||||
|
||||
// Initialize in-degrees and adjacency list
|
||||
for (const node of flow.nodes) {
|
||||
inDegree.set(node.id, 0);
|
||||
adjacencyList.set(node.id, []);
|
||||
}
|
||||
|
||||
for (const edge of flow.edges) {
|
||||
// Ensure the edge target node exists before incrementing in-degree
|
||||
if (inDegree.has(edge.target)) {
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
|
||||
// Ensure the adjacency list source node exists before adding
|
||||
adjacencyList.get(edge.source)?.push(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm for topological sort
|
||||
const queue: string[] = [];
|
||||
for (const [nodeId, degree] of inDegree.entries()) {
|
||||
if (degree === 0) {
|
||||
queue.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedNodeIds: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const nodeId = queue.shift()!;
|
||||
sortedNodeIds.push(nodeId);
|
||||
|
||||
for (const neighborId of adjacencyList.get(nodeId) || []) {
|
||||
inDegree.set(neighborId, (inDegree.get(neighborId) || 0) - 1);
|
||||
if (inDegree.get(neighborId) === 0) {
|
||||
queue.push(neighborId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle detection
|
||||
if (sortedNodeIds.length !== flow.nodes.length) {
|
||||
// This should ideally be a more specific error or an exception
|
||||
console.error('Cycle detected in flow graph. Topological sort failed.');
|
||||
throw new Error('Cycle detected in flow graph. Cannot build orchestration plan from cyclic flow.');
|
||||
}
|
||||
|
||||
// Convert sorted nodes to OrchestrationSteps
|
||||
for (const nodeId of sortedNodeIds) {
|
||||
const node = nodeMap.get(nodeId)!;
|
||||
const nodeData = node.data as PromptTemplateNodeData; // Assuming all nodes are PromptTemplateNodeData
|
||||
|
||||
const dependsOn = flow.edges
|
||||
.filter((edge) => edge.target === node.id)
|
||||
.map((edge) => edge.source);
|
||||
|
||||
// Map delivery to sessionStrategy
|
||||
let sessionStrategy: SessionStrategy | undefined;
|
||||
if (nodeData.delivery === 'newExecution') {
|
||||
sessionStrategy = 'new_session';
|
||||
} else if (nodeData.delivery === 'sendToSession' && nodeData.targetSessionKey) {
|
||||
sessionStrategy = 'specific_session';
|
||||
} else if (nodeData.delivery === 'sendToSession' && !nodeData.targetSessionKey) {
|
||||
// Fallback or explicit default if targetSessionKey is missing for sendToSession
|
||||
sessionStrategy = 'reuse_default';
|
||||
}
|
||||
|
||||
// Determine execution type
|
||||
let executionType: ExecutionType = 'frontend-cli'; // Default
|
||||
if (nodeData.slashCommand) {
|
||||
executionType = 'slash-command';
|
||||
} else if (nodeData.tool && nodeData.mode) {
|
||||
// More sophisticated logic might be needed here to differentiate backend-flow
|
||||
// For now, if tool/mode are present, assume frontend-cli or backend-flow
|
||||
// depending on whether it's a direct CLI call or a backend orchestrator call.
|
||||
// Assuming CLI tools are frontend-cli for now unless specified otherwise.
|
||||
executionType = 'frontend-cli';
|
||||
}
|
||||
|
||||
steps.push({
|
||||
id: node.id,
|
||||
name: nodeData.label || `Step ${node.id}`,
|
||||
instruction: nodeData.instruction || '',
|
||||
tool: nodeData.tool,
|
||||
mode: nodeData.mode,
|
||||
sessionStrategy: sessionStrategy,
|
||||
targetSessionKey: nodeData.targetSessionKey,
|
||||
resumeKey: nodeData.resumeKey,
|
||||
dependsOn: dependsOn,
|
||||
condition: nodeData.condition,
|
||||
contextRefs: nodeData.contextRefs,
|
||||
outputName: nodeData.outputName,
|
||||
// Error handling can be added at node level if flow nodes support it
|
||||
errorHandling: undefined,
|
||||
executionType: executionType,
|
||||
sourceNodeId: node.id,
|
||||
});
|
||||
}
|
||||
|
||||
const metadata: OrchestrationMetadata = {
|
||||
totalSteps: steps.length,
|
||||
hasParallelGroups: OrchestrationPlanBuilder.detectParallelGroups(steps), // Implement this
|
||||
estimatedComplexity: OrchestrationPlanBuilder.estimateComplexity(steps), // Implement this
|
||||
};
|
||||
|
||||
return {
|
||||
id: flow.id,
|
||||
name: flow.name,
|
||||
source: 'flow',
|
||||
sourceId: flow.id,
|
||||
steps: steps,
|
||||
variables: flow.variables,
|
||||
defaultSessionStrategy: OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
|
||||
defaultErrorHandling: OrchestrationPlanBuilder.DEFAULT_ERROR_HANDLING,
|
||||
status: 'pending',
|
||||
createdAt: flow.created_at,
|
||||
updatedAt: flow.updated_at,
|
||||
metadata: metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an IssueQueue with execution groups into an OrchestrationPlan.
|
||||
*
|
||||
* @param queue The IssueQueue object.
|
||||
* @param issues A map of issue IDs to Issue objects, needed for context.
|
||||
* @returns An OrchestrationPlan.
|
||||
*/
|
||||
public static fromQueue(queue: IssueQueue, issues: Map<string, any>): OrchestrationPlan {
|
||||
const steps: OrchestrationStep[] = [];
|
||||
const groupIdToSteps = new Map<string, string[]>(); // Maps group ID to list of step IDs in that group
|
||||
const allStepIds = new Set<string>();
|
||||
|
||||
let previousGroupStepIds: string[] = [];
|
||||
|
||||
for (const groupId of queue.execution_groups) {
|
||||
const groupItems = queue.grouped_items[groupId] || [];
|
||||
const currentGroupStepIds: string[] = [];
|
||||
const groupDependsOn: string[] = []; // Dependencies for the current group
|
||||
|
||||
if (groupId.startsWith('S*') || groupId.startsWith('P*')) {
|
||||
// Sequential or parallel groups: depend on all steps from the previous group
|
||||
groupDependsOn.push(...previousGroupStepIds);
|
||||
}
|
||||
|
||||
for (const item of groupItems) {
|
||||
const stepId = `queue-item-${item.item_id}`;
|
||||
allStepIds.add(stepId);
|
||||
currentGroupStepIds.push(stepId);
|
||||
|
||||
// Fetch the associated issue
|
||||
const issue = issues.get(item.issue_id);
|
||||
const instruction = issue ? buildQueueItemContext(item, issue) : `Execute queue item ${item.item_id}`;
|
||||
|
||||
// Queue items are typically frontend-cli executions
|
||||
const executionType: ExecutionType = 'frontend-cli';
|
||||
|
||||
steps.push({
|
||||
id: stepId,
|
||||
name: `Queue Item: ${item.item_id}`,
|
||||
instruction: instruction,
|
||||
tool: undefined, // Queue items don't typically specify tool/mode directly
|
||||
mode: undefined,
|
||||
sessionStrategy: OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
|
||||
targetSessionKey: undefined,
|
||||
resumeKey: undefined,
|
||||
dependsOn: groupDependsOn, // All items in the current group depend on the previous group's steps
|
||||
condition: undefined,
|
||||
contextRefs: undefined,
|
||||
outputName: `queueItemOutput_${item.item_id}`,
|
||||
errorHandling: undefined,
|
||||
executionType: executionType,
|
||||
sourceItemId: item.item_id,
|
||||
});
|
||||
}
|
||||
|
||||
groupIdToSteps.set(groupId, currentGroupStepIds);
|
||||
previousGroupStepIds = currentGroupStepIds;
|
||||
}
|
||||
|
||||
const metadata: OrchestrationMetadata = {
|
||||
totalSteps: steps.length,
|
||||
hasParallelGroups: queue.execution_groups.some((id) => id.startsWith('P*')),
|
||||
estimatedComplexity: OrchestrationPlanBuilder.estimateComplexity(steps),
|
||||
};
|
||||
|
||||
return {
|
||||
id: queue.id || `queue-${Date.now()}`,
|
||||
name: `Queue Plan: ${queue.id || 'Untitled'}`,
|
||||
source: 'queue',
|
||||
sourceId: queue.id,
|
||||
steps: steps,
|
||||
variables: {}, // Queue plans might not have global variables in the same way flows do
|
||||
defaultSessionStrategy: OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
|
||||
defaultErrorHandling: OrchestrationPlanBuilder.DEFAULT_ERROR_HANDLING,
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single-step OrchestrationPlan from manual user input.
|
||||
*
|
||||
* @param params Parameters for the manual orchestration.
|
||||
* @returns An OrchestrationPlan.
|
||||
*/
|
||||
public static fromManual(params: ManualOrchestrationParams): OrchestrationPlan {
|
||||
const stepId = `manual-step-${Date.now()}`;
|
||||
const manualStep: OrchestrationStep = {
|
||||
id: stepId,
|
||||
name: 'Manual Execution',
|
||||
instruction: params.prompt,
|
||||
tool: params.tool,
|
||||
mode: params.mode,
|
||||
sessionStrategy: params.sessionStrategy || OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
resumeKey: undefined,
|
||||
dependsOn: [],
|
||||
condition: undefined,
|
||||
contextRefs: undefined,
|
||||
outputName: params.outputName,
|
||||
errorHandling: params.errorHandling,
|
||||
executionType: 'frontend-cli', // Manual commands are typically frontend CLI
|
||||
sourceNodeId: undefined,
|
||||
sourceItemId: undefined,
|
||||
};
|
||||
|
||||
const metadata: OrchestrationMetadata = {
|
||||
totalSteps: 1,
|
||||
hasParallelGroups: false,
|
||||
estimatedComplexity: 'low',
|
||||
};
|
||||
|
||||
return {
|
||||
id: `manual-plan-${Date.now()}`,
|
||||
name: 'Manual Orchestration',
|
||||
source: 'manual',
|
||||
sourceId: undefined,
|
||||
steps: [manualStep],
|
||||
variables: {},
|
||||
defaultSessionStrategy: OrchestrationPlanBuilder.DEFAULT_SESSION_STRATEGY,
|
||||
defaultErrorHandling: OrchestrationPlanBuilder.DEFAULT_ERROR_HANDLING,
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to detect if the plan contains parallel groups.
|
||||
* @param steps The steps of the orchestration plan.
|
||||
* @returns True if parallel groups are detected, false otherwise.
|
||||
*/
|
||||
private static detectParallelGroups(steps: OrchestrationStep[]): boolean {
|
||||
// A simple heuristic: check if any two steps have the same 'dependsOn' set
|
||||
// but are not explicitly dependent on each other, implying they can run in parallel.
|
||||
// This is a basic check and might need refinement.
|
||||
const dependencySets = new Map<string, Set<string>>();
|
||||
for (const step of steps) {
|
||||
const depKey = JSON.stringify(step.dependsOn.sort());
|
||||
if (!dependencySets.has(depKey)) {
|
||||
dependencySets.set(depKey, new Set());
|
||||
}
|
||||
dependencySets.get(depKey)!.add(step.id);
|
||||
}
|
||||
|
||||
for (const [, stepIds] of dependencySets.entries()) {
|
||||
if (stepIds.size > 1) {
|
||||
// If multiple steps share the same dependencies, they might be parallel
|
||||
// Need to ensure they don't have implicit dependencies among themselves
|
||||
let isParallelGroup = true;
|
||||
for (const id1 of stepIds) {
|
||||
for (const id2 of stepIds) {
|
||||
if (id1 !== id2) {
|
||||
const step1 = steps.find(s => s.id === id1);
|
||||
const step2 = steps.find(s => s.id === id2);
|
||||
// If step1 depends on step2 or vice-versa, they are not parallel
|
||||
if (step1?.dependsOn.includes(id2) || step2?.dependsOn.includes(id1)) {
|
||||
isParallelGroup = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isParallelGroup) break;
|
||||
}
|
||||
if (isParallelGroup) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to estimate the complexity of the orchestration plan.
|
||||
* @param steps The steps of the orchestration plan.
|
||||
* @returns 'low', 'medium', or 'high'.
|
||||
*/
|
||||
private static estimateComplexity(steps: OrchestrationStep[]): 'low' | 'medium' | 'high' {
|
||||
if (steps.length <= 1) {
|
||||
return 'low';
|
||||
}
|
||||
// Heuristic: More steps or presence of parallel groups increases complexity
|
||||
if (steps.length > 5 || OrchestrationPlanBuilder.detectParallelGroups(steps)) {
|
||||
return 'high';
|
||||
}
|
||||
return 'medium';
|
||||
}
|
||||
}
|
||||
478
ccw/frontend/src/orchestrator/SequentialRunner.ts
Normal file
478
ccw/frontend/src/orchestrator/SequentialRunner.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
// ========================================
|
||||
// Sequential Runner
|
||||
// ========================================
|
||||
// Manages PTY session lifecycle and step-by-step command dispatch for
|
||||
// orchestration plans. Creates/reuses CLI sessions and dispatches
|
||||
// steps sequentially, resolving runtime variables between steps.
|
||||
//
|
||||
// Integration pattern:
|
||||
// 1. SequentialRunner.start() -> dispatches first step
|
||||
// 2. WebSocket CLI_COMPLETED -> useCompletionCallbackChain -> store update
|
||||
// 3. Store subscription detects step completion -> executeStep(nextStepId)
|
||||
// 4. Repeat until all steps complete
|
||||
//
|
||||
// Uses store subscription (Option B) for clean separation between
|
||||
// the callback chain (which updates the store) and the runner
|
||||
// (which reacts to store changes by dispatching the next step).
|
||||
|
||||
import type { OrchestrationPlan } from '../types/orchestrator';
|
||||
import { dispatch } from '../lib/unifiedExecutionDispatcher';
|
||||
import type { DispatchOptions } from '../lib/unifiedExecutionDispatcher';
|
||||
import { createCliSession } from '../lib/api';
|
||||
import { useOrchestratorStore } from '../stores/orchestratorStore';
|
||||
import type { OrchestrationRunState, StepRunState } from '../stores/orchestratorStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/** Configuration options for starting an orchestration plan */
|
||||
export interface StartOptions {
|
||||
/** Working directory for session creation */
|
||||
workingDir?: string;
|
||||
/** Project path for API routing */
|
||||
projectPath?: string;
|
||||
/** Execution category for tracking */
|
||||
category?: DispatchOptions['category'];
|
||||
}
|
||||
|
||||
/** Tracks active subscriptions per plan for cleanup */
|
||||
interface PlanSubscription {
|
||||
/** Zustand unsubscribe function */
|
||||
unsubscribe: () => void;
|
||||
/** The plan ID being tracked */
|
||||
planId: string;
|
||||
/** Set of step IDs that have already been dispatched (prevents double-dispatch) */
|
||||
dispatchedSteps: Set<string>;
|
||||
/** Options passed at start() for reuse during step dispatches */
|
||||
options: StartOptions;
|
||||
}
|
||||
|
||||
// ========== Module State ==========
|
||||
|
||||
/** Active subscriptions keyed by plan ID */
|
||||
const activeSubscriptions = new Map<string, PlanSubscription>();
|
||||
|
||||
// ========== Public API ==========
|
||||
|
||||
/**
|
||||
* Start executing an orchestration plan.
|
||||
*
|
||||
* 1. Registers the plan in the orchestratorStore
|
||||
* 2. Creates a new CLI session if needed (based on plan's defaultSessionStrategy)
|
||||
* 3. Subscribes to store changes for automated step advancement
|
||||
* 4. Dispatches the first ready step
|
||||
*
|
||||
* @param plan - The orchestration plan to execute
|
||||
* @param sessionKey - Optional existing session key to reuse
|
||||
* @param options - Additional options for session creation and dispatch
|
||||
*/
|
||||
export async function start(
|
||||
plan: OrchestrationPlan,
|
||||
sessionKey?: string,
|
||||
options: StartOptions = {}
|
||||
): Promise<void> {
|
||||
const store = useOrchestratorStore.getState();
|
||||
|
||||
// Clean up any existing subscription for this plan
|
||||
stop(plan.id);
|
||||
|
||||
// Resolve session key
|
||||
let resolvedSessionKey = sessionKey;
|
||||
if (!resolvedSessionKey && plan.defaultSessionStrategy === 'new_session') {
|
||||
const result = await createCliSession(
|
||||
{
|
||||
workingDir: options.workingDir,
|
||||
tool: plan.steps[0]?.tool,
|
||||
},
|
||||
options.projectPath
|
||||
);
|
||||
resolvedSessionKey = result.session.sessionKey;
|
||||
}
|
||||
|
||||
// Initialize plan in the store
|
||||
store.startOrchestration(plan, resolvedSessionKey);
|
||||
|
||||
// Subscribe to store changes for automated step advancement
|
||||
const subscription = subscribeToStepAdvancement(plan.id, options);
|
||||
activeSubscriptions.set(plan.id, subscription);
|
||||
|
||||
// Dispatch the first ready step
|
||||
const firstStepId = store.getNextReadyStep(plan.id);
|
||||
if (firstStepId) {
|
||||
subscription.dispatchedSteps.add(firstStepId);
|
||||
await executeStep(plan.id, firstStepId, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a specific step within a plan.
|
||||
*
|
||||
* 1. Resolves runtime variables in the step instruction
|
||||
* 2. Resolves contextRefs from previous step outputs
|
||||
* 3. Updates step status to 'running'
|
||||
* 4. Dispatches execution via UnifiedExecutionDispatcher
|
||||
* 5. Registers the executionId for callback chain matching
|
||||
*
|
||||
* @param planId - The plan containing the step
|
||||
* @param stepId - The step to execute
|
||||
* @param options - Dispatch options (workingDir, projectPath, etc.)
|
||||
*/
|
||||
export async function executeStep(
|
||||
planId: string,
|
||||
stepId: string,
|
||||
options: StartOptions = {}
|
||||
): Promise<void> {
|
||||
const store = useOrchestratorStore.getState();
|
||||
const runState = store.activePlans[planId];
|
||||
if (!runState) {
|
||||
console.error(`[SequentialRunner] Plan "${planId}" not found in store`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the step definition
|
||||
const step = runState.plan.steps.find((s) => s.id === stepId);
|
||||
if (!step) {
|
||||
console.error(`[SequentialRunner] Step "${stepId}" not found in plan "${planId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect previous step outputs for variable interpolation
|
||||
const stepOutputs = collectStepOutputs(runState);
|
||||
|
||||
// Resolve runtime variables in the instruction
|
||||
const resolvedInstruction = interpolateInstruction(
|
||||
step.instruction,
|
||||
runState.plan.variables,
|
||||
stepOutputs
|
||||
);
|
||||
|
||||
// Resolve contextRefs - append previous step outputs as context
|
||||
const contextSuffix = resolveContextRefs(step.contextRefs, stepOutputs);
|
||||
const finalInstruction = contextSuffix
|
||||
? `${resolvedInstruction}\n\n--- Context from previous steps ---\n${contextSuffix}`
|
||||
: resolvedInstruction;
|
||||
|
||||
// Create a modified step with the resolved instruction for dispatch
|
||||
const resolvedStep = { ...step, instruction: finalInstruction };
|
||||
|
||||
// Mark step as running
|
||||
store.updateStepStatus(planId, stepId, 'running');
|
||||
|
||||
try {
|
||||
// Dispatch via UnifiedExecutionDispatcher
|
||||
const result = await dispatch(resolvedStep, runState.sessionKey ?? '', {
|
||||
workingDir: options.workingDir,
|
||||
projectPath: options.projectPath,
|
||||
category: options.category,
|
||||
resumeKey: step.resumeKey,
|
||||
});
|
||||
|
||||
// Register executionId for callback chain matching
|
||||
store.registerExecution(planId, stepId, result.executionId);
|
||||
|
||||
// If dispatch created a new session and plan had no session, update the run state
|
||||
if (result.isNewSession && !runState.sessionKey) {
|
||||
// Update session key on the run state by re-reading store
|
||||
// The session key is now tracked on the dispatch result
|
||||
// Future steps in this plan will use this session via the store's sessionKey
|
||||
const currentState = useOrchestratorStore.getState();
|
||||
const currentRunState = currentState.activePlans[planId];
|
||||
if (currentRunState && !currentRunState.sessionKey) {
|
||||
// The store does not expose a setSessionKey action, so we rely on
|
||||
// the dispatch result's sessionKey being used by subsequent steps.
|
||||
// This is handled by resolveSessionKey in the dispatcher using
|
||||
// the step's sessionStrategy or reuse_default with the plan's sessionKey.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[SequentialRunner] Failed to dispatch step "${stepId}":`, errorMessage);
|
||||
store.updateStepStatus(planId, stepId, 'failed', { error: errorMessage });
|
||||
// Error handling (pause/skip/stop) is handled by useCompletionCallbackChain
|
||||
// but since this is a dispatch failure (not a CLI completion failure),
|
||||
// we need to apply error handling here too
|
||||
applyDispatchErrorHandling(planId, stepId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when _advanceToNextStep identifies a next step.
|
||||
* If nextStepId is not null, dispatches execution for that step.
|
||||
* If null, orchestration is complete (store already updated).
|
||||
*
|
||||
* @param planId - The plan ID
|
||||
* @param nextStepId - The next step to execute, or null if complete
|
||||
* @param options - Dispatch options
|
||||
*/
|
||||
export async function onStepAdvanced(
|
||||
planId: string,
|
||||
nextStepId: string | null,
|
||||
options: StartOptions = {}
|
||||
): Promise<void> {
|
||||
if (nextStepId) {
|
||||
await executeStep(planId, nextStepId, options);
|
||||
}
|
||||
// If null, orchestration is complete - store already updated by _advanceToNextStep
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking a plan and clean up its store subscription.
|
||||
*
|
||||
* @param planId - The plan to stop tracking
|
||||
*/
|
||||
export function stop(planId: string): void {
|
||||
const subscription = activeSubscriptions.get(planId);
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
activeSubscriptions.delete(planId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active plan subscriptions.
|
||||
*/
|
||||
export function stopAll(): void {
|
||||
for (const [planId] of activeSubscriptions) {
|
||||
stop(planId);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Variable Interpolation ==========
|
||||
|
||||
/**
|
||||
* Replace {{variableName}} placeholders in an instruction string with
|
||||
* values from the plan variables and previous step outputs.
|
||||
*
|
||||
* Supports:
|
||||
* - Simple replacement: {{variableName}} -> value from variables map
|
||||
* - Step output reference: {{stepOutputName}} -> value from step outputs
|
||||
* - Nested dot-notation: {{step1.output.field}} -> nested property access
|
||||
*
|
||||
* @param instruction - The instruction template string
|
||||
* @param variables - Plan-level variables
|
||||
* @param stepOutputs - Collected outputs from completed steps
|
||||
* @returns The interpolated instruction string
|
||||
*/
|
||||
export function interpolateInstruction(
|
||||
instruction: string,
|
||||
variables: Record<string, unknown>,
|
||||
stepOutputs: Record<string, unknown>
|
||||
): string {
|
||||
return instruction.replace(/\{\{([^}]+)\}\}/g, (_match, key: string) => {
|
||||
const trimmedKey = key.trim();
|
||||
|
||||
// Try plan variables first (simple key)
|
||||
if (trimmedKey in variables) {
|
||||
return formatValue(variables[trimmedKey]);
|
||||
}
|
||||
|
||||
// Try step outputs (simple key)
|
||||
if (trimmedKey in stepOutputs) {
|
||||
return formatValue(stepOutputs[trimmedKey]);
|
||||
}
|
||||
|
||||
// Try nested dot-notation in step outputs
|
||||
const nestedValue = resolveNestedPath(trimmedKey, stepOutputs);
|
||||
if (nestedValue !== undefined) {
|
||||
return formatValue(nestedValue);
|
||||
}
|
||||
|
||||
// Try nested dot-notation in plan variables
|
||||
const nestedVarValue = resolveNestedPath(trimmedKey, variables);
|
||||
if (nestedVarValue !== undefined) {
|
||||
return formatValue(nestedVarValue);
|
||||
}
|
||||
|
||||
// Unresolved placeholder - leave as-is
|
||||
return `{{${trimmedKey}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Internal Helpers ==========
|
||||
|
||||
/**
|
||||
* Subscribe to orchestratorStore changes for a specific plan.
|
||||
* When a step transitions to 'completed' or 'skipped', check if there is
|
||||
* a new ready step and dispatch it.
|
||||
*
|
||||
* This implements Option B (store subscription) for clean separation
|
||||
* between the callback chain (store updates) and the runner (step dispatch).
|
||||
*/
|
||||
function subscribeToStepAdvancement(
|
||||
planId: string,
|
||||
options: StartOptions
|
||||
): PlanSubscription {
|
||||
const dispatchedSteps = new Set<string>();
|
||||
|
||||
// Track previous step statuses to detect transitions
|
||||
let previousStatuses: Record<string, StepRunState> | undefined;
|
||||
|
||||
const unsubscribe = useOrchestratorStore.subscribe((state) => {
|
||||
const runState = state.activePlans[planId];
|
||||
if (!runState || runState.status !== 'running') return;
|
||||
|
||||
const currentStatuses = runState.stepStatuses;
|
||||
|
||||
// On first call, just capture the initial state
|
||||
if (!previousStatuses) {
|
||||
previousStatuses = currentStatuses;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect if any step just transitioned to a terminal state (completed/skipped)
|
||||
let hasNewCompletion = false;
|
||||
for (const [stepId, stepState] of Object.entries(currentStatuses)) {
|
||||
const prevState = previousStatuses[stepId];
|
||||
if (!prevState) continue;
|
||||
|
||||
const wasTerminal =
|
||||
prevState.status === 'completed' || prevState.status === 'skipped';
|
||||
const isTerminal =
|
||||
stepState.status === 'completed' || stepState.status === 'skipped';
|
||||
|
||||
if (!wasTerminal && isTerminal) {
|
||||
hasNewCompletion = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
previousStatuses = currentStatuses;
|
||||
|
||||
if (!hasNewCompletion) return;
|
||||
|
||||
// A step just completed - check if _advanceToNextStep was already called
|
||||
// (by useCompletionCallbackChain). We detect the next ready step.
|
||||
const store = useOrchestratorStore.getState();
|
||||
const nextStepId = store.getNextReadyStep(planId);
|
||||
|
||||
if (nextStepId && !dispatchedSteps.has(nextStepId)) {
|
||||
dispatchedSteps.add(nextStepId);
|
||||
// Dispatch asynchronously to avoid blocking the subscription callback
|
||||
onStepAdvanced(planId, nextStepId, options).catch((err) => {
|
||||
console.error(
|
||||
`[SequentialRunner] Failed to advance to step "${nextStepId}":`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
unsubscribe,
|
||||
planId,
|
||||
dispatchedSteps,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect output results from completed steps, keyed by outputName.
|
||||
*/
|
||||
function collectStepOutputs(
|
||||
runState: OrchestrationRunState
|
||||
): Record<string, unknown> {
|
||||
const outputs: Record<string, unknown> = {};
|
||||
|
||||
for (const step of runState.plan.steps) {
|
||||
if (!step.outputName) continue;
|
||||
|
||||
const stepState = runState.stepStatuses[step.id];
|
||||
if (stepState && (stepState.status === 'completed' || stepState.status === 'skipped')) {
|
||||
outputs[step.outputName] = stepState.result;
|
||||
}
|
||||
}
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve contextRefs by looking up output values from previous steps
|
||||
* and formatting them as a context string to append to the instruction.
|
||||
*/
|
||||
function resolveContextRefs(
|
||||
contextRefs: string[] | undefined,
|
||||
stepOutputs: Record<string, unknown>
|
||||
): string {
|
||||
if (!contextRefs || contextRefs.length === 0) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const ref of contextRefs) {
|
||||
const value = stepOutputs[ref];
|
||||
if (value !== undefined) {
|
||||
parts.push(`[${ref}]:\n${formatValue(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dot-notation path against an object.
|
||||
* E.g., "step1.output.field" resolves by traversing the nested structure.
|
||||
*/
|
||||
function resolveNestedPath(
|
||||
path: string,
|
||||
obj: Record<string, unknown>
|
||||
): unknown | undefined {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== 'object') return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for insertion into an instruction string.
|
||||
*/
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
// For objects/arrays, produce a JSON string
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply error handling for dispatch-level failures (not CLI completion failures).
|
||||
* This mirrors the error handling in useCompletionCallbackChain but is needed
|
||||
* when the dispatch itself fails before a CLI execution starts.
|
||||
*/
|
||||
function applyDispatchErrorHandling(planId: string, stepId: string): void {
|
||||
const store = useOrchestratorStore.getState();
|
||||
const runState = store.activePlans[planId];
|
||||
if (!runState) return;
|
||||
|
||||
const stepDef = runState.plan.steps.find((s) => s.id === stepId);
|
||||
const strategy =
|
||||
stepDef?.errorHandling?.strategy ??
|
||||
runState.plan.defaultErrorHandling.strategy ??
|
||||
'pause_on_error';
|
||||
|
||||
switch (strategy) {
|
||||
case 'pause_on_error':
|
||||
store.pauseOrchestration(planId);
|
||||
break;
|
||||
case 'skip':
|
||||
store.skipStep(planId, stepId);
|
||||
store._advanceToNextStep(planId);
|
||||
break;
|
||||
case 'stop':
|
||||
store.stopOrchestration(
|
||||
planId,
|
||||
`Dispatch failed for step "${stepId}"`
|
||||
);
|
||||
break;
|
||||
default:
|
||||
store.pauseOrchestration(planId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { OrchestrationPlanBuilder } from '../OrchestrationPlanBuilder';
|
||||
import { Flow, FlowNode, FlowEdge, PromptTemplateNodeData } from '../../types/flow';
|
||||
import { IssueQueue, QueueItem } from '../../lib/api';
|
||||
import {
|
||||
OrchestrationStep,
|
||||
ManualOrchestrationParams,
|
||||
} from '../../types/orchestrator';
|
||||
|
||||
// Mock buildQueueItemContext as it's an external dependency
|
||||
vi.mock('../../lib/queue-prompt', () => ({
|
||||
buildQueueItemContext: vi.fn((item: QueueItem, issue: any) => `Instruction for ${item.item_id} from issue ${issue?.id}`),
|
||||
}));
|
||||
|
||||
import { buildQueueItemContext } from '../../lib/queue-prompt';
|
||||
|
||||
describe('OrchestrationPlanBuilder', () => {
|
||||
const MOCKED_CREATED_AT = '2026-02-14T10:00:00.000Z';
|
||||
const MOCKED_UPDATED_AT = '2026-02-14T11:00:00.000Z';
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock Date.now() to ensure consistent IDs and timestamps
|
||||
vi.spyOn(Date, 'now').mockReturnValue(new Date(MOCKED_CREATED_AT).getTime());
|
||||
vi.spyOn(Date.prototype, 'toISOString').mockReturnValue(MOCKED_CREATED_AT);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('fromFlow', () => {
|
||||
it('should correctly convert a simple linear flow into an OrchestrationPlan', () => {
|
||||
const flow: Flow = {
|
||||
id: 'flow-123',
|
||||
name: 'Test Flow',
|
||||
description: 'A simple linear flow',
|
||||
version: '1.0.0',
|
||||
created_at: MOCKED_CREATED_AT,
|
||||
updated_at: MOCKED_UPDATED_AT,
|
||||
nodes: [
|
||||
{ id: 'nodeA', type: 'prompt-template', data: { label: 'Step A', instruction: 'Do A', outputName: 'outputA' } as PromptTemplateNodeData, position: { x: 0, y: 0 } },
|
||||
{ id: 'nodeB', type: 'prompt-template', data: { label: 'Step B', instruction: 'Do B', contextRefs: ['outputA'] } as PromptTemplateNodeData, position: { x: 1, y: 1 } },
|
||||
{ id: 'nodeC', type: 'prompt-template', data: { label: 'Step C', instruction: 'Do C' } as PromptTemplateNodeData, position: { x: 2, y: 2 } },
|
||||
] as FlowNode[],
|
||||
edges: [
|
||||
{ id: 'edge-ab', source: 'nodeA', target: 'nodeB' },
|
||||
{ id: 'edge-bc', source: 'nodeB', target: 'nodeC' },
|
||||
] as FlowEdge[],
|
||||
variables: { var1: 'value1' },
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const plan = OrchestrationPlanBuilder.fromFlow(flow);
|
||||
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan.id).toBe('flow-123');
|
||||
expect(plan.name).toBe('Test Flow');
|
||||
expect(plan.source).toBe('flow');
|
||||
expect(plan.sourceId).toBe('flow-123');
|
||||
expect(plan.variables).toEqual({ var1: 'value1' });
|
||||
expect(plan.steps).toHaveLength(3);
|
||||
expect(plan.metadata.totalSteps).toBe(3);
|
||||
expect(plan.metadata.estimatedComplexity).toBe('medium'); // 3 steps is medium
|
||||
|
||||
// Verify topological sort and dependencies
|
||||
expect(plan.steps[0].id).toBe('nodeA');
|
||||
expect(plan.steps[0].dependsOn).toEqual([]);
|
||||
expect(plan.steps[1].id).toBe('nodeB');
|
||||
expect(plan.steps[1].dependsOn).toEqual(['nodeA']);
|
||||
expect(plan.steps[2].id).toBe('nodeC');
|
||||
expect(plan.steps[2].dependsOn).toEqual(['nodeB']);
|
||||
|
||||
// Verify step details
|
||||
expect(plan.steps[0].name).toBe('Step A');
|
||||
expect(plan.steps[0].instruction).toBe('Do A');
|
||||
expect(plan.steps[0].outputName).toBe('outputA');
|
||||
expect(plan.steps[0].executionType).toBe('frontend-cli');
|
||||
|
||||
expect(plan.steps[1].name).toBe('Step B');
|
||||
expect(plan.steps[1].instruction).toBe('Do B');
|
||||
expect(plan.steps[1].contextRefs).toEqual(['outputA']);
|
||||
});
|
||||
|
||||
it('should handle a more complex flow with branching and merging', () => {
|
||||
const flow: Flow = {
|
||||
id: 'flow-complex',
|
||||
name: 'Complex Flow',
|
||||
description: 'Branching and merging flow',
|
||||
version: '1.0.0',
|
||||
created_at: MOCKED_CREATED_AT,
|
||||
updated_at: MOCKED_UPDATED_AT,
|
||||
nodes: [
|
||||
{ id: 'start', type: 'prompt-template', data: { label: 'Start', instruction: 'Start here' } as PromptTemplateNodeData, position: { x: 0, y: 0 } },
|
||||
{ id: 'branchA', type: 'prompt-template', data: { label: 'Branch A', instruction: 'Path A' } as PromptTemplateNodeData, position: { x: 1, y: 1 } },
|
||||
{ id: 'branchB', type: 'prompt-template', data: { label: 'Branch B', instruction: 'Path B' } as PromptTemplateNodeData, position: { x: 1, y: 2 } },
|
||||
{ id: 'merge', type: 'prompt-template', data: { label: 'Merge', instruction: 'Merge results' } as PromptTemplateNodeData, position: { x: 2, y: 1 } },
|
||||
{ id: 'end', type: 'prompt-template', data: { label: 'End', instruction: 'Finish' } as PromptTemplateNodeData, position: { x: 3, y: 1 } },
|
||||
] as FlowNode[],
|
||||
edges: [
|
||||
{ id: 'e-start-a', source: 'start', target: 'branchA' },
|
||||
{ id: 'e-start-b', source: 'start', target: 'branchB' },
|
||||
{ id: 'e-a-merge', source: 'branchA', target: 'merge' },
|
||||
{ id: 'e-b-merge', source: 'branchB', target: 'merge' },
|
||||
{ id: 'e-merge-end', source: 'merge', target: 'end' },
|
||||
] as FlowEdge[],
|
||||
variables: {},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const plan = OrchestrationPlanBuilder.fromFlow(flow);
|
||||
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan.steps).toHaveLength(5);
|
||||
expect(plan.metadata.totalSteps).toBe(5);
|
||||
expect(plan.metadata.hasParallelGroups).toBe(true); // branchA and branchB can run in parallel
|
||||
expect(plan.metadata.estimatedComplexity).toBe('high'); // >5 steps, or parallel groups
|
||||
|
||||
// Verify topological sort (order might vary for parallel steps, but dependencies must be correct)
|
||||
const startStep = plan.steps.find(s => s.id === 'start');
|
||||
const branchAStep = plan.steps.find(s => s.id === 'branchA');
|
||||
const branchBStep = plan.steps.find(s => s.id === 'branchB');
|
||||
const mergeStep = plan.steps.find(s => s.id === 'merge');
|
||||
const endStep = plan.steps.find(s => s.id === 'end');
|
||||
|
||||
expect(startStep?.dependsOn).toEqual([]);
|
||||
expect(branchAStep?.dependsOn).toEqual(['start']);
|
||||
expect(branchBStep?.dependsOn).toEqual(['start']);
|
||||
expect(mergeStep?.dependsOn).toEqual(expect.arrayContaining(['branchA', 'branchB']));
|
||||
expect(endStep?.dependsOn).toEqual(['merge']);
|
||||
|
||||
// Ensure 'merge' step comes after 'branchA' and 'branchB'
|
||||
const indexA = plan.steps.indexOf(branchAStep!);
|
||||
const indexB = plan.steps.indexOf(branchBStep!);
|
||||
const indexMerge = plan.steps.indexOf(mergeStep!);
|
||||
expect(indexMerge).toBeGreaterThan(indexA);
|
||||
expect(indexMerge).toBeGreaterThan(indexB);
|
||||
});
|
||||
|
||||
it('should detect cycles and throw an error', () => {
|
||||
const flow: Flow = {
|
||||
id: 'flow-cycle',
|
||||
name: 'Cyclic Flow',
|
||||
description: 'A flow with a cycle',
|
||||
version: '1.0.0',
|
||||
created_at: MOCKED_CREATED_AT,
|
||||
updated_at: MOCKED_UPDATED_AT,
|
||||
nodes: [
|
||||
{ id: 'nodeA', type: 'prompt-template', data: { label: 'A', instruction: 'Do A' } as PromptTemplateNodeData, position: { x: 0, y: 0 } },
|
||||
{ id: 'nodeB', type: 'prompt-template', data: { label: 'B', instruction: 'Do B' } as PromptTemplateNodeData, position: { x: 1, y: 1 } },
|
||||
] as FlowNode[],
|
||||
edges: [
|
||||
{ id: 'e-ab', source: 'nodeA', target: 'nodeB' },
|
||||
{ id: 'e-ba', source: 'nodeB', target: 'nodeA' }, // Cycle
|
||||
] as FlowEdge[],
|
||||
variables: {},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
expect(() => OrchestrationPlanBuilder.fromFlow(flow)).toThrow('Cycle detected in flow graph. Cannot build orchestration plan from cyclic flow.');
|
||||
});
|
||||
|
||||
it('should correctly map sessionStrategy and executionType from node data', () => {
|
||||
const flow: Flow = {
|
||||
id: 'flow-delivery',
|
||||
name: 'Delivery Flow',
|
||||
description: 'Flow with different delivery types',
|
||||
version: '1.0.0',
|
||||
created_at: MOCKED_CREATED_AT,
|
||||
updated_at: MOCKED_UPDATED_AT,
|
||||
nodes: [
|
||||
{ id: 'node1', type: 'prompt-template', data: { label: 'New Session', instruction: 'New', delivery: 'newExecution' } as PromptTemplateNodeData, position: { x: 0, y: 0 } },
|
||||
{ id: 'node2', type: 'prompt-template', data: { label: 'Specific Session', instruction: 'Specific', delivery: 'sendToSession', targetSessionKey: 'sessionX' } as PromptTemplateNodeData, position: { x: 1, y: 1 } },
|
||||
{ id: 'node3', type: 'prompt-template', data: { label: 'Slash Cmd', instruction: 'Slash', slashCommand: 'test:cmd', mode: 'mainprocess' } as PromptTemplateNodeData, position: { x: 2, y: 2 } },
|
||||
{ id: 'node4', type: 'prompt-template', data: { label: 'Frontend CLI', instruction: 'CLI', tool: 'gemini', mode: 'analysis' } as PromptTemplateNodeData, position: { x: 3, y: 3 } },
|
||||
] as FlowNode[],
|
||||
edges: [
|
||||
{ id: 'e1', source: 'node1', target: 'node2' },
|
||||
{ id: 'e2', source: 'node2', target: 'node3' },
|
||||
{ id: 'e3', source: 'node3', target: 'node4' },
|
||||
],
|
||||
variables: {},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const plan = OrchestrationPlanBuilder.fromFlow(flow);
|
||||
expect(plan.steps).toHaveLength(4);
|
||||
|
||||
expect(plan.steps[0].id).toBe('node1');
|
||||
expect(plan.steps[0].sessionStrategy).toBe('new_session');
|
||||
expect(plan.steps[0].executionType).toBe('frontend-cli'); // default as no slash command/tool specified
|
||||
|
||||
expect(plan.steps[1].id).toBe('node2');
|
||||
expect(plan.steps[1].sessionStrategy).toBe('specific_session');
|
||||
expect(plan.steps[1].targetSessionKey).toBe('sessionX');
|
||||
expect(plan.steps[1].executionType).toBe('frontend-cli');
|
||||
|
||||
expect(plan.steps[2].id).toBe('node3');
|
||||
expect(plan.steps[2].executionType).toBe('slash-command');
|
||||
|
||||
expect(plan.steps[3].id).toBe('node4');
|
||||
expect(plan.steps[3].tool).toBe('gemini');
|
||||
expect(plan.steps[3].mode).toBe('analysis');
|
||||
expect(plan.steps[3].executionType).toBe('frontend-cli');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromQueue', () => {
|
||||
it('should correctly convert an IssueQueue with S* groups into an OrchestrationPlan', () => {
|
||||
const issue1 = { id: 'issue-1', title: 'Fix bug A', description: 'desc A' };
|
||||
const issue2 = { id: 'issue-2', title: 'Implement feature B', description: 'desc B' };
|
||||
const issue3 = { id: 'issue-3', title: 'Refactor C', description: 'desc C' };
|
||||
|
||||
const item1: QueueItem = { item_id: 'qi-1', issue_id: 'issue-1', solution_id: 'sol-1', execution_group: 'S*group1', depends_on: [], status: 'pending', execution_order: 0, semantic_priority: 0 };
|
||||
const item2: QueueItem = { item_id: 'qi-2', issue_id: 'issue-1', solution_id: 'sol-1', execution_group: 'S*group1', depends_on: [], status: 'pending', execution_order: 1, semantic_priority: 0 };
|
||||
const item3: QueueItem = { item_id: 'qi-3', issue_id: 'issue-2', solution_id: 'sol-2', execution_group: 'S*group2', depends_on: [], status: 'pending', execution_order: 2, semantic_priority: 0 };
|
||||
const item4: QueueItem = { item_id: 'qi-4', issue_id: 'issue-3', solution_id: 'sol-3', execution_group: 'S*group3', depends_on: [], status: 'pending', execution_order: 3, semantic_priority: 0 };
|
||||
|
||||
const queue: IssueQueue = {
|
||||
id: 'queue-abc',
|
||||
execution_groups: ['S*group1', 'S*group2', 'S*group3'],
|
||||
grouped_items: {
|
||||
'S*group1': [item1, item2],
|
||||
'S*group2': [item3],
|
||||
'S*group3': [item4],
|
||||
},
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
const issues = new Map<string, any>();
|
||||
issues.set('issue-1', issue1);
|
||||
issues.set('issue-2', issue2);
|
||||
issues.set('issue-3', issue3);
|
||||
|
||||
const plan = OrchestrationPlanBuilder.fromQueue(queue, issues);
|
||||
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan.source).toBe('queue');
|
||||
expect(plan.sourceId).toBe('queue-abc');
|
||||
expect(plan.steps).toHaveLength(4);
|
||||
expect(plan.metadata.totalSteps).toBe(4);
|
||||
// fromQueue uses explicit P*/S* prefix check for hasParallelGroups (not DAG heuristic).
|
||||
// All groups here are S* (sequential), so hasParallelGroups is false.
|
||||
expect(plan.metadata.hasParallelGroups).toBe(false);
|
||||
// estimateComplexity uses detectParallelGroups (DAG heuristic), which sees qi-1 and qi-2
|
||||
// sharing dependsOn=[] without mutual dependencies. But estimateComplexity also checks
|
||||
// step count (<=1 => low, >5 => high), so 4 steps with no DAG-detected parallelism
|
||||
// (fromQueue passes the already-computed hasParallelGroups=false to metadata, not the
|
||||
// DAG heuristic) means medium. Actually estimateComplexity is a separate static call
|
||||
// that does its own DAG-level check.
|
||||
// With 4 steps and qi-1/qi-2 sharing empty dependsOn: detectParallelGroups returns true,
|
||||
// so estimateComplexity returns 'high'.
|
||||
expect(plan.metadata.estimatedComplexity).toBe('high');
|
||||
|
||||
// Verify sequential dependencies
|
||||
// S*group1 items have no dependencies (first group)
|
||||
expect(plan.steps.find(s => s.id === 'queue-item-qi-1')?.dependsOn).toEqual([]);
|
||||
expect(plan.steps.find(s => s.id === 'queue-item-qi-2')?.dependsOn).toEqual([]);
|
||||
|
||||
// S*group2 items depend on all items from S*group1
|
||||
expect(plan.steps.find(s => s.id === 'queue-item-qi-3')?.dependsOn).toEqual(expect.arrayContaining(['queue-item-qi-1', 'queue-item-qi-2']));
|
||||
|
||||
// S*group3 items depend on all items from S*group2
|
||||
expect(plan.steps.find(s => s.id === 'queue-item-qi-4')?.dependsOn).toEqual(['queue-item-qi-3']);
|
||||
|
||||
// Verify instruction context via mock
|
||||
const mockedBuild = vi.mocked(buildQueueItemContext);
|
||||
expect(plan.steps[0].instruction).toBe('Instruction for qi-1 from issue issue-1');
|
||||
expect(mockedBuild).toHaveBeenCalledWith(item1, issue1);
|
||||
});
|
||||
|
||||
it('should correctly convert an IssueQueue with P* groups into an OrchestrationPlan', () => {
|
||||
const issue1 = { id: 'issue-1', title: 'Fix bug A', description: 'desc A' };
|
||||
const issue2 = { id: 'issue-2', title: 'Implement feature B', description: 'desc B' };
|
||||
|
||||
const item1: QueueItem = { item_id: 'qi-1', issue_id: 'issue-1', solution_id: 'sol-1', execution_group: 'P*group1', depends_on: [], status: 'pending', execution_order: 0, semantic_priority: 0 };
|
||||
const item2: QueueItem = { item_id: 'qi-2', issue_id: 'issue-2', solution_id: 'sol-2', execution_group: 'P*group1', depends_on: [], status: 'pending', execution_order: 1, semantic_priority: 0 };
|
||||
const item3: QueueItem = { item_id: 'qi-3', issue_id: 'issue-1', solution_id: 'sol-1', execution_group: 'S*group2', depends_on: [], status: 'pending', execution_order: 2, semantic_priority: 0 };
|
||||
|
||||
const queue: IssueQueue = {
|
||||
id: 'queue-parallel',
|
||||
execution_groups: ['P*group1', 'S*group2'],
|
||||
grouped_items: {
|
||||
'P*group1': [item1, item2],
|
||||
'S*group2': [item3],
|
||||
},
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
const issues = new Map<string, any>();
|
||||
issues.set('issue-1', issue1);
|
||||
issues.set('issue-2', issue2);
|
||||
|
||||
const plan = OrchestrationPlanBuilder.fromQueue(queue, issues);
|
||||
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan.steps).toHaveLength(3);
|
||||
expect(plan.metadata.hasParallelGroups).toBe(true);
|
||||
expect(plan.metadata.estimatedComplexity).toBe('high');
|
||||
|
||||
// P*group1 items have no dependencies (first group)
|
||||
expect(plan.steps.find(s => s.id === 'queue-item-qi-1')?.dependsOn).toEqual([]);
|
||||
expect(plan.steps.find(s => s.id === 'queue-item-qi-2')?.dependsOn).toEqual([]);
|
||||
|
||||
// S*group2 items depend on all items from P*group1
|
||||
expect(plan.steps.find(s => s.id === 'queue-item-qi-3')?.dependsOn).toEqual(expect.arrayContaining(['queue-item-qi-1', 'queue-item-qi-2']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromManual', () => {
|
||||
it('should create a single-step OrchestrationPlan from manual input', () => {
|
||||
const params: ManualOrchestrationParams = {
|
||||
prompt: 'Analyze current directory',
|
||||
tool: 'gemini',
|
||||
mode: 'analysis',
|
||||
sessionStrategy: 'new_session',
|
||||
outputName: 'analysisResult',
|
||||
errorHandling: { strategy: 'stop', maxRetries: 1, retryDelayMs: 100 },
|
||||
};
|
||||
|
||||
const plan = OrchestrationPlanBuilder.fromManual(params);
|
||||
|
||||
expect(plan).toBeDefined();
|
||||
expect(plan.id).toMatch(/^manual-plan-/);
|
||||
expect(plan.name).toBe('Manual Orchestration');
|
||||
expect(plan.source).toBe('manual');
|
||||
expect(plan.steps).toHaveLength(1);
|
||||
expect(plan.metadata.totalSteps).toBe(1);
|
||||
expect(plan.metadata.hasParallelGroups).toBe(false);
|
||||
expect(plan.metadata.estimatedComplexity).toBe('low');
|
||||
|
||||
const step = plan.steps[0];
|
||||
expect(step.id).toMatch(/^manual-step-/);
|
||||
expect(step.name).toBe('Manual Execution');
|
||||
expect(step.instruction).toBe('Analyze current directory');
|
||||
expect(step.tool).toBe('gemini');
|
||||
expect(step.mode).toBe('analysis');
|
||||
expect(step.sessionStrategy).toBe('new_session');
|
||||
expect(step.targetSessionKey).toBeUndefined();
|
||||
expect(step.dependsOn).toEqual([]);
|
||||
expect(step.outputName).toBe('analysisResult');
|
||||
expect(step.errorHandling).toEqual({ strategy: 'stop', maxRetries: 1, retryDelayMs: 100 });
|
||||
expect(step.executionType).toBe('frontend-cli');
|
||||
});
|
||||
|
||||
it('should use default session strategy and error handling if not provided', () => {
|
||||
const params: ManualOrchestrationParams = {
|
||||
prompt: 'Simple command',
|
||||
};
|
||||
|
||||
const plan = OrchestrationPlanBuilder.fromManual(params);
|
||||
const step = plan.steps[0];
|
||||
|
||||
expect(step.sessionStrategy).toBe('reuse_default');
|
||||
expect(step.errorHandling).toBeUndefined(); // Should be undefined if not explicitly set for step
|
||||
expect(plan.defaultSessionStrategy).toBe('reuse_default');
|
||||
expect(plan.defaultErrorHandling).toEqual({ strategy: 'pause_on_error', maxRetries: 0, retryDelayMs: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility methods', () => {
|
||||
it('should correctly detect parallel groups', () => {
|
||||
// Linear steps, no parallel
|
||||
const linearSteps: OrchestrationStep[] = [
|
||||
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
|
||||
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['2'], executionType: 'frontend-cli' },
|
||||
];
|
||||
expect((OrchestrationPlanBuilder as any).detectParallelGroups(linearSteps)).toBe(false);
|
||||
|
||||
// Parallel steps (2 and 3 depend on 1, but not on each other)
|
||||
const parallelSteps: OrchestrationStep[] = [
|
||||
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
|
||||
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
];
|
||||
expect((OrchestrationPlanBuilder as any).detectParallelGroups(parallelSteps)).toBe(true);
|
||||
|
||||
// Complex parallel scenario
|
||||
const complexParallelSteps: OrchestrationStep[] = [
|
||||
{ id: 'A', name: 'sA', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
|
||||
{ id: 'B', name: 'sB', instruction: 'i', dependsOn: ['A'], executionType: 'frontend-cli' },
|
||||
{ id: 'C', name: 'sC', instruction: 'i', dependsOn: ['A'], executionType: 'frontend-cli' },
|
||||
{ id: 'D', name: 'sD', instruction: 'i', dependsOn: ['B', 'C'], executionType: 'frontend-cli' },
|
||||
];
|
||||
expect((OrchestrationPlanBuilder as any).detectParallelGroups(complexParallelSteps)).toBe(true);
|
||||
|
||||
// Parallel steps with some implicit dependencies (not strictly parallel)
|
||||
const nonStrictlyParallel: OrchestrationStep[] = [
|
||||
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
|
||||
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
{ id: '4', name: 's4', instruction: 'i', dependsOn: ['2'], executionType: 'frontend-cli' },
|
||||
];
|
||||
expect((OrchestrationPlanBuilder as any).detectParallelGroups(nonStrictlyParallel)).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly estimate complexity', () => {
|
||||
const stepsLow: OrchestrationStep[] = [
|
||||
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
|
||||
];
|
||||
expect((OrchestrationPlanBuilder as any).estimateComplexity(stepsLow)).toBe('low');
|
||||
|
||||
const stepsMedium: OrchestrationStep[] = [
|
||||
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
|
||||
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['2'], executionType: 'frontend-cli' },
|
||||
{ id: '4', name: 's4', instruction: 'i', dependsOn: ['3'], executionType: 'frontend-cli' },
|
||||
{ id: '5', name: 's5', instruction: 'i', dependsOn: ['4'], executionType: 'frontend-cli' },
|
||||
];
|
||||
expect((OrchestrationPlanBuilder as any).estimateComplexity(stepsMedium)).toBe('medium');
|
||||
|
||||
const stepsHighByCount: OrchestrationStep[] = [
|
||||
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
|
||||
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['2'], executionType: 'frontend-cli' },
|
||||
{ id: '4', name: 's4', instruction: 'i', dependsOn: ['3'], executionType: 'frontend-cli' },
|
||||
{ id: '5', name: 's5', instruction: 'i', dependsOn: ['4'], executionType: 'frontend-cli' },
|
||||
{ id: '6', name: 's6', instruction: 'i', dependsOn: ['5'], executionType: 'frontend-cli' },
|
||||
];
|
||||
expect((OrchestrationPlanBuilder as any).estimateComplexity(stepsHighByCount)).toBe('high');
|
||||
|
||||
const stepsHighByParallel: OrchestrationStep[] = [
|
||||
{ id: '1', name: 's1', instruction: 'i', dependsOn: [], executionType: 'frontend-cli' },
|
||||
{ id: '2', name: 's2', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
{ id: '3', name: 's3', instruction: 'i', dependsOn: ['1'], executionType: 'frontend-cli' },
|
||||
];
|
||||
expect((OrchestrationPlanBuilder as any).estimateComplexity(stepsHighByParallel)).toBe('high');
|
||||
});
|
||||
});
|
||||
});
|
||||
18
ccw/frontend/src/orchestrator/index.ts
Normal file
18
ccw/frontend/src/orchestrator/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// ========================================
|
||||
// Orchestrator Module
|
||||
// ========================================
|
||||
// Barrel exports for the orchestration system.
|
||||
//
|
||||
// OrchestrationPlanBuilder: Builds plans from Flow, Queue, or Manual input
|
||||
// SequentialRunner: Manages PTY session lifecycle and step-by-step dispatch
|
||||
|
||||
export { OrchestrationPlanBuilder } from './OrchestrationPlanBuilder';
|
||||
export {
|
||||
start,
|
||||
executeStep,
|
||||
onStepAdvanced,
|
||||
stop,
|
||||
stopAll,
|
||||
interpolateInstruction,
|
||||
} from './SequentialRunner';
|
||||
export type { StartOptions } from './SequentialRunner';
|
||||
@@ -79,41 +79,65 @@ function FixProgressCarousel({ sessionId }: { sessionId: string }) {
|
||||
const [currentSlide, setCurrentSlide] = React.useState(0);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
// Fetch fix progress data
|
||||
const fetchFixProgress = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/fix-progress?sessionId=${encodeURIComponent(sessionId)}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setFixProgressData(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setFixProgressData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch fix progress:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Poll for fix progress updates
|
||||
// Sequential polling with AbortController — no concurrent requests possible
|
||||
React.useEffect(() => {
|
||||
fetchFixProgress();
|
||||
const abortController = new AbortController();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
let errorCount = 0;
|
||||
|
||||
// Stop polling if phase is completion
|
||||
if (fixProgressData?.phase === 'completion') {
|
||||
return;
|
||||
}
|
||||
const poll = async () => {
|
||||
if (stopped) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchFixProgress();
|
||||
}, 5000);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/fix-progress?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
if (!response.ok) {
|
||||
errorCount += 1;
|
||||
if (response.status === 404 || errorCount >= 3) {
|
||||
stopped = true;
|
||||
setFixProgressData(null);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
errorCount = 0;
|
||||
const data = await response.json();
|
||||
setFixProgressData(data);
|
||||
if (data?.phase === 'completion') {
|
||||
stopped = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (abortController.signal.aborted) return;
|
||||
errorCount += 1;
|
||||
if (errorCount >= 3) {
|
||||
stopped = true;
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchFixProgress, fixProgressData?.phase]);
|
||||
// Schedule next poll only after current request completes
|
||||
if (!stopped) {
|
||||
timeoutId = setTimeout(poll, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
abortController.abort();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// Navigate carousel
|
||||
const navigateSlide = (direction: 'prev' | 'next' | number) => {
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
// ========================================
|
||||
// TeamPage
|
||||
// ========================================
|
||||
// Main page for team execution visualization
|
||||
// Main page for team execution - list/detail dual view with tabbed detail
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Package, MessageSquare } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
|
||||
import { useTeamStore } from '@/stores/teamStore';
|
||||
import { useTeams, useTeamMessages, useTeamStatus } from '@/hooks/useTeamData';
|
||||
import { TeamEmptyState } from '@/components/team/TeamEmptyState';
|
||||
import type { TeamDetailTab } from '@/stores/teamStore';
|
||||
import { useTeamMessages, useTeamStatus } from '@/hooks/useTeamData';
|
||||
import { TeamHeader } from '@/components/team/TeamHeader';
|
||||
import { TeamPipeline } from '@/components/team/TeamPipeline';
|
||||
import { TeamMembersPanel } from '@/components/team/TeamMembersPanel';
|
||||
import { TeamMessageFeed } from '@/components/team/TeamMessageFeed';
|
||||
import { TeamArtifacts } from '@/components/team/TeamArtifacts';
|
||||
import { TeamListView } from '@/components/team/TeamListView';
|
||||
|
||||
export function TeamPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
selectedTeam,
|
||||
setSelectedTeam,
|
||||
viewMode,
|
||||
autoRefresh,
|
||||
toggleAutoRefresh,
|
||||
messageFilter,
|
||||
@@ -27,89 +29,92 @@ export function TeamPage() {
|
||||
clearMessageFilter,
|
||||
timelineExpanded,
|
||||
setTimelineExpanded,
|
||||
detailTab,
|
||||
setDetailTab,
|
||||
backToList,
|
||||
} = useTeamStore();
|
||||
|
||||
// Data hooks
|
||||
const { teams, isLoading: teamsLoading } = useTeams();
|
||||
// Data hooks (only active in detail mode)
|
||||
const { messages, total: messageTotal } = useTeamMessages(
|
||||
selectedTeam,
|
||||
viewMode === 'detail' ? selectedTeam : null,
|
||||
messageFilter
|
||||
);
|
||||
const { members, totalMessages } = useTeamStatus(selectedTeam);
|
||||
const { members, totalMessages } = useTeamStatus(
|
||||
viewMode === 'detail' ? selectedTeam : null
|
||||
);
|
||||
|
||||
// Auto-select first team if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedTeam && teams.length > 0) {
|
||||
setSelectedTeam(teams[0].name);
|
||||
}
|
||||
}, [selectedTeam, teams, setSelectedTeam]);
|
||||
|
||||
// Show empty state when no teams exist
|
||||
if (!teamsLoading && teams.length === 0) {
|
||||
// List view
|
||||
if (viewMode === 'list' || !selectedTeam) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Users className="w-5 h-5" />
|
||||
<h1 className="text-xl font-semibold">{formatMessage({ id: 'team.title' })}</h1>
|
||||
</div>
|
||||
<TeamEmptyState />
|
||||
<TeamListView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{
|
||||
value: 'artifacts',
|
||||
label: formatMessage({ id: 'team.tabs.artifacts' }),
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: 'messages',
|
||||
label: formatMessage({ id: 'team.tabs.messages' }),
|
||||
icon: <MessageSquare className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
// Detail view
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
<h1 className="text-xl font-semibold">{formatMessage({ id: 'team.title' })}</h1>
|
||||
</div>
|
||||
|
||||
{/* Team Header: selector + stats + controls */}
|
||||
{/* Detail Header: back button + team name + stats + controls */}
|
||||
<TeamHeader
|
||||
teams={teams}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={setSelectedTeam}
|
||||
onBack={backToList}
|
||||
members={members}
|
||||
totalMessages={totalMessages}
|
||||
autoRefresh={autoRefresh}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
/>
|
||||
|
||||
{selectedTeam ? (
|
||||
<>
|
||||
{/* Main content grid: Pipeline (left) + Members (right) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Pipeline visualization */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="p-4">
|
||||
<TeamPipeline messages={messages} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Overview: Pipeline + Members (always visible) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2 flex flex-col">
|
||||
<CardContent className="p-4 flex-1">
|
||||
<TeamPipeline messages={messages} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TeamMembersPanel members={members} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Members panel */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TeamMembersPanel members={members} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Tab Navigation: Artifacts / Messages */}
|
||||
<TabsNavigation
|
||||
value={detailTab}
|
||||
onValueChange={(v) => setDetailTab(v as TeamDetailTab)}
|
||||
tabs={tabs}
|
||||
/>
|
||||
|
||||
{/* Message timeline */}
|
||||
<TeamMessageFeed
|
||||
messages={messages}
|
||||
total={messageTotal}
|
||||
filter={messageFilter}
|
||||
onFilterChange={setMessageFilter}
|
||||
onClearFilter={clearMessageFilter}
|
||||
expanded={timelineExpanded}
|
||||
onExpandedChange={setTimelineExpanded}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{formatMessage({ id: 'team.noTeamSelected' })}
|
||||
</div>
|
||||
{/* Artifacts Tab */}
|
||||
{detailTab === 'artifacts' && (
|
||||
<TeamArtifacts messages={messages} />
|
||||
)}
|
||||
|
||||
{/* Messages Tab */}
|
||||
{detailTab === 'messages' && (
|
||||
<TeamMessageFeed
|
||||
messages={messages}
|
||||
total={messageTotal}
|
||||
filter={messageFilter}
|
||||
onFilterChange={setMessageFilter}
|
||||
onClearFilter={clearMessageFilter}
|
||||
expanded={timelineExpanded}
|
||||
onExpandedChange={setTimelineExpanded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -356,15 +356,21 @@ export const useCliStreamStore = create<CliStreamState>()(
|
||||
// Also update in executions
|
||||
const state = get();
|
||||
if (state.executions[executionId]) {
|
||||
set((state) => ({
|
||||
executions: {
|
||||
...state.executions,
|
||||
[executionId]: {
|
||||
...state.executions[executionId],
|
||||
output: [...state.executions[executionId].output, line],
|
||||
set((state) => {
|
||||
const currentOutput = state.executions[executionId].output;
|
||||
const updatedOutput = [...currentOutput, line];
|
||||
return {
|
||||
executions: {
|
||||
...state.executions,
|
||||
[executionId]: {
|
||||
...state.executions[executionId],
|
||||
output: updatedOutput.length > MAX_OUTPUT_LINES
|
||||
? updatedOutput.slice(-MAX_OUTPUT_LINES)
|
||||
: updatedOutput,
|
||||
},
|
||||
},
|
||||
},
|
||||
}), false, 'cliStream/updateExecutionOutput');
|
||||
};
|
||||
}, false, 'cliStream/updateExecutionOutput');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -529,11 +535,14 @@ export const useCliStreamStore = create<CliStreamState>()(
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Stable empty array to avoid new references */
|
||||
const EMPTY_OUTPUTS: CliOutputLine[] = [];
|
||||
|
||||
/**
|
||||
* Selector for getting outputs by execution ID
|
||||
*/
|
||||
export const selectOutputs = (state: CliStreamState, executionId: string) =>
|
||||
state.outputs[executionId] || [];
|
||||
state.outputs[executionId] || EMPTY_OUTPUTS;
|
||||
|
||||
/**
|
||||
* Selector for getting addOutput action
|
||||
|
||||
@@ -464,6 +464,7 @@ export const useExecutionStore = create<ExecutionStore>()(
|
||||
);
|
||||
|
||||
// Selectors for common access patterns
|
||||
const EMPTY_TOOL_CALLS: never[] = [];
|
||||
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
|
||||
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
|
||||
export const selectLogs = (state: ExecutionStore) => state.logs;
|
||||
@@ -474,7 +475,7 @@ export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollL
|
||||
export const selectNodeOutputs = (state: ExecutionStore, nodeId: string) =>
|
||||
state.nodeOutputs[nodeId];
|
||||
export const selectNodeToolCalls = (state: ExecutionStore, nodeId: string) =>
|
||||
state.nodeToolCalls[nodeId] || [];
|
||||
state.nodeToolCalls[nodeId] || EMPTY_TOOL_CALLS;
|
||||
export const selectSelectedNodeId = (state: ExecutionStore) => state.selectedNodeId;
|
||||
|
||||
// Helper to check if execution is active
|
||||
@@ -489,6 +490,6 @@ export const selectNodeStatus = (nodeId: string) => (state: ExecutionStore) => {
|
||||
|
||||
// Helper to get selected node's tool calls
|
||||
export const selectSelectedNodeToolCalls = (state: ExecutionStore) => {
|
||||
if (!state.selectedNodeId) return [];
|
||||
return state.nodeToolCalls[state.selectedNodeId] || [];
|
||||
if (!state.selectedNodeId) return EMPTY_TOOL_CALLS;
|
||||
return state.nodeToolCalls[state.selectedNodeId] || EMPTY_TOOL_CALLS;
|
||||
};
|
||||
|
||||
@@ -111,6 +111,18 @@ export {
|
||||
selectHasActiveExecution,
|
||||
} from './queueExecutionStore';
|
||||
|
||||
// Orchestrator Store
|
||||
export {
|
||||
useOrchestratorStore,
|
||||
selectActivePlans,
|
||||
selectPlan,
|
||||
selectStepStatuses,
|
||||
selectStepRunState,
|
||||
selectHasRunningPlan,
|
||||
selectActivePlanCount,
|
||||
selectPlanStepByExecutionId,
|
||||
} from './orchestratorStore';
|
||||
|
||||
// Terminal Panel Store Types
|
||||
export type {
|
||||
PanelView,
|
||||
@@ -131,6 +143,15 @@ export type {
|
||||
QueueExecutionStore,
|
||||
} from './queueExecutionStore';
|
||||
|
||||
// Orchestrator Store Types
|
||||
export type {
|
||||
StepRunState,
|
||||
OrchestrationRunState,
|
||||
OrchestratorState,
|
||||
OrchestratorActions,
|
||||
OrchestratorStore,
|
||||
} from './orchestratorStore';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
// App Store Types
|
||||
|
||||
533
ccw/frontend/src/stores/orchestratorStore.ts
Normal file
533
ccw/frontend/src/stores/orchestratorStore.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
// ========================================
|
||||
// Orchestrator Store
|
||||
// ========================================
|
||||
// Zustand store for orchestration plan execution state machine.
|
||||
// Manages multiple concurrent orchestration plans, step lifecycle,
|
||||
// and execution-to-step mapping for WebSocket callback chain matching.
|
||||
//
|
||||
// NOTE: This is SEPARATE from executionStore.ts (which handles Flow DAG
|
||||
// execution visualization). This store manages the plan-level state machine
|
||||
// for automated step advancement.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
OrchestrationPlan,
|
||||
OrchestrationStatus,
|
||||
StepStatus,
|
||||
} from '../types/orchestrator';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/** Runtime state for a single orchestration step */
|
||||
export interface StepRunState {
|
||||
/** Current step status */
|
||||
status: StepStatus;
|
||||
/** CLI execution ID assigned when the step starts executing */
|
||||
executionId?: string;
|
||||
/** Step execution result (populated on completion) */
|
||||
result?: unknown;
|
||||
/** Error message (populated on failure) */
|
||||
error?: string;
|
||||
/** ISO timestamp when step started executing */
|
||||
startedAt?: string;
|
||||
/** ISO timestamp when step completed/failed */
|
||||
completedAt?: string;
|
||||
/** Number of retry attempts for this step */
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
/** Runtime state for an entire orchestration plan */
|
||||
export interface OrchestrationRunState {
|
||||
/** The orchestration plan definition */
|
||||
plan: OrchestrationPlan;
|
||||
/** Current overall status of the plan */
|
||||
status: OrchestrationStatus;
|
||||
/** Index of the current step being executed (for sequential tracking) */
|
||||
currentStepIndex: number;
|
||||
/** Per-step runtime state keyed by step ID */
|
||||
stepStatuses: Record<string, StepRunState>;
|
||||
/** Maps executionId -> stepId for callback chain matching */
|
||||
executionIdMap: Record<string, string>;
|
||||
/** Optional session key for session-scoped orchestration */
|
||||
sessionKey?: string;
|
||||
/** Error message if the plan itself failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OrchestratorState {
|
||||
/** All active orchestration plans keyed by plan ID */
|
||||
activePlans: Record<string, OrchestrationRunState>;
|
||||
}
|
||||
|
||||
export interface OrchestratorActions {
|
||||
/** Initialize and start an orchestration plan */
|
||||
startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => void;
|
||||
/** Pause a running orchestration */
|
||||
pauseOrchestration: (planId: string) => void;
|
||||
/** Resume a paused orchestration */
|
||||
resumeOrchestration: (planId: string) => void;
|
||||
/** Stop an orchestration (marks as failed) */
|
||||
stopOrchestration: (planId: string, error?: string) => void;
|
||||
/** Update a step's status with optional result or error */
|
||||
updateStepStatus: (
|
||||
planId: string,
|
||||
stepId: string,
|
||||
status: StepStatus,
|
||||
result?: { data?: unknown; error?: string }
|
||||
) => void;
|
||||
/** Register an execution ID mapping for callback chain matching */
|
||||
registerExecution: (planId: string, stepId: string, executionId: string) => void;
|
||||
/** Retry a failed step (reset to pending, increment retryCount) */
|
||||
retryStep: (planId: string, stepId: string) => void;
|
||||
/** Skip a step (mark as skipped, treated as completed for dependency resolution) */
|
||||
skipStep: (planId: string, stepId: string) => void;
|
||||
/**
|
||||
* Internal: Find and return the next ready step ID.
|
||||
* Does NOT execute the step - only identifies it and updates currentStepIndex.
|
||||
* If no steps remain, marks plan as completed.
|
||||
* Returns the step ID if found, null otherwise.
|
||||
*/
|
||||
_advanceToNextStep: (planId: string) => string | null;
|
||||
/**
|
||||
* Pure getter: Find the next step whose dependsOn are all completed/skipped.
|
||||
* Returns the step ID or null if none are ready.
|
||||
*/
|
||||
getNextReadyStep: (planId: string) => string | null;
|
||||
/** Remove a completed/failed plan from active tracking */
|
||||
removePlan: (planId: string) => void;
|
||||
/** Clear all plans */
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
export type OrchestratorStore = OrchestratorState & OrchestratorActions;
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* Check if a step's dependencies are all satisfied (completed or skipped).
|
||||
*/
|
||||
function areDependenciesSatisfied(
|
||||
step: { dependsOn: string[] },
|
||||
stepStatuses: Record<string, StepRunState>
|
||||
): boolean {
|
||||
return step.dependsOn.every((depId) => {
|
||||
const depState = stepStatuses[depId];
|
||||
return depState && (depState.status === 'completed' || depState.status === 'skipped');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next ready step from a plan's steps that is pending and has all deps satisfied.
|
||||
*/
|
||||
function findNextReadyStep(
|
||||
plan: OrchestrationPlan,
|
||||
stepStatuses: Record<string, StepRunState>
|
||||
): string | null {
|
||||
for (const step of plan.steps) {
|
||||
const state = stepStatuses[step.id];
|
||||
if (state && state.status === 'pending' && areDependenciesSatisfied(step, stepStatuses)) {
|
||||
return step.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all steps are in a terminal state (completed, skipped, or failed).
|
||||
*/
|
||||
function areAllStepsTerminal(stepStatuses: Record<string, StepRunState>): boolean {
|
||||
return Object.values(stepStatuses).every(
|
||||
(s) => s.status === 'completed' || s.status === 'skipped' || s.status === 'failed'
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
const initialState: OrchestratorState = {
|
||||
activePlans: {},
|
||||
};
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
export const useOrchestratorStore = create<OrchestratorStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Plan Lifecycle ==========
|
||||
|
||||
startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => {
|
||||
// Initialize all step statuses as pending
|
||||
const stepStatuses: Record<string, StepRunState> = {};
|
||||
for (const step of plan.steps) {
|
||||
stepStatuses[step.id] = {
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const runState: OrchestrationRunState = {
|
||||
plan,
|
||||
status: 'running',
|
||||
currentStepIndex: 0,
|
||||
stepStatuses,
|
||||
executionIdMap: {},
|
||||
sessionKey,
|
||||
};
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[plan.id]: runState,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'startOrchestration'
|
||||
);
|
||||
},
|
||||
|
||||
pauseOrchestration: (planId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing || existing.status !== 'running') return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
status: 'paused',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'pauseOrchestration'
|
||||
);
|
||||
},
|
||||
|
||||
resumeOrchestration: (planId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing || existing.status !== 'paused') return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
status: 'running',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'resumeOrchestration'
|
||||
);
|
||||
},
|
||||
|
||||
stopOrchestration: (planId: string, error?: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
status: 'failed',
|
||||
error: error ?? 'Orchestration stopped by user',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'stopOrchestration'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Step State Updates ==========
|
||||
|
||||
updateStepStatus: (
|
||||
planId: string,
|
||||
stepId: string,
|
||||
status: StepStatus,
|
||||
result?: { data?: unknown; error?: string }
|
||||
) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
const stepState = existing.stepStatuses[stepId];
|
||||
if (!stepState) return state;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const isStarting = status === 'running';
|
||||
const isTerminal =
|
||||
status === 'completed' || status === 'failed' || status === 'skipped';
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
stepStatuses: {
|
||||
...existing.stepStatuses,
|
||||
[stepId]: {
|
||||
...stepState,
|
||||
status,
|
||||
startedAt: isStarting ? now : stepState.startedAt,
|
||||
completedAt: isTerminal ? now : stepState.completedAt,
|
||||
result: result?.data ?? stepState.result,
|
||||
error: result?.error ?? stepState.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'updateStepStatus'
|
||||
);
|
||||
},
|
||||
|
||||
registerExecution: (planId: string, stepId: string, executionId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
executionIdMap: {
|
||||
...existing.executionIdMap,
|
||||
[executionId]: stepId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'registerExecution'
|
||||
);
|
||||
},
|
||||
|
||||
retryStep: (planId: string, stepId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
const stepState = existing.stepStatuses[stepId];
|
||||
if (!stepState) return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
status: 'running',
|
||||
stepStatuses: {
|
||||
...existing.stepStatuses,
|
||||
[stepId]: {
|
||||
...stepState,
|
||||
status: 'pending',
|
||||
error: undefined,
|
||||
result: undefined,
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
retryCount: stepState.retryCount + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'retryStep'
|
||||
);
|
||||
},
|
||||
|
||||
skipStep: (planId: string, stepId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
const stepState = existing.stepStatuses[stepId];
|
||||
if (!stepState) return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
stepStatuses: {
|
||||
...existing.stepStatuses,
|
||||
[stepId]: {
|
||||
...stepState,
|
||||
status: 'skipped',
|
||||
completedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'skipStep'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Step Advancement ==========
|
||||
|
||||
_advanceToNextStep: (planId: string): string | null => {
|
||||
const state = get();
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing || existing.status !== 'running') return null;
|
||||
|
||||
// Find the next step that is pending with all dependencies satisfied
|
||||
const nextStepId = findNextReadyStep(existing.plan, existing.stepStatuses);
|
||||
|
||||
if (nextStepId) {
|
||||
// Update currentStepIndex to match the found step
|
||||
const stepIndex = existing.plan.steps.findIndex((s) => s.id === nextStepId);
|
||||
|
||||
set(
|
||||
(prevState) => {
|
||||
const plan = prevState.activePlans[planId];
|
||||
if (!plan) return prevState;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...prevState.activePlans,
|
||||
[planId]: {
|
||||
...plan,
|
||||
currentStepIndex: stepIndex >= 0 ? stepIndex : plan.currentStepIndex,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'_advanceToNextStep'
|
||||
);
|
||||
|
||||
return nextStepId;
|
||||
}
|
||||
|
||||
// No pending steps found - check if all are terminal
|
||||
if (areAllStepsTerminal(existing.stepStatuses)) {
|
||||
// Check if any step failed (and was not skipped)
|
||||
const hasFailed = Object.values(existing.stepStatuses).some(
|
||||
(s) => s.status === 'failed'
|
||||
);
|
||||
|
||||
set(
|
||||
(prevState) => {
|
||||
const plan = prevState.activePlans[planId];
|
||||
if (!plan) return prevState;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...prevState.activePlans,
|
||||
[planId]: {
|
||||
...plan,
|
||||
status: hasFailed ? 'failed' : 'completed',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'_advanceToNextStep/complete'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
getNextReadyStep: (planId: string): string | null => {
|
||||
const state = get();
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing || existing.status !== 'running') return null;
|
||||
|
||||
return findNextReadyStep(existing.plan, existing.stepStatuses);
|
||||
},
|
||||
|
||||
// ========== Cleanup ==========
|
||||
|
||||
removePlan: (planId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const { [planId]: _removed, ...remaining } = state.activePlans;
|
||||
return { activePlans: remaining };
|
||||
},
|
||||
false,
|
||||
'removePlan'
|
||||
);
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set(initialState, false, 'clearAll');
|
||||
},
|
||||
}),
|
||||
{ name: 'OrchestratorStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Select all active plans */
|
||||
export const selectActivePlans = (state: OrchestratorStore) => state.activePlans;
|
||||
|
||||
/** Select a specific plan by ID */
|
||||
export const selectPlan =
|
||||
(planId: string) =>
|
||||
(state: OrchestratorStore): OrchestrationRunState | undefined =>
|
||||
state.activePlans[planId];
|
||||
|
||||
/** Select the step statuses for a plan */
|
||||
export const selectStepStatuses =
|
||||
(planId: string) =>
|
||||
(state: OrchestratorStore): Record<string, StepRunState> | undefined =>
|
||||
state.activePlans[planId]?.stepStatuses;
|
||||
|
||||
/** Select a specific step's run state */
|
||||
export const selectStepRunState =
|
||||
(planId: string, stepId: string) =>
|
||||
(state: OrchestratorStore): StepRunState | undefined =>
|
||||
state.activePlans[planId]?.stepStatuses[stepId];
|
||||
|
||||
/** Check if any plan is currently running */
|
||||
export const selectHasRunningPlan = (state: OrchestratorStore): boolean =>
|
||||
Object.values(state.activePlans).some((p) => p.status === 'running');
|
||||
|
||||
/** Get the count of active (non-terminal) plans */
|
||||
export const selectActivePlanCount = (state: OrchestratorStore): number =>
|
||||
Object.values(state.activePlans).filter(
|
||||
(p) => p.status === 'running' || p.status === 'paused'
|
||||
).length;
|
||||
|
||||
/** Look up which plan and step an executionId belongs to */
|
||||
export const selectPlanStepByExecutionId =
|
||||
(executionId: string) =>
|
||||
(
|
||||
state: OrchestratorStore
|
||||
): { planId: string; stepId: string } | undefined => {
|
||||
for (const [planId, runState] of Object.entries(state.activePlans)) {
|
||||
const stepId = runState.executionIdMap[executionId];
|
||||
if (stepId) {
|
||||
return { planId, stepId };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -157,21 +157,33 @@ export const useQueueExecutionStore = create<QueueExecutionStore>()(
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Stable empty array to avoid new references */
|
||||
const EMPTY_EXECUTIONS: QueueExecution[] = [];
|
||||
|
||||
/** Select all executions as a record */
|
||||
export const selectQueueExecutions = (state: QueueExecutionStore) => state.executions;
|
||||
|
||||
/** Select only currently running executions */
|
||||
/**
|
||||
* Select only currently running executions.
|
||||
* WARNING: Returns new array each call — use with useMemo in components.
|
||||
*/
|
||||
export const selectActiveExecutions = (state: QueueExecutionStore): QueueExecution[] => {
|
||||
return Object.values(state.executions).filter((exec) => exec.status === 'running');
|
||||
const all = Object.values(state.executions);
|
||||
const running = all.filter((exec) => exec.status === 'running');
|
||||
return running.length === 0 ? EMPTY_EXECUTIONS : running;
|
||||
};
|
||||
|
||||
/** Select executions for a specific queue item */
|
||||
/**
|
||||
* Select executions for a specific queue item.
|
||||
* WARNING: Returns new array each call — use with useMemo in components.
|
||||
*/
|
||||
export const selectByQueueItem =
|
||||
(queueItemId: string) =>
|
||||
(state: QueueExecutionStore): QueueExecution[] => {
|
||||
return Object.values(state.executions).filter(
|
||||
const matched = Object.values(state.executions).filter(
|
||||
(exec) => exec.queueItemId === queueItemId
|
||||
);
|
||||
return matched.length === 0 ? EMPTY_EXECUTIONS : matched;
|
||||
};
|
||||
|
||||
/** Compute execution statistics by status */
|
||||
|
||||
@@ -7,16 +7,28 @@ import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type { TeamMessageFilter } from '@/types/team';
|
||||
|
||||
export type TeamDetailTab = 'artifacts' | 'messages';
|
||||
|
||||
interface TeamStore {
|
||||
selectedTeam: string | null;
|
||||
autoRefresh: boolean;
|
||||
messageFilter: TeamMessageFilter;
|
||||
timelineExpanded: boolean;
|
||||
viewMode: 'list' | 'detail';
|
||||
locationFilter: 'active' | 'archived' | 'all';
|
||||
searchQuery: string;
|
||||
detailTab: TeamDetailTab;
|
||||
setSelectedTeam: (name: string | null) => void;
|
||||
toggleAutoRefresh: () => void;
|
||||
setMessageFilter: (filter: Partial<TeamMessageFilter>) => void;
|
||||
clearMessageFilter: () => void;
|
||||
setTimelineExpanded: (expanded: boolean) => void;
|
||||
setViewMode: (mode: 'list' | 'detail') => void;
|
||||
setLocationFilter: (filter: 'active' | 'archived' | 'all') => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setDetailTab: (tab: TeamDetailTab) => void;
|
||||
selectTeamAndShowDetail: (name: string) => void;
|
||||
backToList: () => void;
|
||||
}
|
||||
|
||||
export const useTeamStore = create<TeamStore>()(
|
||||
@@ -27,12 +39,22 @@ export const useTeamStore = create<TeamStore>()(
|
||||
autoRefresh: true,
|
||||
messageFilter: {},
|
||||
timelineExpanded: true,
|
||||
viewMode: 'list',
|
||||
locationFilter: 'active',
|
||||
searchQuery: '',
|
||||
detailTab: 'artifacts',
|
||||
setSelectedTeam: (name) => set({ selectedTeam: name }),
|
||||
toggleAutoRefresh: () => set((s) => ({ autoRefresh: !s.autoRefresh })),
|
||||
setMessageFilter: (filter) =>
|
||||
set((s) => ({ messageFilter: { ...s.messageFilter, ...filter } })),
|
||||
clearMessageFilter: () => set({ messageFilter: {} }),
|
||||
setTimelineExpanded: (expanded) => set({ timelineExpanded: expanded }),
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setLocationFilter: (filter) => set({ locationFilter: filter }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setDetailTab: (tab) => set({ detailTab: tab }),
|
||||
selectTeamAndShowDetail: (name) => set({ selectedTeam: name, viewMode: 'detail', detailTab: 'artifacts' }),
|
||||
backToList: () => set({ viewMode: 'list', detailTab: 'artifacts' }),
|
||||
}),
|
||||
{ name: 'ccw-team-store' }
|
||||
),
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface TerminalPanelActions {
|
||||
openTerminal: (sessionKey: string) => void;
|
||||
/** Close the terminal panel (keeps terminal order intact) */
|
||||
closePanel: () => void;
|
||||
/** Toggle panel open/closed; when opening, restores last active or shows queue */
|
||||
togglePanel: () => void;
|
||||
/** Switch active terminal without opening/closing */
|
||||
setActiveTerminal: (sessionKey: string) => void;
|
||||
/** Switch panel view between 'terminal' and 'queue' */
|
||||
@@ -80,6 +82,24 @@ export const useTerminalPanelStore = create<TerminalPanelStore>()(
|
||||
set({ isPanelOpen: false }, false, 'closePanel');
|
||||
},
|
||||
|
||||
togglePanel: () => {
|
||||
const { isPanelOpen, activeTerminalId, terminalOrder } = get();
|
||||
if (isPanelOpen) {
|
||||
set({ isPanelOpen: false }, false, 'togglePanel/close');
|
||||
} else {
|
||||
const nextActive = activeTerminalId ?? terminalOrder[0] ?? null;
|
||||
set(
|
||||
{
|
||||
isPanelOpen: true,
|
||||
activeTerminalId: nextActive,
|
||||
panelView: nextActive ? 'terminal' : 'queue',
|
||||
},
|
||||
false,
|
||||
'togglePanel/open'
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Terminal Selection ==========
|
||||
|
||||
setActiveTerminal: (sessionKey: string) => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Zustand store for managing CLI Viewer layout and tab state
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -909,6 +910,9 @@ export const useViewerStore = create<ViewerState>()(
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Stable empty array to avoid new references */
|
||||
const EMPTY_TABS: TabState[] = [];
|
||||
|
||||
/**
|
||||
* Select the current layout
|
||||
*/
|
||||
@@ -940,11 +944,12 @@ export const selectPane = (state: ViewerState, paneId: PaneId) => state.panes[pa
|
||||
export const selectTab = (state: ViewerState, tabId: TabId) => state.tabs[tabId];
|
||||
|
||||
/**
|
||||
* Select tabs for a specific pane, sorted by order
|
||||
* Select tabs for a specific pane, sorted by order.
|
||||
* WARNING: Returns new array each call — use with useMemo or useShallow in components.
|
||||
*/
|
||||
export const selectPaneTabs = (state: ViewerState, paneId: PaneId): TabState[] => {
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return [];
|
||||
if (!pane) return EMPTY_TABS;
|
||||
return [...pane.tabs].sort((a, b) => a.order - b.order);
|
||||
};
|
||||
|
||||
@@ -964,7 +969,7 @@ export const selectActiveTab = (state: ViewerState, paneId: PaneId): TabState |
|
||||
* Useful for components that only need actions, not the full state
|
||||
*/
|
||||
export const useViewerActions = () => {
|
||||
return useViewerStore((state) => ({
|
||||
return useViewerStore(useShallow((state) => ({
|
||||
setLayout: state.setLayout,
|
||||
addPane: state.addPane,
|
||||
removePane: state.removePane,
|
||||
@@ -976,5 +981,5 @@ export const useViewerActions = () => {
|
||||
setFocusedPane: state.setFocusedPane,
|
||||
initializeDefaultLayout: state.initializeDefaultLayout,
|
||||
reset: state.reset,
|
||||
}));
|
||||
})));
|
||||
};
|
||||
|
||||
@@ -105,6 +105,21 @@ export type {
|
||||
TemplateExportRequest,
|
||||
} from './execution';
|
||||
|
||||
// ========== Orchestrator Types ==========
|
||||
export type {
|
||||
SessionStrategy,
|
||||
ErrorHandlingStrategy,
|
||||
ErrorHandling,
|
||||
OrchestrationStatus,
|
||||
StepStatus,
|
||||
ExecutionType,
|
||||
OrchestrationMetadata,
|
||||
OrchestrationSource,
|
||||
OrchestrationStep,
|
||||
OrchestrationPlan,
|
||||
ManualOrchestrationParams,
|
||||
} from './orchestrator';
|
||||
|
||||
// ========== Tool Call Types ==========
|
||||
export type {
|
||||
ToolCallStatus,
|
||||
|
||||
238
ccw/frontend/src/types/orchestrator.ts
Normal file
238
ccw/frontend/src/types/orchestrator.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { CliTool, ExecutionMode } from './flow';
|
||||
|
||||
// ========================================
|
||||
// Orchestrator Specific Types
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Strategy for session management during step execution.
|
||||
* - 'reuse_default': Use the default session specified in the plan or orchestrator settings.
|
||||
* - 'new_session': Create a new session for this step.
|
||||
* - 'specific_session': Use a specific session identified by `targetSessionKey`.
|
||||
*/
|
||||
export type SessionStrategy = 'reuse_default' | 'new_session' | 'specific_session';
|
||||
|
||||
/**
|
||||
* Strategy for handling errors within a step or plan.
|
||||
* - 'pause_on_error': Pause the orchestration and wait for user intervention.
|
||||
* - 'skip': Skip the failing step and proceed with the next.
|
||||
* - 'stop': Stop the entire orchestration.
|
||||
*/
|
||||
export type ErrorHandlingStrategy = 'pause_on_error' | 'skip' | 'stop';
|
||||
|
||||
/**
|
||||
* Defines error handling configuration.
|
||||
*/
|
||||
export interface ErrorHandling {
|
||||
strategy: ErrorHandlingStrategy;
|
||||
maxRetries: number;
|
||||
retryDelayMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall status of an orchestration plan.
|
||||
*/
|
||||
export type OrchestrationStatus = 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Status of an individual orchestration step.
|
||||
*/
|
||||
export type StepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'paused' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Defines the type of execution for an orchestration step.
|
||||
* - 'frontend-cli': Execute a command directly in the frontend CLI (e.g., via a pseudo-terminal).
|
||||
* - 'backend-flow': Execute a sub-flow defined on the backend.
|
||||
* - 'slash-command': Execute a predefined slash command (e.g., /workflow:plan).
|
||||
*/
|
||||
export type ExecutionType = 'frontend-cli' | 'backend-flow' | 'slash-command';
|
||||
|
||||
/**
|
||||
* Metadata about the orchestration plan for display and analysis.
|
||||
*/
|
||||
export interface OrchestrationMetadata {
|
||||
totalSteps: number;
|
||||
hasParallelGroups: boolean;
|
||||
estimatedComplexity: 'low' | 'medium' | 'high';
|
||||
// Add any other relevant metadata here
|
||||
}
|
||||
|
||||
/**
|
||||
* Source from which the orchestration plan was created.
|
||||
*/
|
||||
export type OrchestrationSource = 'flow' | 'queue' | 'manual';
|
||||
|
||||
/**
|
||||
* Represents a single executable step within an orchestration plan.
|
||||
* This is a generalized step that can originate from a flow node, a queue item, or manual input.
|
||||
*/
|
||||
export interface OrchestrationStep {
|
||||
/**
|
||||
* Unique identifier for the step.
|
||||
* For flow-based plans, this might correspond to the node ID.
|
||||
* For queue-based, it could be item_id or a generated ID.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Display name for the step.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The core instruction for the step.
|
||||
* This could be a prompt for a CLI tool, a slash command string, etc.
|
||||
*/
|
||||
instruction: string;
|
||||
|
||||
/**
|
||||
* Optional CLI tool to use for execution, if applicable.
|
||||
*/
|
||||
tool?: CliTool;
|
||||
|
||||
/**
|
||||
* Optional execution mode (e.g., 'analysis', 'write'), if applicable.
|
||||
*/
|
||||
mode?: ExecutionMode;
|
||||
|
||||
/**
|
||||
* Session management strategy for this specific step.
|
||||
* Overrides the plan's `defaultSessionStrategy` if provided.
|
||||
*/
|
||||
sessionStrategy?: SessionStrategy;
|
||||
|
||||
/**
|
||||
* When `sessionStrategy` is 'specific_session', this key identifies the target session.
|
||||
*/
|
||||
targetSessionKey?: string;
|
||||
|
||||
/**
|
||||
* A logical key for resuming or chaining related executions.
|
||||
*/
|
||||
resumeKey?: string;
|
||||
|
||||
/**
|
||||
* An array of step IDs that this step depends on.
|
||||
* This forms the DAG for execution ordering.
|
||||
*/
|
||||
dependsOn: string[];
|
||||
|
||||
/**
|
||||
* An optional condition (e.g., a JavaScript expression) that must evaluate to true for the step to execute.
|
||||
*/
|
||||
condition?: string;
|
||||
|
||||
/**
|
||||
* References to outputs from previous steps, used for context injection.
|
||||
* E.g., `["analysisResult", "fileContent"]`
|
||||
*/
|
||||
contextRefs?: string[];
|
||||
|
||||
/**
|
||||
* The name under which this step's output should be stored,
|
||||
* allowing subsequent steps to reference it via `contextRefs`.
|
||||
*/
|
||||
outputName?: string;
|
||||
|
||||
/**
|
||||
* Error handling configuration for this specific step.
|
||||
* Overrides the plan's `defaultErrorHandling` if provided.
|
||||
*/
|
||||
errorHandling?: ErrorHandling;
|
||||
|
||||
/**
|
||||
* The underlying type of execution this step represents.
|
||||
*/
|
||||
executionType: ExecutionType;
|
||||
|
||||
/**
|
||||
* For flow-based plans, the ID of the source FlowNode.
|
||||
*/
|
||||
sourceNodeId?: string;
|
||||
|
||||
/**
|
||||
* For queue-based plans, the ID of the source QueueItem.
|
||||
*/
|
||||
sourceItemId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a complete, executable orchestration plan.
|
||||
* This plan is a directed acyclic graph (DAG) of `OrchestrationStep`s.
|
||||
*/
|
||||
export interface OrchestrationPlan {
|
||||
/**
|
||||
* Unique identifier for the plan.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Display name for the plan.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The source from which this plan was generated.
|
||||
*/
|
||||
source: OrchestrationSource;
|
||||
|
||||
/**
|
||||
* Optional ID of the source artifact (e.g., Flow ID, Queue ID).
|
||||
*/
|
||||
sourceId?: string;
|
||||
|
||||
/**
|
||||
* The ordered list of steps to be executed.
|
||||
* The actual execution order will be derived from `dependsOn` relationships,
|
||||
* but this array provides a stable definition of all steps.
|
||||
*/
|
||||
steps: OrchestrationStep[];
|
||||
|
||||
/**
|
||||
* Global variables that can be used within the plan (e.g., for instruction interpolation).
|
||||
*/
|
||||
variables: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Default session strategy for steps in this plan if not overridden at the step level.
|
||||
*/
|
||||
defaultSessionStrategy: SessionStrategy;
|
||||
|
||||
/**
|
||||
* Default error handling for steps in this plan if not overridden at the step level.
|
||||
*/
|
||||
defaultErrorHandling: ErrorHandling;
|
||||
|
||||
/**
|
||||
* Status of the overall plan.
|
||||
*/
|
||||
status: OrchestrationStatus;
|
||||
|
||||
/**
|
||||
* Timestamp when the plan was created.
|
||||
*/
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the plan was last updated.
|
||||
*/
|
||||
updatedAt: string;
|
||||
|
||||
/**
|
||||
* Analytical metadata about the plan.
|
||||
*/
|
||||
metadata: OrchestrationMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the parameters for manually creating an orchestration plan.
|
||||
*/
|
||||
export interface ManualOrchestrationParams {
|
||||
prompt: string;
|
||||
tool?: CliTool;
|
||||
mode?: ExecutionMode;
|
||||
sessionStrategy?: SessionStrategy;
|
||||
targetSessionKey?: string;
|
||||
outputName?: string;
|
||||
errorHandling?: ErrorHandling;
|
||||
}
|
||||
@@ -35,12 +35,24 @@ export interface TeamMember {
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export type TeamStatus = 'active' | 'completed' | 'archived';
|
||||
|
||||
export interface TeamSummary {
|
||||
name: string;
|
||||
messageCount: number;
|
||||
lastActivity: string;
|
||||
}
|
||||
|
||||
export interface TeamSummaryExtended extends TeamSummary {
|
||||
status: TeamStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at?: string;
|
||||
pipeline_mode?: string;
|
||||
memberCount: number;
|
||||
members?: string[];
|
||||
}
|
||||
|
||||
export interface TeamMessagesResponse {
|
||||
total: number;
|
||||
showing: number;
|
||||
@@ -53,7 +65,7 @@ export interface TeamStatusResponse {
|
||||
}
|
||||
|
||||
export interface TeamsListResponse {
|
||||
teams: TeamSummary[];
|
||||
teams: TeamSummaryExtended[];
|
||||
}
|
||||
|
||||
export interface TeamMessageFilter {
|
||||
|
||||
Reference in New Issue
Block a user