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:
catlog22
2026-02-13 14:50:58 +08:00
parent af90069db2
commit ad5b35a1a5
17 changed files with 1466 additions and 36 deletions

View File

@@ -0,0 +1,353 @@
// ========================================
// Execution Panel
// ========================================
// Content panel for Executions tab in IssueHub.
// Shows queue execution state from queueExecutionStore
// with split-view: execution list (left) + detail view (right).
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Play,
CheckCircle,
XCircle,
Clock,
Terminal,
Loader2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import {
useQueueExecutionStore,
selectExecutionStats,
useTerminalPanelStore,
} from '@/stores';
import type { QueueExecution, QueueExecutionStatus } from '@/stores/queueExecutionStore';
import { cn } from '@/lib/utils';
// ========== Helpers ==========
function statusBadgeVariant(status: QueueExecutionStatus): 'info' | 'success' | 'destructive' | 'secondary' {
switch (status) {
case 'running':
return 'info';
case 'completed':
return 'success';
case 'failed':
return 'destructive';
case 'pending':
default:
return 'secondary';
}
}
function statusIcon(status: QueueExecutionStatus) {
switch (status) {
case 'running':
return <Loader2 className="w-3.5 h-3.5 animate-spin" />;
case 'completed':
return <CheckCircle className="w-3.5 h-3.5" />;
case 'failed':
return <XCircle className="w-3.5 h-3.5" />;
case 'pending':
default:
return <Clock className="w-3.5 h-3.5" />;
}
}
function formatRelativeTime(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
// ========== Empty State ==========
function ExecutionEmptyState() {
const { formatMessage } = useIntl();
return (
<Card className="p-12 text-center">
<Terminal className="w-16 h-16 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">
{formatMessage({ id: 'issues.executions.emptyState.title' })}
</h3>
<p className="mt-2 text-muted-foreground">
{formatMessage({ id: 'issues.executions.emptyState.description' })}
</p>
</Card>
);
}
// ========== Stats Cards ==========
function ExecutionStatsCards() {
const { formatMessage } = useIntl();
const stats = useQueueExecutionStore(selectExecutionStats);
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-2">
<Play className="w-5 h-5 text-info" />
<span className="text-2xl font-bold">{stats.running}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'issues.executions.stats.running' })}
</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-success" />
<span className="text-2xl font-bold">{stats.completed}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'issues.executions.stats.completed' })}
</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
<span className="text-2xl font-bold">{stats.failed}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'issues.executions.stats.failed' })}
</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-muted-foreground" />
<span className="text-2xl font-bold">{stats.total}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'issues.executions.stats.total' })}
</p>
</Card>
</div>
);
}
// ========== Execution List Item ==========
function ExecutionListItem({
execution,
isSelected,
onSelect,
}: {
execution: QueueExecution;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
className={cn(
'w-full text-left p-3 rounded-md transition-colors',
'hover:bg-muted/60',
isSelected && 'bg-muted ring-1 ring-primary/30'
)}
onClick={onSelect}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{statusIcon(execution.status)}
<span className="text-sm font-medium text-foreground truncate">
{execution.id}
</span>
</div>
<Badge variant={statusBadgeVariant(execution.status)} className="gap-1 shrink-0">
{execution.status}
</Badge>
</div>
<div className="mt-1.5 flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono">{execution.tool}</span>
<span>{execution.mode}</span>
<span>{execution.type}</span>
<span className="ml-auto">{formatRelativeTime(execution.startedAt)}</span>
</div>
</button>
);
}
// ========== Execution Detail View ==========
function ExecutionDetailView({ execution }: { execution: QueueExecution | null }) {
const { formatMessage } = useIntl();
const openTerminal = useTerminalPanelStore((s) => s.openTerminal);
if (!execution) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{formatMessage({ id: 'issues.executions.detail.selectExecution' })}
</div>
);
}
const detailRows: Array<{ label: string; value: string | undefined }> = [
{ label: formatMessage({ id: 'issues.executions.detail.id' }), value: execution.id },
{ label: formatMessage({ id: 'issues.executions.detail.queueItemId' }), value: execution.queueItemId },
{ label: formatMessage({ id: 'issues.executions.detail.issueId' }), value: execution.issueId },
{ label: formatMessage({ id: 'issues.executions.detail.solutionId' }), value: execution.solutionId },
{ label: formatMessage({ id: 'issues.executions.detail.type' }), value: execution.type },
{ label: formatMessage({ id: 'issues.executions.detail.tool' }), value: execution.tool },
{ label: formatMessage({ id: 'issues.executions.detail.mode' }), value: execution.mode },
{ label: formatMessage({ id: 'issues.executions.detail.status' }), value: execution.status },
{ label: formatMessage({ id: 'issues.executions.detail.startedAt' }), value: execution.startedAt },
{ label: formatMessage({ id: 'issues.executions.detail.completedAt' }), value: execution.completedAt || '-' },
];
if (execution.sessionKey) {
detailRows.push({
label: formatMessage({ id: 'issues.executions.detail.sessionKey' }),
value: execution.sessionKey,
});
}
if (execution.flowId) {
detailRows.push({
label: formatMessage({ id: 'issues.executions.detail.flowId' }),
value: execution.flowId,
});
}
if (execution.execId) {
detailRows.push({
label: formatMessage({ id: 'issues.executions.detail.execId' }),
value: execution.execId,
});
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{statusIcon(execution.status)}
<span className="text-sm font-semibold text-foreground">{execution.id}</span>
<Badge variant={statusBadgeVariant(execution.status)}>{execution.status}</Badge>
</div>
{execution.type === 'session' && execution.sessionKey && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => openTerminal(execution.sessionKey!)}
>
<Terminal className="w-3.5 h-3.5" />
{formatMessage({ id: 'issues.executions.detail.openInTerminal' })}
</Button>
)}
</div>
{/* Error Banner */}
{execution.error && (
<Card className="p-3 border-destructive/50 bg-destructive/5">
<div className="flex items-start gap-2">
<XCircle className="w-4 h-4 text-destructive shrink-0 mt-0.5" />
<p className="text-sm text-destructive break-all">{execution.error}</p>
</div>
</Card>
)}
{/* Detail Table */}
<Card className="p-0 overflow-hidden">
<div className="divide-y divide-border">
{detailRows.map((row) => (
<div key={row.label} className="grid grid-cols-3 gap-2 px-4 py-2.5">
<span className="text-xs font-medium text-muted-foreground">{row.label}</span>
<span className="col-span-2 text-xs font-mono text-foreground break-all">
{row.value || '-'}
</span>
</div>
))}
</div>
</Card>
</div>
);
}
// ========== Main Panel Component ==========
export function ExecutionPanel() {
const { formatMessage } = useIntl();
const executions = useQueueExecutionStore((s) => s.executions);
const clearCompleted = useQueueExecutionStore((s) => s.clearCompleted);
const [selectedId, setSelectedId] = useState<string | null>(null);
// Sort executions: running first, then pending, then by startedAt descending
const sortedExecutions = useMemo(() => {
const all = Object.values(executions);
const statusOrder: Record<string, number> = {
running: 0,
pending: 1,
failed: 2,
completed: 3,
};
return all.sort((a, b) => {
const sa = statusOrder[a.status] ?? 4;
const sb = statusOrder[b.status] ?? 4;
if (sa !== sb) return sa - sb;
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
});
}, [executions]);
const selectedExecution = selectedId ? executions[selectedId] ?? null : null;
const hasCompletedOrFailed = sortedExecutions.some(
(e) => e.status === 'completed' || e.status === 'failed'
);
if (sortedExecutions.length === 0) {
return (
<div className="space-y-6">
<ExecutionStatsCards />
<ExecutionEmptyState />
</div>
);
}
return (
<div className="space-y-6">
{/* Stats Cards */}
<ExecutionStatsCards />
{/* Split View */}
<div className="grid grid-cols-[3fr_2fr] gap-4 min-h-[400px]">
{/* Left: Execution List */}
<Card className="p-3 overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'issues.executions.list.title' })}
</h3>
{hasCompletedOrFailed && (
<Button variant="outline" size="sm" onClick={clearCompleted}>
{formatMessage({ id: 'issues.executions.list.clearCompleted' })}
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto space-y-1">
{sortedExecutions.map((exec) => (
<ExecutionListItem
key={exec.id}
execution={exec}
isSelected={selectedId === exec.id}
onSelect={() => setSelectedId(exec.id)}
/>
))}
</div>
</Card>
{/* Right: Detail View */}
<Card className="p-4 overflow-y-auto">
<ExecutionDetailView execution={selectedExecution} />
</Card>
</div>
</div>
);
}
export default ExecutionPanel;

View File

@@ -4,9 +4,9 @@
// Dynamic header component for IssueHub
import { useIntl } from 'react-intl';
import { AlertCircle, Radar, ListTodo, LayoutGrid, Activity } from 'lucide-react';
import { AlertCircle, Radar, ListTodo, LayoutGrid, Activity, Terminal } from 'lucide-react';
type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'observability';
type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'observability' | 'executions';
interface IssueHubHeaderProps {
currentTab: IssueTab;
@@ -42,6 +42,11 @@ export function IssueHubHeader({ currentTab }: IssueHubHeaderProps) {
title: formatMessage({ id: 'issues.observability.pageTitle' }),
description: formatMessage({ id: 'issues.observability.description' }),
},
executions: {
icon: <Terminal className="w-6 h-6 text-primary" />,
title: formatMessage({ id: 'issues.executions.pageTitle' }),
description: formatMessage({ id: 'issues.executions.description' }),
},
};
const config = tabConfig[currentTab];

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
// Keep in sync with IssueHubHeader/IssueHubPage
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'observability';
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'observability' | 'executions';
interface IssueHubTabsProps {
currentTab: IssueTab;
@@ -24,6 +24,7 @@ export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
{ value: 'observability', label: formatMessage({ id: 'issues.hub.tabs.observability' }) },
{ value: 'executions', label: formatMessage({ id: 'issues.hub.tabs.executions' }) },
];
return (

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

View File

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

View File

@@ -0,0 +1,136 @@
// ========================================
// CliExecutionSettings Component
// ========================================
// Shared execution parameter controls (tool, mode, resumeStrategy)
// extracted from QueueExecuteInSession and QueueSendToOrchestrator.
import { useIntl } from 'react-intl';
import { Card, CardContent } from '@/components/ui/Card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ToolName = 'claude' | 'codex' | 'gemini' | 'qwen';
export type ExecutionMode = 'analysis' | 'write';
export type ResumeStrategy = 'nativeResume' | 'promptConcat';
export interface CliExecutionSettingsProps {
/** Currently selected tool. */
tool: ToolName;
/** Currently selected execution mode. */
mode: ExecutionMode;
/** Currently selected resume strategy. */
resumeStrategy: ResumeStrategy;
/** Callback when tool changes. */
onToolChange: (tool: ToolName) => void;
/** Callback when mode changes. */
onModeChange: (mode: ExecutionMode) => void;
/** Callback when resume strategy changes. */
onResumeStrategyChange: (strategy: ResumeStrategy) => void;
/** Available tool options. Defaults to claude, codex, gemini, qwen. */
toolOptions?: ToolName[];
/** Additional CSS class. */
className?: string;
}
// ---------------------------------------------------------------------------
// Default tool list
// ---------------------------------------------------------------------------
const DEFAULT_TOOL_OPTIONS: ToolName[] = ['claude', 'codex', 'gemini', 'qwen'];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CliExecutionSettings({
tool,
mode,
resumeStrategy,
onToolChange,
onModeChange,
onResumeStrategyChange,
toolOptions = DEFAULT_TOOL_OPTIONS,
className,
}: CliExecutionSettingsProps) {
const { formatMessage } = useIntl();
return (
<Card className={cn('', className)}>
<CardContent className="p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{/* Tool selector */}
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.tool' })}
</label>
<Select
value={tool}
onValueChange={(v) => onToolChange(v as ToolName)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{toolOptions.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Mode selector */}
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.mode' })}
</label>
<Select
value={mode}
onValueChange={(v) => onModeChange(v as ExecutionMode)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="analysis">analysis</SelectItem>
<SelectItem value="write">write</SelectItem>
</SelectContent>
</Select>
</div>
{/* Resume strategy selector */}
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
{formatMessage({ id: 'issues.terminal.exec.resumeStrategy' })}
</label>
<Select
value={resumeStrategy}
onValueChange={(v) =>
onResumeStrategyChange(v as ResumeStrategy)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nativeResume">nativeResume</SelectItem>
<SelectItem value="promptConcat">promptConcat</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -159,3 +159,12 @@ export { ConfigSync } from './ConfigSync';
export { ConfigSyncModal } from './ConfigSyncModal';
export type { ConfigSyncProps, BackupInfo, SyncResult, BackupResult } from './ConfigSync';
export type { ConfigSyncModalProps } from './ConfigSyncModal';
// CLI execution settings
export { CliExecutionSettings } from './CliExecutionSettings';
export type {
CliExecutionSettingsProps,
ToolName,
ExecutionMode,
ResumeStrategy,
} from './CliExecutionSettings';