mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user