From 4d22ae4b2f1c7ae8ed7fadc9e90cf89d08cd1a43 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sat, 14 Feb 2026 12:54:08 +0800 Subject: [PATCH] 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. --- .../components/issue/hub/ExecutionPanel.tsx | 12 +- .../src/components/issue/hub/IssueDrawer.tsx | 106 +++- .../issue/queue/QueueItemExecutor.tsx | 2 +- ccw/frontend/src/components/layout/Header.tsx | 34 ++ .../orchestrator/OrchestratorControlPanel.tsx | 411 ++++++++++++++ .../src/components/orchestrator/index.ts | 1 + .../src/components/shared/TaskDrawer.tsx | 4 +- .../src/components/team/TeamArtifacts.tsx | 285 ++++++++++ ccw/frontend/src/components/team/TeamCard.tsx | 235 ++++++++ .../src/components/team/TeamHeader.tsx | 68 +-- .../src/components/team/TeamListView.tsx | 230 ++++++++ .../src/components/team/TeamPipeline.tsx | 46 +- .../terminal-panel/TerminalMainArea.tsx | 196 ++++--- .../terminal-panel/TerminalNavBar.tsx | 69 ++- ccw/frontend/src/hooks/index.ts | 4 +- .../src/hooks/useActiveCliExecutions.ts | 33 +- ccw/frontend/src/hooks/useCliSessionCore.ts | 2 +- ccw/frontend/src/hooks/useCommands.ts | 18 +- .../src/hooks/useCompletionCallbackChain.ts | 199 +++++++ ccw/frontend/src/hooks/useHistory.ts | 16 +- ccw/frontend/src/hooks/useIndex.ts | 15 +- ccw/frontend/src/hooks/useLoops.ts | 35 +- ccw/frontend/src/hooks/usePromptHistory.ts | 54 +- ccw/frontend/src/hooks/useTeamData.ts | 83 ++- ccw/frontend/src/hooks/useWebSocket.ts | 4 +- ccw/frontend/src/lib/api.ts | 42 +- ccw/frontend/src/lib/queryKeys.ts | 1 + .../src/lib/unifiedExecutionDispatcher.ts | 203 +++++++ ccw/frontend/src/locales/en/home.json | 9 +- ccw/frontend/src/locales/en/issues.json | 1 + ccw/frontend/src/locales/en/orchestrator.json | 9 + ccw/frontend/src/locales/en/team.json | 52 ++ ccw/frontend/src/locales/zh/home.json | 9 +- ccw/frontend/src/locales/zh/issues.json | 1 + ccw/frontend/src/locales/zh/orchestrator.json | 9 + ccw/frontend/src/locales/zh/team.json | 52 ++ .../orchestrator/OrchestrationPlanBuilder.ts | 344 +++++++++++ .../src/orchestrator/SequentialRunner.ts | 478 ++++++++++++++++ .../OrchestrationPlanBuilder.test.ts | 430 ++++++++++++++ ccw/frontend/src/orchestrator/index.ts | 18 + ccw/frontend/src/pages/ReviewSessionPage.tsx | 86 ++- ccw/frontend/src/pages/TeamPage.tsx | 135 ++--- ccw/frontend/src/stores/cliStreamStore.ts | 27 +- ccw/frontend/src/stores/executionStore.ts | 7 +- ccw/frontend/src/stores/index.ts | 21 + ccw/frontend/src/stores/orchestratorStore.ts | 533 ++++++++++++++++++ .../src/stores/queueExecutionStore.ts | 20 +- ccw/frontend/src/stores/teamStore.ts | 22 + ccw/frontend/src/stores/terminalPanelStore.ts | 20 + ccw/frontend/src/stores/viewerStore.ts | 13 +- ccw/frontend/src/types/index.ts | 15 + ccw/frontend/src/types/orchestrator.ts | 238 ++++++++ ccw/frontend/src/types/team.ts | 14 +- ccw/src/core/lite-scanner.ts | 19 +- ccw/src/core/routes/team-routes.ts | 134 ++++- ccw/src/tools/team-msg.ts | 68 ++- 56 files changed, 4767 insertions(+), 425 deletions(-) create mode 100644 ccw/frontend/src/components/orchestrator/OrchestratorControlPanel.tsx create mode 100644 ccw/frontend/src/components/team/TeamArtifacts.tsx create mode 100644 ccw/frontend/src/components/team/TeamCard.tsx create mode 100644 ccw/frontend/src/components/team/TeamListView.tsx create mode 100644 ccw/frontend/src/hooks/useCompletionCallbackChain.ts create mode 100644 ccw/frontend/src/lib/unifiedExecutionDispatcher.ts create mode 100644 ccw/frontend/src/orchestrator/OrchestrationPlanBuilder.ts create mode 100644 ccw/frontend/src/orchestrator/SequentialRunner.ts create mode 100644 ccw/frontend/src/orchestrator/__tests__/OrchestrationPlanBuilder.test.ts create mode 100644 ccw/frontend/src/orchestrator/index.ts create mode 100644 ccw/frontend/src/stores/orchestratorStore.ts create mode 100644 ccw/frontend/src/types/orchestrator.ts diff --git a/ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx b/ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx index aa3615d3..2125bf5b 100644 --- a/ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx +++ b/ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx @@ -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 (
diff --git a/ccw/frontend/src/components/issue/hub/IssueDrawer.tsx b/ccw/frontend/src/components/issue/hub/IssueDrawer.tsx index 734fe249..c5014bdc 100644 --- a/ccw/frontend/src/components/issue/hub/IssueDrawer.tsx +++ b/ccw/frontend/src/components/issue/hub/IssueDrawer.tsx @@ -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(initialTab); + // Execution binding state + const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const; + const [selectedTool, setSelectedTool] = useState('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' }: )} - {/* Terminal Tab - Link to Terminal Panel */} + {/* Terminal Tab - Execution Binding */} -
- -

{formatMessage({ id: 'home.terminalPanel.openInPanel' })}

- +
+ {/* Tool Selection */} +
+

+ {formatMessage({ id: 'issues.terminal.exec.tool' })} +

+
+ {CLI_TOOLS.map((t) => ( + + ))} +
+
+ + {/* Mode Selection */} +
+

+ {formatMessage({ id: 'issues.terminal.exec.mode' })} +

+
+ {(['analysis', 'write'] as const).map((m) => ( + + ))} +
+
+ + {/* Launch Action */} +
+ + + +
diff --git a/ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx b/ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx index b106fc51..913b7761 100644 --- a/ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx +++ b/ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx @@ -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 diff --git a/ccw/frontend/src/components/layout/Header.tsx b/ccw/frontend/src/components/layout/Header.tsx index 456ba08c..7ac828a7 100644 --- a/ccw/frontend/src/components/layout/Header.tsx +++ b/ccw/frontend/src/components/layout/Header.tsx @@ -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({ )} + {/* Terminal Panel toggle */} + + + {/* CLI Viewer page link */} + + {/* Workspace selector */} diff --git a/ccw/frontend/src/components/orchestrator/OrchestratorControlPanel.tsx b/ccw/frontend/src/components/orchestrator/OrchestratorControlPanel.tsx new file mode 100644 index 00000000..bae16ecc --- /dev/null +++ b/ccw/frontend/src/components/orchestrator/OrchestratorControlPanel.tsx @@ -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 ( + + {formatMessage({ id: config.labelKey })} + + ); +} + +// ========== Step Status Icon ========== + +function StepStatusIcon({ status }: { status: StepStatus }) { + switch (status) { + case 'pending': + return ; + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'skipped': + return ; + case 'paused': + return ; + case 'cancelled': + return ; + default: + return ; + } +} + +// ========== Step List Item ========== + +interface StepListItemProps { + stepId: string; + name: string; + runState: StepRunState; + isCurrent: boolean; +} + +function StepListItem({ stepId: _stepId, name, runState, isCurrent }: StepListItemProps) { + return ( +
+ {/* Status icon */} +
+ +
+ + {/* Step info */} +
+ + {name} + + + {/* Error message inline */} + {runState.status === 'failed' && runState.error && ( +
+ + + {runState.error} + +
+ )} + + {/* Retry count indicator */} + {runState.retryCount > 0 && ( + + Retry #{runState.retryCount} + + )} +
+
+ ); +} + +// ========== 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 ( +
+

+ {formatMessage({ id: 'orchestrator.controlPanel.completedMessage' })} +

+
+ ); + } + + if (status === 'failed' || status === 'cancelled') { + return ( +
+

+ {formatMessage({ id: 'orchestrator.controlPanel.failedMessage' })} +

+
+ ); + } + + // Determine if paused due to error (has a failed step) + const isPausedOnError = status === 'paused' && failedStepId !== null; + const isPausedByUser = status === 'paused' && failedStepId === null; + + return ( +
+ {/* Running state: Pause + Stop */} + {status === 'running' && ( + <> + + + + )} + + {/* User-paused state: Resume + Stop */} + {isPausedByUser && ( + <> + + + + )} + + {/* Error-paused state: Retry + Skip + Stop */} + {isPausedOnError && failedStepId && ( + <> + + + + + )} +
+ ); +} + +// ========== 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 ( +
+

+ {formatMessage({ id: 'orchestrator.controlPanel.noPlan' })} +

+
+ ); + } + + const { plan, status, currentStepIndex } = runState; + + return ( +
+ {/* Header: Plan name + Status badge + Progress count */} +
+
+

+ {plan.name} +

+ + + {formatMessage( + { id: 'orchestrator.controlPanel.progress' }, + { completed: completedCount, total: totalCount } + )} + +
+ + {/* Progress bar */} +
+
+
+
+ + {/* Step list */} +
+ {plan.steps.map((step, index) => { + const stepState = runState.stepStatuses[step.id]; + if (!stepState) return null; + + return ( + + ); + })} +
+ + {/* Control bar */} + +
+ ); +}); + +OrchestratorControlPanel.displayName = 'OrchestratorControlPanel'; diff --git a/ccw/frontend/src/components/orchestrator/index.ts b/ccw/frontend/src/components/orchestrator/index.ts index e3b436c2..08700c81 100644 --- a/ccw/frontend/src/components/orchestrator/index.ts +++ b/ccw/frontend/src/components/orchestrator/index.ts @@ -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'; diff --git a/ccw/frontend/src/components/shared/TaskDrawer.tsx b/ccw/frontend/src/components/shared/TaskDrawer.tsx index 0f2cb0f0..d2d04fd4 100644 --- a/ccw/frontend/src/components/shared/TaskDrawer.tsx +++ b/ccw/frontend/src/components/shared/TaskDrawer.tsx @@ -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; } diff --git a/ccw/frontend/src/components/team/TeamArtifacts.tsx b/ccw/frontend/src/components/team/TeamArtifacts.tsx new file mode 100644 index 00000000..b5a16b31 --- /dev/null +++ b/ccw/frontend/src/components/team/TeamArtifacts.tsx @@ -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 = { + 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 = { + 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 { + const groups: Record = { + 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 ( + onView(artifact)} + > + +
+ {artifact.ref ? ( + + ) : ( + + )} +
+
+

{artifact.message.summary}

+
+ {artifact.ref ? ( + + {artifact.ref.split('/').pop()} + + ) : ( + + {formatMessage({ id: 'team.artifacts.noRef' })} + + )} + + {formatTimestamp(artifact.message.ts)} + +
+
+
+
+ ); +} + +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 ( +
+
+ +

+ {formatMessage({ id: `team.artifacts.${phase}` })} +

+ + {artifacts.length} + +
+
+ {artifacts.map((artifact) => ( + + ))} +
+
+ ); +} + +// ======================================== +// Main Component +// ======================================== + +export function TeamArtifacts({ messages }: TeamArtifactsProps) { + const { formatMessage } = useIntl(); + const [selectedArtifact, setSelectedArtifact] = React.useState(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 ( +
+ +

+ {formatMessage({ id: 'team.artifacts.noArtifacts' })} +

+
+ ); + } + + // 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 ( + <> +
+ {PHASE_ORDER.map((phase) => ( + + ))} +
+ + {selectedArtifact && ( + + )} + + ); +} diff --git a/ccw/frontend/src/components/team/TeamCard.tsx b/ccw/frontend/src/components/team/TeamCard.tsx new file mode 100644 index 00000000..bbf8ee2d --- /dev/null +++ b/ccw/frontend/src/components/team/TeamCard.tsx @@ -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 = { + 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 ( + + + {/* Header: team name + status + actions */} +
+
+
+

+ {team.name} +

+
+
+
+ {statusLabel} + {team.pipeline_mode && ( + + + {team.pipeline_mode} + + )} + {showActions && ( + + + + + + handleAction(e, 'view')}> + + {formatMessage({ id: 'team.actions.viewDetails' })} + + + {isArchived ? ( + handleAction(e, 'unarchive')}> + + {formatMessage({ id: 'team.actions.unarchive' })} + + ) : ( + handleAction(e, 'archive')}> + + {formatMessage({ id: 'team.actions.archive' })} + + )} + + handleAction(e, 'delete')} + className="text-destructive focus:text-destructive" + > + + {formatMessage({ id: 'team.actions.delete' })} + + + + )} +
+
+ + {/* Meta info row */} +
+ + + {team.messageCount} {formatMessage({ id: 'team.card.messages' })} + + {team.lastActivity && ( + + + {formatDate(team.lastActivity)} + + )} +
+ + {/* Members row */} + {team.members && team.members.length > 0 && ( +
+ + {team.members.map((name) => ( + + {name} + + ))} +
+ )} +
+
+ ); +} + +/** + * Skeleton loader for TeamCard + */ +export function TeamCardSkeleton({ className }: { className?: string }) { + return ( + + +
+
+
+
+
+
+
+
+
+
+
+ + + ); +} diff --git a/ccw/frontend/src/components/team/TeamHeader.tsx b/ccw/frontend/src/components/team/TeamHeader.tsx index 395f3992..f37faec1 100644 --- a/ccw/frontend/src/components/team/TeamHeader.tsx +++ b/ccw/frontend/src/components/team/TeamHeader.tsx @@ -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 (
- {/* Team Selector */} - + {/* Back button */} + - {/* Stats chips */} + {/* Team name */} {selectedTeam && ( -
- - - {formatMessage({ id: 'team.members' })}: {members.length} - - - - {formatMessage({ id: 'team.messages' })}: {totalMessages} - -
+ <> +

{selectedTeam}

+ + {/* Stats chips */} +
+ + + {formatMessage({ id: 'team.members' })}: {members.length} + + + + {formatMessage({ id: 'team.messages' })}: {totalMessages} + +
+ )}
diff --git a/ccw/frontend/src/components/team/TeamListView.tsx b/ccw/frontend/src/components/team/TeamListView.tsx new file mode 100644 index 00000000..42c2118a --- /dev/null +++ b/ccw/frontend/src/components/team/TeamListView.tsx @@ -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(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 ( +
+ {/* Header */} +
+
+
+ +

+ {formatMessage({ id: 'team.title' })} +

+
+

+ {formatMessage({ id: 'team.description' })} +

+
+
+ +
+
+ + {/* Filters */} +
+ {/* Location tabs */} + 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 */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-9" + /> + {searchQuery && ( + + )} +
+
+ + {/* Team grid */} + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : filteredTeams.length === 0 ? ( +
+ +

+ {searchQuery + ? formatMessage({ id: 'team.emptyState.noMatching' }) + : formatMessage({ id: 'team.emptyState.noTeams' })} +

+

+ {searchQuery + ? formatMessage({ id: 'team.emptyState.noMatchingDescription' }) + : formatMessage({ id: 'team.emptyState.noTeamsDescription' })} +

+ {searchQuery && ( + + )} +
+ ) : ( +
+ {filteredTeams.map((team) => ( + + ))} +
+ )} + + {/* Delete Confirmation Dialog */} + + + + {formatMessage({ id: 'team.dialog.deleteTeam' })} + + {formatMessage({ id: 'team.dialog.deleteConfirm' })} + + + + + + + + +
+ ); +} diff --git a/ccw/frontend/src/components/team/TeamPipeline.tsx b/ccw/frontend/src/components/team/TeamPipeline.tsx index b6f6164a..19940ab1 100644 --- a/ccw/frontend/src/components/team/TeamPipeline.tsx +++ b/ccw/frontend/src/components/team/TeamPipeline.tsx @@ -109,32 +109,34 @@ export function TeamPipeline({ messages }: TeamPipelineProps) { const stageStatus = derivePipelineStatus(messages); return ( -
-

- {formatMessage({ id: 'team.pipeline.title' })} -

+
+
+

+ {formatMessage({ id: 'team.pipeline.title' })} +

- {/* Desktop: horizontal layout */} -
- - - - -
- - + {/* Desktop: horizontal layout */} +
+ + + + +
+ + +
+
+ + {/* Mobile: vertical layout */} +
+ {STAGES.map((stage) => ( + + ))}
- {/* Mobile: vertical layout */} -
- {STAGES.map((stage) => ( - - ))} -
- - {/* Legend */} -
+ {/* Legend - pinned to bottom */} +
{(['completed', 'in_progress', 'pending', 'blocked'] as PipelineStageStatus[]).map((s) => { const cfg = statusConfig[s]; const Icon = cfg.icon; diff --git a/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx b/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx index a5263806..054ef7d7 100644 --- a/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx +++ b/ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx @@ -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(''); const flushTimerRef = useRef(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) => { - 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) {
+ {/* Toolbar */} + {panelView === 'terminal' && ( +
+ {/* New CLI session buttons */} + {CLI_TOOLS.map((tool) => ( + + ))} + +
+ + {/* Terminal actions */} + {activeTerminalId && ( + <> + + + + )} +
+ )} + {/* Content */} {panelView === 'queue' ? ( - /* Queue View - Placeholder */ -
-
- -

{formatMessage({ id: 'home.terminalPanel.executionQueueDesc' })}

-

{formatMessage({ id: 'home.terminalPanel.executionQueuePhase2' })}

-
-
+ /* Queue View */ + ) : activeTerminalId ? ( /* Terminal View */ -
- {/* xterm container */} -
-
-
- - {/* Command Input */} -
-
-