mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat: unify queue execution handling with QueueItemExecutor and CLI execution settings
- Removed ad-hoc test scripts and temp files from project root - Added QueueItemExecutor component to handle both session and orchestrator executions - Created CliExecutionSettings component for shared execution parameter controls - Introduced useCliSessionCore hook for managing CLI session lifecycle - Merged buildQueueItemPrompt and buildQueueItemInstruction into a single function for context building - Implemented Zustand store for queue execution state management - Updated localization files for new execution features
This commit is contained in:
406
ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx
Normal file
406
ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
// ========================================
|
||||
// QueueItemExecutor
|
||||
// ========================================
|
||||
// Unified execution component for queue items with Tab switching
|
||||
// between Session (direct PTY) and Orchestrator (flow-based) modes.
|
||||
// Replaces QueueExecuteInSession and QueueSendToOrchestrator.
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, RefreshCw, Terminal, Workflow } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { toast, useExecutionStore, useFlowStore } from '@/stores';
|
||||
import { useIssues } from '@/hooks';
|
||||
import {
|
||||
executeInCliSession,
|
||||
createOrchestratorFlow,
|
||||
executeOrchestratorFlow,
|
||||
type QueueItem,
|
||||
} from '@/lib/api';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionCore } from '@/hooks/useCliSessionCore';
|
||||
import {
|
||||
CliExecutionSettings,
|
||||
type ToolName,
|
||||
type ExecutionMode,
|
||||
type ResumeStrategy,
|
||||
} from '@/components/shared/CliExecutionSettings';
|
||||
import { buildQueueItemContext } from '@/lib/queue-prompt';
|
||||
import { useQueueExecutionStore, type QueueExecution } from '@/stores/queueExecutionStore';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ExecutionTab = 'session' | 'orchestrator';
|
||||
|
||||
export interface QueueItemExecutorProps {
|
||||
item: QueueItem;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function QueueItemExecutor({ item, className }: QueueItemExecutorProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Resolve the parent issue for context building
|
||||
const { issues } = useIssues();
|
||||
const issue = useMemo(
|
||||
() => issues.find((i) => i.id === item.issue_id) as any,
|
||||
[issues, item.issue_id]
|
||||
);
|
||||
|
||||
// Shared session management via useCliSessionCore
|
||||
const {
|
||||
sessions,
|
||||
selectedSessionKey,
|
||||
setSelectedSessionKey,
|
||||
refreshSessions,
|
||||
ensureSession,
|
||||
handleCreateSession,
|
||||
isLoading,
|
||||
error: sessionError,
|
||||
} = useCliSessionCore({ autoSelectLast: true, resumeKey: item.issue_id });
|
||||
|
||||
// Shared execution settings state
|
||||
const [tool, setTool] = useState<ToolName>('claude');
|
||||
const [mode, setMode] = useState<ExecutionMode>('write');
|
||||
const [resumeStrategy, setResumeStrategy] = useState<ResumeStrategy>('nativeResume');
|
||||
|
||||
// Execution state
|
||||
const [activeTab, setActiveTab] = useState<ExecutionTab>('session');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
|
||||
// Combine errors from session core and local execution
|
||||
const displayError = error || sessionError || null;
|
||||
|
||||
// Store reference for recording executions
|
||||
const addExecution = useQueueExecutionStore((s) => s.addExecution);
|
||||
|
||||
// ========== Session Execution ==========
|
||||
|
||||
const handleSessionExecute = async () => {
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
setLastResult(null);
|
||||
try {
|
||||
const sessionKey = await ensureSession();
|
||||
const prompt = buildQueueItemContext(item, issue);
|
||||
const result = await executeInCliSession(
|
||||
sessionKey,
|
||||
{
|
||||
tool,
|
||||
prompt,
|
||||
mode,
|
||||
workingDir: projectPath,
|
||||
category: 'user',
|
||||
resumeKey: item.issue_id,
|
||||
resumeStrategy,
|
||||
},
|
||||
projectPath
|
||||
);
|
||||
|
||||
// Record to queueExecutionStore
|
||||
const execution: QueueExecution = {
|
||||
id: result.executionId,
|
||||
queueItemId: item.item_id,
|
||||
issueId: item.issue_id,
|
||||
solutionId: item.solution_id,
|
||||
type: 'session',
|
||||
sessionKey,
|
||||
tool,
|
||||
mode,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
addExecution(execution);
|
||||
|
||||
setLastResult(result.executionId);
|
||||
|
||||
// Open terminal panel to show output
|
||||
useTerminalPanelStore.getState().openTerminal(sessionKey);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Orchestrator Execution ==========
|
||||
|
||||
const handleOrchestratorExecute = async () => {
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
setLastResult(null);
|
||||
try {
|
||||
const sessionKey = await ensureSession();
|
||||
const instruction = buildQueueItemContext(item, issue);
|
||||
|
||||
const nodeId = generateId('node');
|
||||
const flowName = `Queue ${item.issue_id} / ${item.solution_id}${item.task_id ? ` / ${item.task_id}` : ''}`;
|
||||
const flowDescription = `Queue item ${item.item_id} -> Orchestrator`;
|
||||
|
||||
const created = await createOrchestratorFlow(
|
||||
{
|
||||
name: flowName,
|
||||
description: flowDescription,
|
||||
version: '1.0.0',
|
||||
nodes: [
|
||||
{
|
||||
id: nodeId,
|
||||
type: 'prompt-template',
|
||||
position: { x: 100, y: 100 },
|
||||
data: {
|
||||
label: flowName,
|
||||
instruction,
|
||||
tool,
|
||||
mode,
|
||||
delivery: 'sendToSession',
|
||||
targetSessionKey: sessionKey,
|
||||
resumeKey: item.issue_id,
|
||||
resumeStrategy,
|
||||
tags: ['queue', item.item_id, item.issue_id, item.solution_id].filter(Boolean),
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
variables: {},
|
||||
metadata: {
|
||||
source: 'local',
|
||||
tags: ['queue', item.item_id, item.issue_id, item.solution_id].filter(Boolean),
|
||||
},
|
||||
},
|
||||
projectPath || undefined
|
||||
);
|
||||
|
||||
if (!created.success) {
|
||||
throw new Error('Failed to create flow');
|
||||
}
|
||||
|
||||
// Hydrate Orchestrator stores
|
||||
const flowDto = created.data as any;
|
||||
const parsedVersion = parseInt(String(flowDto.version ?? '1'), 10);
|
||||
const flowForStore = {
|
||||
...flowDto,
|
||||
version: Number.isFinite(parsedVersion) ? parsedVersion : 1,
|
||||
} as any;
|
||||
useFlowStore.getState().setCurrentFlow(flowForStore);
|
||||
|
||||
// Execute the flow
|
||||
const executed = await executeOrchestratorFlow(
|
||||
created.data.id,
|
||||
{},
|
||||
projectPath || undefined
|
||||
);
|
||||
if (!executed.success) {
|
||||
throw new Error('Failed to execute flow');
|
||||
}
|
||||
|
||||
const execId = executed.data.execId;
|
||||
useExecutionStore.getState().startExecution(execId, created.data.id);
|
||||
useExecutionStore.getState().setMonitorPanelOpen(true);
|
||||
|
||||
// Record to queueExecutionStore
|
||||
const execution: QueueExecution = {
|
||||
id: generateId('qexec'),
|
||||
queueItemId: item.item_id,
|
||||
issueId: item.issue_id,
|
||||
solutionId: item.solution_id,
|
||||
type: 'orchestrator',
|
||||
flowId: created.data.id,
|
||||
execId,
|
||||
tool,
|
||||
mode,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
addExecution(execution);
|
||||
|
||||
setLastResult(`${created.data.id} / ${execId}`);
|
||||
toast.success(
|
||||
formatMessage({ id: 'issues.queue.orchestrator.sentTitle' }),
|
||||
formatMessage(
|
||||
{ id: 'issues.queue.orchestrator.sentDesc' },
|
||||
{ flowId: created.data.id }
|
||||
)
|
||||
);
|
||||
|
||||
navigate('/orchestrator');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setError(message);
|
||||
toast.error(formatMessage({ id: 'issues.queue.orchestrator.sendFailed' }), message);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Render ==========
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{/* Header with session controls */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{formatMessage({ id: 'issues.queue.exec.title' })}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshSessions}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
|
||||
{formatMessage({ id: 'issues.terminal.session.refresh' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateSession}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.terminal.session.new' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{formatMessage({ id: 'issues.terminal.session.select' })}
|
||||
</label>
|
||||
<Select value={selectedSessionKey} onValueChange={setSelectedSessionKey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={formatMessage({ id: 'issues.terminal.session.none' })}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sessions.length === 0 ? (
|
||||
<SelectItem value="__none__" disabled>
|
||||
{formatMessage({ id: 'issues.terminal.session.none' })}
|
||||
</SelectItem>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SelectItem key={s.sessionKey} value={s.sessionKey}>
|
||||
{(s.tool || 'cli') + ' \u00B7 ' + s.sessionKey}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Shared execution settings */}
|
||||
<CliExecutionSettings
|
||||
tool={tool}
|
||||
mode={mode}
|
||||
resumeStrategy={resumeStrategy}
|
||||
onToolChange={setTool}
|
||||
onModeChange={setMode}
|
||||
onResumeStrategyChange={setResumeStrategy}
|
||||
/>
|
||||
|
||||
{/* Execution mode tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as ExecutionTab)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="session" className="flex-1 gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.queue.exec.sessionTab' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="orchestrator" className="flex-1 gap-2">
|
||||
<Workflow className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.queue.exec.orchestratorTab' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Session Tab */}
|
||||
<TabsContent value="session" className="mt-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
onClick={handleSessionExecute}
|
||||
disabled={isExecuting || !projectPath}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
{formatMessage({ id: 'issues.terminal.exec.run' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'issues.terminal.exec.run' })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Orchestrator Tab */}
|
||||
<TabsContent value="orchestrator" className="mt-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
onClick={handleOrchestratorExecute}
|
||||
disabled={isExecuting || !projectPath}
|
||||
className="gap-2"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
{formatMessage({ id: 'issues.queue.orchestrator.sending' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'issues.queue.orchestrator.send' })
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Error display */}
|
||||
{displayError && (
|
||||
<div className="text-sm text-destructive">{displayError}</div>
|
||||
)}
|
||||
|
||||
{/* Last result */}
|
||||
{lastResult && (
|
||||
<div className="text-xs text-muted-foreground font-mono break-all">
|
||||
{lastResult}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueueItemExecutor;
|
||||
@@ -9,8 +9,7 @@ import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangl
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { QueueExecuteInSession } from '@/components/issue/queue/QueueExecuteInSession';
|
||||
import { QueueSendToOrchestrator } from '@/components/issue/queue/QueueSendToOrchestrator';
|
||||
import { QueueItemExecutor } from '@/components/issue/queue/QueueItemExecutor';
|
||||
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
|
||||
import { useIssueQueue } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -178,11 +177,8 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Execute in Session */}
|
||||
<QueueExecuteInSession item={item} />
|
||||
|
||||
{/* Send to Orchestrator */}
|
||||
<QueueSendToOrchestrator item={item} />
|
||||
{/* Unified Execution */}
|
||||
<QueueItemExecutor item={item} />
|
||||
|
||||
{/* Dependencies */}
|
||||
{item.depends_on && item.depends_on.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user