mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-14 02:42:04 +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:
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node -e \"const p=JSON.parse(process.env.HOOK_INPUT||\\\"{}\\\");const prompt=(p.user_prompt||\\\"\\\").trim();if(/^ccw\\s+session\\s+init/i.test(prompt)||/^\\/workflow:session:start/i.test(prompt)||/^\\/workflow:session\\s+init/i.test(prompt)){const cp=require(\\\"child_process\\\");const payload=JSON.stringify({type:\\\"SESSION_CREATED\\\",prompt:prompt,timestamp:Date.now(),project:process.env.CLAUDE_PROJECT_DIR||process.cwd()});cp.spawnSync(\\\"curl\\\",[\\\"-s\\\",\\\"-X\\\",\\\"POST\\\",\\\"-H\\\",\\\"Content-Type: application/json\\\",\\\"-d\\\",payload,\\\"http://localhost:3456/api/hook\\\"],{stdio:\\\"inherit\\\",shell:true})}\""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node -e \"const p=JSON.parse(process.env.HOOK_INPUT||\\\"{}\\\");const prompt=(p.user_prompt||\\\"\\\").toLowerCase();if(prompt===\\\"status\\\"||prompt===\\\"ccw status\\\"||prompt.startsWith(\\\"/status\\\")){const cp=require(\\\"child_process\\\");cp.spawnSync(\\\"curl\\\",[\\\"-s\\\",\\\"http://localhost:3456/api/status/all\\\"],{stdio:\\\"inherit\\\"})}\""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 226 KiB |
353
ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx
Normal file
353
ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx
Normal 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;
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
// Dynamic header component for IssueHub
|
// Dynamic header component for IssueHub
|
||||||
|
|
||||||
import { useIntl } from 'react-intl';
|
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 {
|
interface IssueHubHeaderProps {
|
||||||
currentTab: IssueTab;
|
currentTab: IssueTab;
|
||||||
@@ -42,6 +42,11 @@ export function IssueHubHeader({ currentTab }: IssueHubHeaderProps) {
|
|||||||
title: formatMessage({ id: 'issues.observability.pageTitle' }),
|
title: formatMessage({ id: 'issues.observability.pageTitle' }),
|
||||||
description: formatMessage({ id: 'issues.observability.description' }),
|
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];
|
const config = tabConfig[currentTab];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// Keep in sync with IssueHubHeader/IssueHubPage
|
// 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 {
|
interface IssueHubTabsProps {
|
||||||
currentTab: IssueTab;
|
currentTab: IssueTab;
|
||||||
@@ -24,6 +24,7 @@ export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
|
|||||||
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
|
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
|
||||||
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
|
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
|
||||||
{ value: 'observability', label: formatMessage({ id: 'issues.hub.tabs.observability' }) },
|
{ value: 'observability', label: formatMessage({ id: 'issues.hub.tabs.observability' }) },
|
||||||
|
{ value: 'executions', label: formatMessage({ id: 'issues.hub.tabs.executions' }) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
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 { Badge } from '@/components/ui/Badge';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||||
import { QueueExecuteInSession } from '@/components/issue/queue/QueueExecuteInSession';
|
import { QueueItemExecutor } from '@/components/issue/queue/QueueItemExecutor';
|
||||||
import { QueueSendToOrchestrator } from '@/components/issue/queue/QueueSendToOrchestrator';
|
|
||||||
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
|
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
|
||||||
import { useIssueQueue } from '@/hooks';
|
import { useIssueQueue } from '@/hooks';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -178,11 +177,8 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Execute in Session */}
|
{/* Unified Execution */}
|
||||||
<QueueExecuteInSession item={item} />
|
<QueueItemExecutor item={item} />
|
||||||
|
|
||||||
{/* Send to Orchestrator */}
|
|
||||||
<QueueSendToOrchestrator item={item} />
|
|
||||||
|
|
||||||
{/* Dependencies */}
|
{/* Dependencies */}
|
||||||
{item.depends_on && item.depends_on.length > 0 && (
|
{item.depends_on && item.depends_on.length > 0 && (
|
||||||
|
|||||||
136
ccw/frontend/src/components/shared/CliExecutionSettings.tsx
Normal file
136
ccw/frontend/src/components/shared/CliExecutionSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -159,3 +159,12 @@ export { ConfigSync } from './ConfigSync';
|
|||||||
export { ConfigSyncModal } from './ConfigSyncModal';
|
export { ConfigSyncModal } from './ConfigSyncModal';
|
||||||
export type { ConfigSyncProps, BackupInfo, SyncResult, BackupResult } from './ConfigSync';
|
export type { ConfigSyncProps, BackupInfo, SyncResult, BackupResult } from './ConfigSync';
|
||||||
export type { ConfigSyncModalProps } from './ConfigSyncModal';
|
export type { ConfigSyncModalProps } from './ConfigSyncModal';
|
||||||
|
|
||||||
|
// CLI execution settings
|
||||||
|
export { CliExecutionSettings } from './CliExecutionSettings';
|
||||||
|
export type {
|
||||||
|
CliExecutionSettingsProps,
|
||||||
|
ToolName,
|
||||||
|
ExecutionMode,
|
||||||
|
ResumeStrategy,
|
||||||
|
} from './CliExecutionSettings';
|
||||||
|
|||||||
@@ -243,6 +243,13 @@ export type {
|
|||||||
UseCliExecutionReturn,
|
UseCliExecutionReturn,
|
||||||
} from './useCliExecution';
|
} from './useCliExecution';
|
||||||
|
|
||||||
|
// ========== CLI Session Core ==========
|
||||||
|
export { useCliSessionCore } from './useCliSessionCore';
|
||||||
|
export type {
|
||||||
|
UseCliSessionCoreOptions,
|
||||||
|
UseCliSessionCoreReturn,
|
||||||
|
} from './useCliSessionCore';
|
||||||
|
|
||||||
// ========== Workspace Query Keys ==========
|
// ========== Workspace Query Keys ==========
|
||||||
export {
|
export {
|
||||||
useWorkspaceQueryKeys,
|
useWorkspaceQueryKeys,
|
||||||
|
|||||||
169
ccw/frontend/src/hooks/useCliSessionCore.ts
Normal file
169
ccw/frontend/src/hooks/useCliSessionCore.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
// ========================================
|
||||||
|
// useCliSessionCore Hook
|
||||||
|
// ========================================
|
||||||
|
// Shared CLI session lifecycle management extracted from
|
||||||
|
// QueueExecuteInSession, QueueSendToOrchestrator, and IssueTerminalTab.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
createCliSession,
|
||||||
|
fetchCliSessions,
|
||||||
|
type CliSession,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||||
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface UseCliSessionCoreOptions {
|
||||||
|
/** When true, auto-select the most recent session on load. Defaults to true. */
|
||||||
|
autoSelectLast?: boolean;
|
||||||
|
/** Default resumeKey used when creating sessions via ensureSession/handleCreateSession. */
|
||||||
|
resumeKey?: string;
|
||||||
|
/** Additional createCliSession fields (cols, rows, tool, model). */
|
||||||
|
createSessionDefaults?: {
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
tool?: string;
|
||||||
|
model?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCliSessionCoreReturn {
|
||||||
|
/** Sorted list of CLI sessions (oldest first). */
|
||||||
|
sessions: CliSession[];
|
||||||
|
/** Currently selected session key. */
|
||||||
|
selectedSessionKey: string;
|
||||||
|
/** Setter for selected session key. */
|
||||||
|
setSelectedSessionKey: (key: string) => void;
|
||||||
|
/** Refresh sessions from the backend. */
|
||||||
|
refreshSessions: () => Promise<void>;
|
||||||
|
/** Return the current session key, creating one if none is selected. */
|
||||||
|
ensureSession: () => Promise<string>;
|
||||||
|
/** Explicitly create a new session and select it. */
|
||||||
|
handleCreateSession: () => Promise<void>;
|
||||||
|
/** True while sessions are being fetched. */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Last error message, or null. */
|
||||||
|
error: string | null;
|
||||||
|
/** Clear the current error. */
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useCliSessionCore(options: UseCliSessionCoreOptions = {}): UseCliSessionCoreReturn {
|
||||||
|
const {
|
||||||
|
autoSelectLast = true,
|
||||||
|
resumeKey,
|
||||||
|
createSessionDefaults,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
|
// Store selectors
|
||||||
|
const sessionsByKey = useCliSessionStore((s) => s.sessions);
|
||||||
|
const setSessions = useCliSessionStore((s) => s.setSessions);
|
||||||
|
const upsertSession = useCliSessionStore((s) => s.upsertSession);
|
||||||
|
|
||||||
|
// Derived sorted list (oldest first)
|
||||||
|
const sessions = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(sessionsByKey).sort((a, b) =>
|
||||||
|
a.createdAt.localeCompare(b.createdAt)
|
||||||
|
),
|
||||||
|
[sessionsByKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [selectedSessionKey, setSelectedSessionKey] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => setError(null), []);
|
||||||
|
|
||||||
|
// ------- refreshSessions -------
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await fetchCliSessions(projectPath || undefined);
|
||||||
|
setSessions(r.sessions as unknown as CliSession[]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectPath, setSessions]);
|
||||||
|
|
||||||
|
// Fetch on mount / when projectPath changes
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshSessions();
|
||||||
|
}, [refreshSessions]);
|
||||||
|
|
||||||
|
// Auto-select the last (most recent) session
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoSelectLast) return;
|
||||||
|
if (selectedSessionKey) return;
|
||||||
|
if (sessions.length === 0) return;
|
||||||
|
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
|
||||||
|
}, [sessions, selectedSessionKey, autoSelectLast]);
|
||||||
|
|
||||||
|
// ------- ensureSession -------
|
||||||
|
const ensureSession = useCallback(async (): Promise<string> => {
|
||||||
|
if (selectedSessionKey) return selectedSessionKey;
|
||||||
|
if (!projectPath) throw new Error('No project path selected');
|
||||||
|
const created = await createCliSession(
|
||||||
|
{
|
||||||
|
workingDir: projectPath,
|
||||||
|
preferredShell: 'bash',
|
||||||
|
resumeKey,
|
||||||
|
...createSessionDefaults,
|
||||||
|
},
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
upsertSession(created.session as unknown as CliSession);
|
||||||
|
setSelectedSessionKey(created.session.sessionKey);
|
||||||
|
return created.session.sessionKey;
|
||||||
|
}, [selectedSessionKey, projectPath, resumeKey, createSessionDefaults, upsertSession]);
|
||||||
|
|
||||||
|
// ------- handleCreateSession -------
|
||||||
|
const handleCreateSession = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (!projectPath) throw new Error('No project path selected');
|
||||||
|
const created = await createCliSession(
|
||||||
|
{
|
||||||
|
workingDir: projectPath,
|
||||||
|
preferredShell: 'bash',
|
||||||
|
resumeKey,
|
||||||
|
...createSessionDefaults,
|
||||||
|
},
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
upsertSession(created.session as unknown as CliSession);
|
||||||
|
setSelectedSessionKey(created.session.sessionKey);
|
||||||
|
// Refresh full list so store stays consistent
|
||||||
|
const r = await fetchCliSessions(projectPath || undefined);
|
||||||
|
setSessions(r.sessions as unknown as CliSession[]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, [projectPath, resumeKey, createSessionDefaults, upsertSession, setSessions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
selectedSessionKey,
|
||||||
|
setSelectedSessionKey,
|
||||||
|
refreshSessions,
|
||||||
|
ensureSession,
|
||||||
|
handleCreateSession,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
76
ccw/frontend/src/lib/queue-prompt.ts
Normal file
76
ccw/frontend/src/lib/queue-prompt.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// ========================================
|
||||||
|
// Queue Prompt Builder
|
||||||
|
// ========================================
|
||||||
|
// Unified prompt/instruction builder for queue item execution.
|
||||||
|
// Merges buildQueueItemPrompt (QueueExecuteInSession) and
|
||||||
|
// buildQueueItemInstruction (QueueSendToOrchestrator) into a single function.
|
||||||
|
|
||||||
|
import type { QueueItem } from '@/lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a context string for executing a queue item.
|
||||||
|
*
|
||||||
|
* This produces the same output that both `buildQueueItemPrompt` and
|
||||||
|
* `buildQueueItemInstruction` used to produce. The only difference between
|
||||||
|
* the two originals was that the "in-session" variant included the matched
|
||||||
|
* task block from `solution.tasks[]`; this unified version always includes
|
||||||
|
* it when available, which is strictly a superset.
|
||||||
|
*/
|
||||||
|
export function buildQueueItemContext(
|
||||||
|
item: QueueItem,
|
||||||
|
issue: any | undefined
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.push(`Queue Item: ${item.item_id}`);
|
||||||
|
lines.push(`Issue: ${item.issue_id}`);
|
||||||
|
lines.push(`Solution: ${item.solution_id}`);
|
||||||
|
if (item.task_id) lines.push(`Task: ${item.task_id}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (issue) {
|
||||||
|
if (issue.title) lines.push(`Title: ${issue.title}`);
|
||||||
|
if (issue.context) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Context:');
|
||||||
|
lines.push(String(issue.context));
|
||||||
|
}
|
||||||
|
|
||||||
|
const solution = Array.isArray(issue.solutions)
|
||||||
|
? issue.solutions.find((s: any) => s?.id === item.solution_id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (solution) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Solution Description:');
|
||||||
|
if (solution.description) lines.push(String(solution.description));
|
||||||
|
if (solution.approach) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Approach:');
|
||||||
|
lines.push(String(solution.approach));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include matched task from solution.tasks when available
|
||||||
|
const tasks = Array.isArray(solution.tasks) ? solution.tasks : [];
|
||||||
|
const task = item.task_id
|
||||||
|
? tasks.find((t: any) => t?.id === item.task_id)
|
||||||
|
: undefined;
|
||||||
|
if (task) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Task:');
|
||||||
|
if (task.title) lines.push(`- ${task.title}`);
|
||||||
|
if (task.description) lines.push(String(task.description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer instruction
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Instruction:');
|
||||||
|
lines.push(
|
||||||
|
'Implement the above queue item in this repository. Prefer small, testable changes; run relevant tests; report blockers if any.'
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -157,7 +157,9 @@
|
|||||||
"empty": "No queues"
|
"empty": "No queues"
|
||||||
},
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
"title": "Execute in Session"
|
"title": "Execute",
|
||||||
|
"sessionTab": "Session",
|
||||||
|
"orchestratorTab": "Orchestrator"
|
||||||
},
|
},
|
||||||
"orchestrator": {
|
"orchestrator": {
|
||||||
"title": "Send to Orchestrator",
|
"title": "Send to Orchestrator",
|
||||||
@@ -372,7 +374,43 @@
|
|||||||
"board": "Board",
|
"board": "Board",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"discovery": "Discovery",
|
"discovery": "Discovery",
|
||||||
"observability": "Observability"
|
"observability": "Observability",
|
||||||
|
"executions": "Executions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"executions": {
|
||||||
|
"pageTitle": "Executions",
|
||||||
|
"description": "Monitor and manage queue execution sessions",
|
||||||
|
"stats": {
|
||||||
|
"running": "Running",
|
||||||
|
"completed": "Completed",
|
||||||
|
"failed": "Failed",
|
||||||
|
"total": "Total"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Execution List",
|
||||||
|
"clearCompleted": "Clear Completed"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"selectExecution": "Select an execution to view details",
|
||||||
|
"openInTerminal": "Open in Terminal",
|
||||||
|
"id": "Execution ID",
|
||||||
|
"queueItemId": "Queue Item",
|
||||||
|
"issueId": "Issue",
|
||||||
|
"solutionId": "Solution",
|
||||||
|
"type": "Type",
|
||||||
|
"tool": "Tool",
|
||||||
|
"mode": "Mode",
|
||||||
|
"status": "Status",
|
||||||
|
"startedAt": "Started At",
|
||||||
|
"completedAt": "Completed At",
|
||||||
|
"sessionKey": "Session Key",
|
||||||
|
"flowId": "Flow ID",
|
||||||
|
"execId": "Execution ID (Orchestrator)"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"title": "No Executions",
|
||||||
|
"description": "No queue executions have been started yet"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
|
|||||||
@@ -157,7 +157,9 @@
|
|||||||
"empty": "暂无队列"
|
"empty": "暂无队列"
|
||||||
},
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
"title": "在会话中执行"
|
"title": "执行",
|
||||||
|
"sessionTab": "会话",
|
||||||
|
"orchestratorTab": "编排器"
|
||||||
},
|
},
|
||||||
"orchestrator": {
|
"orchestrator": {
|
||||||
"title": "发送到编排器",
|
"title": "发送到编排器",
|
||||||
@@ -372,7 +374,43 @@
|
|||||||
"board": "看板",
|
"board": "看板",
|
||||||
"queue": "执行队列",
|
"queue": "执行队列",
|
||||||
"discovery": "问题发现",
|
"discovery": "问题发现",
|
||||||
"observability": "可观测"
|
"observability": "可观测",
|
||||||
|
"executions": "执行"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"executions": {
|
||||||
|
"pageTitle": "执行管理",
|
||||||
|
"description": "监控和管理队列执行会话",
|
||||||
|
"stats": {
|
||||||
|
"running": "运行中",
|
||||||
|
"completed": "已完成",
|
||||||
|
"failed": "失败",
|
||||||
|
"total": "总计"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "执行列表",
|
||||||
|
"clearCompleted": "清除已完成"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"selectExecution": "选择执行以查看详情",
|
||||||
|
"openInTerminal": "在终端中打开",
|
||||||
|
"id": "执行 ID",
|
||||||
|
"queueItemId": "队列项",
|
||||||
|
"issueId": "问题",
|
||||||
|
"solutionId": "解决方案",
|
||||||
|
"type": "类型",
|
||||||
|
"tool": "工具",
|
||||||
|
"mode": "模式",
|
||||||
|
"status": "状态",
|
||||||
|
"startedAt": "开始时间",
|
||||||
|
"completedAt": "完成时间",
|
||||||
|
"sessionKey": "会话 Key",
|
||||||
|
"flowId": "流程 ID",
|
||||||
|
"execId": "执行 ID (编排器)"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"title": "暂无执行",
|
||||||
|
"description": "尚未启动任何队列执行"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel';
|
|||||||
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
||||||
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
||||||
import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel';
|
import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel';
|
||||||
|
import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||||
@@ -197,6 +198,9 @@ export function IssueHubPage() {
|
|||||||
case 'observability':
|
case 'observability':
|
||||||
return null; // Observability panel has its own controls
|
return null; // Observability panel has its own controls
|
||||||
|
|
||||||
|
case 'executions':
|
||||||
|
return null; // Execution panel has its own controls
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -222,6 +226,7 @@ export function IssueHubPage() {
|
|||||||
{currentTab === 'queue' && <QueuePanel />}
|
{currentTab === 'queue' && <QueuePanel />}
|
||||||
{currentTab === 'discovery' && <DiscoveryPanel />}
|
{currentTab === 'discovery' && <DiscoveryPanel />}
|
||||||
{currentTab === 'observability' && <ObservabilityPanel />}
|
{currentTab === 'observability' && <ObservabilityPanel />}
|
||||||
|
{currentTab === 'executions' && <ExecutionPanel />}
|
||||||
|
|
||||||
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,6 +101,16 @@ export {
|
|||||||
selectTerminalCount,
|
selectTerminalCount,
|
||||||
} from './terminalPanelStore';
|
} from './terminalPanelStore';
|
||||||
|
|
||||||
|
// Queue Execution Store
|
||||||
|
export {
|
||||||
|
useQueueExecutionStore,
|
||||||
|
selectQueueExecutions,
|
||||||
|
selectActiveExecutions,
|
||||||
|
selectByQueueItem,
|
||||||
|
selectExecutionStats,
|
||||||
|
selectHasActiveExecution,
|
||||||
|
} from './queueExecutionStore';
|
||||||
|
|
||||||
// Terminal Panel Store Types
|
// Terminal Panel Store Types
|
||||||
export type {
|
export type {
|
||||||
PanelView,
|
PanelView,
|
||||||
@@ -109,6 +119,18 @@ export type {
|
|||||||
TerminalPanelStore,
|
TerminalPanelStore,
|
||||||
} from './terminalPanelStore';
|
} from './terminalPanelStore';
|
||||||
|
|
||||||
|
// Queue Execution Store Types
|
||||||
|
export type {
|
||||||
|
QueueExecutionType,
|
||||||
|
QueueExecutionStatus,
|
||||||
|
QueueExecutionMode,
|
||||||
|
QueueExecution,
|
||||||
|
QueueExecutionStats,
|
||||||
|
QueueExecutionState,
|
||||||
|
QueueExecutionActions,
|
||||||
|
QueueExecutionStore,
|
||||||
|
} from './queueExecutionStore';
|
||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type {
|
export type {
|
||||||
// App Store Types
|
// App Store Types
|
||||||
|
|||||||
191
ccw/frontend/src/stores/queueExecutionStore.ts
Normal file
191
ccw/frontend/src/stores/queueExecutionStore.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// ========================================
|
||||||
|
// Queue Execution Store
|
||||||
|
// ========================================
|
||||||
|
// Zustand store for unified queue execution state management.
|
||||||
|
// Tracks both InSession and Orchestrator execution paths,
|
||||||
|
// bridging them into a single observable state for UI consumption.
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
|
||||||
|
// ========== Types ==========
|
||||||
|
|
||||||
|
export type QueueExecutionType = 'session' | 'orchestrator';
|
||||||
|
|
||||||
|
export type QueueExecutionStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||||
|
|
||||||
|
export type QueueExecutionMode = 'analysis' | 'write';
|
||||||
|
|
||||||
|
export interface QueueExecution {
|
||||||
|
/** Unique execution identifier */
|
||||||
|
id: string;
|
||||||
|
/** Associated queue item ID */
|
||||||
|
queueItemId: string;
|
||||||
|
/** Associated issue ID */
|
||||||
|
issueId: string;
|
||||||
|
/** Associated solution ID */
|
||||||
|
solutionId: string;
|
||||||
|
/** Execution path type */
|
||||||
|
type: QueueExecutionType;
|
||||||
|
/** CLI session key (session type only) */
|
||||||
|
sessionKey?: string;
|
||||||
|
/** Orchestrator flow ID (orchestrator type only) */
|
||||||
|
flowId?: string;
|
||||||
|
/** Orchestrator execution ID (orchestrator type only) */
|
||||||
|
execId?: string;
|
||||||
|
/** CLI tool used for execution */
|
||||||
|
tool: string;
|
||||||
|
/** Execution mode */
|
||||||
|
mode: QueueExecutionMode;
|
||||||
|
/** Current execution status */
|
||||||
|
status: QueueExecutionStatus;
|
||||||
|
/** ISO timestamp when execution started */
|
||||||
|
startedAt: string;
|
||||||
|
/** ISO timestamp when execution completed or failed */
|
||||||
|
completedAt?: string;
|
||||||
|
/** Error message if execution failed */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueExecutionStats {
|
||||||
|
running: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueExecutionState {
|
||||||
|
/** All tracked executions keyed by execution ID */
|
||||||
|
executions: Record<string, QueueExecution>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueExecutionActions {
|
||||||
|
/** Add a new execution to the store */
|
||||||
|
addExecution: (exec: QueueExecution) => void;
|
||||||
|
/** Update the status of an existing execution */
|
||||||
|
updateStatus: (id: string, status: QueueExecutionStatus, error?: string) => void;
|
||||||
|
/** Remove a single execution by ID */
|
||||||
|
removeExecution: (id: string) => void;
|
||||||
|
/** Remove all completed and failed executions */
|
||||||
|
clearCompleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueueExecutionStore = QueueExecutionState & QueueExecutionActions;
|
||||||
|
|
||||||
|
// ========== Initial State ==========
|
||||||
|
|
||||||
|
const initialState: QueueExecutionState = {
|
||||||
|
executions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Store ==========
|
||||||
|
|
||||||
|
export const useQueueExecutionStore = create<QueueExecutionStore>()(
|
||||||
|
devtools(
|
||||||
|
(set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// ========== Execution Lifecycle ==========
|
||||||
|
|
||||||
|
addExecution: (exec: QueueExecution) => {
|
||||||
|
set(
|
||||||
|
(state) => ({
|
||||||
|
executions: {
|
||||||
|
...state.executions,
|
||||||
|
[exec.id]: exec,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
'addExecution'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStatus: (id: string, status: QueueExecutionStatus, error?: string) => {
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const existing = state.executions[id];
|
||||||
|
if (!existing) return state;
|
||||||
|
|
||||||
|
const isTerminal = status === 'completed' || status === 'failed';
|
||||||
|
return {
|
||||||
|
executions: {
|
||||||
|
...state.executions,
|
||||||
|
[id]: {
|
||||||
|
...existing,
|
||||||
|
status,
|
||||||
|
completedAt: isTerminal ? new Date().toISOString() : existing.completedAt,
|
||||||
|
error: error ?? existing.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'updateStatus'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeExecution: (id: string) => {
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const { [id]: _removed, ...remaining } = state.executions;
|
||||||
|
return { executions: remaining };
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'removeExecution'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCompleted: () => {
|
||||||
|
set(
|
||||||
|
(state) => {
|
||||||
|
const active: Record<string, QueueExecution> = {};
|
||||||
|
for (const [id, exec] of Object.entries(state.executions)) {
|
||||||
|
if (exec.status !== 'completed' && exec.status !== 'failed') {
|
||||||
|
active[id] = exec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { executions: active };
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
'clearCompleted'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'QueueExecutionStore' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== Selectors ==========
|
||||||
|
|
||||||
|
/** Select all executions as a record */
|
||||||
|
export const selectQueueExecutions = (state: QueueExecutionStore) => state.executions;
|
||||||
|
|
||||||
|
/** Select only currently running executions */
|
||||||
|
export const selectActiveExecutions = (state: QueueExecutionStore): QueueExecution[] => {
|
||||||
|
return Object.values(state.executions).filter((exec) => exec.status === 'running');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Select executions for a specific queue item */
|
||||||
|
export const selectByQueueItem =
|
||||||
|
(queueItemId: string) =>
|
||||||
|
(state: QueueExecutionStore): QueueExecution[] => {
|
||||||
|
return Object.values(state.executions).filter(
|
||||||
|
(exec) => exec.queueItemId === queueItemId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Compute execution statistics by status */
|
||||||
|
export const selectExecutionStats = (state: QueueExecutionStore): QueueExecutionStats => {
|
||||||
|
const all = Object.values(state.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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Check if any execution is currently running */
|
||||||
|
export const selectHasActiveExecution = (state: QueueExecutionStore): boolean => {
|
||||||
|
return Object.values(state.executions).some((exec) => exec.status === 'running');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user