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:
catlog22
2026-02-14 12:54:08 +08:00
parent cdb240d2c2
commit 4d22ae4b2f
56 changed files with 4767 additions and 425 deletions

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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 />

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;
}

View 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}
/>
)}
</>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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;

View File

@@ -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>
)}

View File

@@ -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>
);
}