diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 850d3f28..00000000 --- a/.claude/settings.json +++ /dev/null @@ -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\\\"})}\"" - } - ] - } - ] - } -} \ No newline at end of file diff --git a/assets/wechat-group-qr.png b/assets/wechat-group-qr.png index 1d7d29e0..73382dc5 100644 Binary files a/assets/wechat-group-qr.png and b/assets/wechat-group-qr.png differ diff --git a/ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx b/ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx new file mode 100644 index 00000000..e4f778b1 --- /dev/null +++ b/ccw/frontend/src/components/issue/hub/ExecutionPanel.tsx @@ -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 ; + case 'completed': + return ; + case 'failed': + return ; + case 'pending': + default: + return ; + } +} + +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 ( + + +

+ {formatMessage({ id: 'issues.executions.emptyState.title' })} +

+

+ {formatMessage({ id: 'issues.executions.emptyState.description' })} +

+
+ ); +} + +// ========== Stats Cards ========== + +function ExecutionStatsCards() { + const { formatMessage } = useIntl(); + const stats = useQueueExecutionStore(selectExecutionStats); + + return ( +
+ +
+ + {stats.running} +
+

+ {formatMessage({ id: 'issues.executions.stats.running' })} +

+
+ +
+ + {stats.completed} +
+

+ {formatMessage({ id: 'issues.executions.stats.completed' })} +

+
+ +
+ + {stats.failed} +
+

+ {formatMessage({ id: 'issues.executions.stats.failed' })} +

+
+ +
+ + {stats.total} +
+

+ {formatMessage({ id: 'issues.executions.stats.total' })} +

+
+
+ ); +} + +// ========== Execution List Item ========== + +function ExecutionListItem({ + execution, + isSelected, + onSelect, +}: { + execution: QueueExecution; + isSelected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +// ========== Execution Detail View ========== + +function ExecutionDetailView({ execution }: { execution: QueueExecution | null }) { + const { formatMessage } = useIntl(); + const openTerminal = useTerminalPanelStore((s) => s.openTerminal); + + if (!execution) { + return ( +
+ {formatMessage({ id: 'issues.executions.detail.selectExecution' })} +
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+ {statusIcon(execution.status)} + {execution.id} + {execution.status} +
+ {execution.type === 'session' && execution.sessionKey && ( + + )} +
+ + {/* Error Banner */} + {execution.error && ( + +
+ +

{execution.error}

+
+
+ )} + + {/* Detail Table */} + +
+ {detailRows.map((row) => ( +
+ {row.label} + + {row.value || '-'} + +
+ ))} +
+
+
+ ); +} + +// ========== 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(null); + + // Sort executions: running first, then pending, then by startedAt descending + const sortedExecutions = useMemo(() => { + const all = Object.values(executions); + const statusOrder: Record = { + 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 ( +
+ + +
+ ); + } + + return ( +
+ {/* Stats Cards */} + + + {/* Split View */} +
+ {/* Left: Execution List */} + +
+

+ {formatMessage({ id: 'issues.executions.list.title' })} +

+ {hasCompletedOrFailed && ( + + )} +
+
+ {sortedExecutions.map((exec) => ( + setSelectedId(exec.id)} + /> + ))} +
+
+ + {/* Right: Detail View */} + + + +
+
+ ); +} + +export default ExecutionPanel; diff --git a/ccw/frontend/src/components/issue/hub/IssueHubHeader.tsx b/ccw/frontend/src/components/issue/hub/IssueHubHeader.tsx index f61621c0..cd82a882 100644 --- a/ccw/frontend/src/components/issue/hub/IssueHubHeader.tsx +++ b/ccw/frontend/src/components/issue/hub/IssueHubHeader.tsx @@ -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: , + title: formatMessage({ id: 'issues.executions.pageTitle' }), + description: formatMessage({ id: 'issues.executions.description' }), + }, }; const config = tabConfig[currentTab]; diff --git a/ccw/frontend/src/components/issue/hub/IssueHubTabs.tsx b/ccw/frontend/src/components/issue/hub/IssueHubTabs.tsx index c50f8792..442321ba 100644 --- a/ccw/frontend/src/components/issue/hub/IssueHubTabs.tsx +++ b/ccw/frontend/src/components/issue/hub/IssueHubTabs.tsx @@ -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 ( diff --git a/ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx b/ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx new file mode 100644 index 00000000..17f38265 --- /dev/null +++ b/ccw/frontend/src/components/issue/queue/QueueItemExecutor.tsx @@ -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('claude'); + const [mode, setMode] = useState('write'); + const [resumeStrategy, setResumeStrategy] = useState('nativeResume'); + + // Execution state + const [activeTab, setActiveTab] = useState('session'); + const [isExecuting, setIsExecuting] = useState(false); + const [error, setError] = useState(null); + const [lastResult, setLastResult] = useState(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 ( +
+ {/* Header with session controls */} +
+

+ {formatMessage({ id: 'issues.queue.exec.title' })} +

+
+ + +
+
+ + {/* Session selector */} +
+ + +
+ + {/* Shared execution settings */} + + + {/* Execution mode tabs */} + setActiveTab(v as ExecutionTab)} + className="w-full" + > + + + + {formatMessage({ id: 'issues.queue.exec.sessionTab' })} + + + + {formatMessage({ id: 'issues.queue.exec.orchestratorTab' })} + + + + {/* Session Tab */} + +
+ +
+
+ + {/* Orchestrator Tab */} + +
+ +
+
+
+ + {/* Error display */} + {displayError && ( +
{displayError}
+ )} + + {/* Last result */} + {lastResult && ( +
+ {lastResult} +
+ )} +
+ ); +} + +export default QueueItemExecutor; diff --git a/ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx b/ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx index 9f9d3507..1b927e10 100644 --- a/ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx +++ b/ccw/frontend/src/components/issue/queue/SolutionDrawer.tsx @@ -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) { - {/* Execute in Session */} - - - {/* Send to Orchestrator */} - + {/* Unified Execution */} + {/* Dependencies */} {item.depends_on && item.depends_on.length > 0 && ( diff --git a/ccw/frontend/src/components/shared/CliExecutionSettings.tsx b/ccw/frontend/src/components/shared/CliExecutionSettings.tsx new file mode 100644 index 00000000..92a840f2 --- /dev/null +++ b/ccw/frontend/src/components/shared/CliExecutionSettings.tsx @@ -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 ( + + +
+ {/* Tool selector */} +
+ + +
+ + {/* Mode selector */} +
+ + +
+ + {/* Resume strategy selector */} +
+ + +
+
+
+
+ ); +} diff --git a/ccw/frontend/src/components/shared/index.ts b/ccw/frontend/src/components/shared/index.ts index 28a5cfaa..475667f8 100644 --- a/ccw/frontend/src/components/shared/index.ts +++ b/ccw/frontend/src/components/shared/index.ts @@ -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'; diff --git a/ccw/frontend/src/hooks/index.ts b/ccw/frontend/src/hooks/index.ts index 4bc3302a..5236b44a 100644 --- a/ccw/frontend/src/hooks/index.ts +++ b/ccw/frontend/src/hooks/index.ts @@ -243,6 +243,13 @@ export type { UseCliExecutionReturn, } from './useCliExecution'; +// ========== CLI Session Core ========== +export { useCliSessionCore } from './useCliSessionCore'; +export type { + UseCliSessionCoreOptions, + UseCliSessionCoreReturn, +} from './useCliSessionCore'; + // ========== Workspace Query Keys ========== export { useWorkspaceQueryKeys, diff --git a/ccw/frontend/src/hooks/useCliSessionCore.ts b/ccw/frontend/src/hooks/useCliSessionCore.ts new file mode 100644 index 00000000..58000db8 --- /dev/null +++ b/ccw/frontend/src/hooks/useCliSessionCore.ts @@ -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; + /** Return the current session key, creating one if none is selected. */ + ensureSession: () => Promise; + /** Explicitly create a new session and select it. */ + handleCreateSession: () => Promise; + /** 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(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 => { + 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, + }; +} diff --git a/ccw/frontend/src/lib/queue-prompt.ts b/ccw/frontend/src/lib/queue-prompt.ts new file mode 100644 index 00000000..8ca16f82 --- /dev/null +++ b/ccw/frontend/src/lib/queue-prompt.ts @@ -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'); +} diff --git a/ccw/frontend/src/locales/en/issues.json b/ccw/frontend/src/locales/en/issues.json index d5c8d391..ff00986d 100644 --- a/ccw/frontend/src/locales/en/issues.json +++ b/ccw/frontend/src/locales/en/issues.json @@ -157,7 +157,9 @@ "empty": "No queues" }, "exec": { - "title": "Execute in Session" + "title": "Execute", + "sessionTab": "Session", + "orchestratorTab": "Orchestrator" }, "orchestrator": { "title": "Send to Orchestrator", @@ -372,7 +374,43 @@ "board": "Board", "queue": "Queue", "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": { diff --git a/ccw/frontend/src/locales/zh/issues.json b/ccw/frontend/src/locales/zh/issues.json index 084d2721..43f18af8 100644 --- a/ccw/frontend/src/locales/zh/issues.json +++ b/ccw/frontend/src/locales/zh/issues.json @@ -157,7 +157,9 @@ "empty": "暂无队列" }, "exec": { - "title": "在会话中执行" + "title": "执行", + "sessionTab": "会话", + "orchestratorTab": "编排器" }, "orchestrator": { "title": "发送到编排器", @@ -372,7 +374,43 @@ "board": "看板", "queue": "执行队列", "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": { diff --git a/ccw/frontend/src/pages/IssueHubPage.tsx b/ccw/frontend/src/pages/IssueHubPage.tsx index 37929fdc..a696957b 100644 --- a/ccw/frontend/src/pages/IssueHubPage.tsx +++ b/ccw/frontend/src/pages/IssueHubPage.tsx @@ -19,6 +19,7 @@ import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel'; import { QueuePanel } from '@/components/issue/hub/QueuePanel'; import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel'; import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel'; +import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog'; @@ -197,6 +198,9 @@ export function IssueHubPage() { case 'observability': return null; // Observability panel has its own controls + case 'executions': + return null; // Execution panel has its own controls + default: return null; } @@ -222,6 +226,7 @@ export function IssueHubPage() { {currentTab === 'queue' && } {currentTab === 'discovery' && } {currentTab === 'observability' && } + {currentTab === 'executions' && } diff --git a/ccw/frontend/src/stores/index.ts b/ccw/frontend/src/stores/index.ts index 4d2a01d8..4d132fff 100644 --- a/ccw/frontend/src/stores/index.ts +++ b/ccw/frontend/src/stores/index.ts @@ -101,6 +101,16 @@ export { selectTerminalCount, } from './terminalPanelStore'; +// Queue Execution Store +export { + useQueueExecutionStore, + selectQueueExecutions, + selectActiveExecutions, + selectByQueueItem, + selectExecutionStats, + selectHasActiveExecution, +} from './queueExecutionStore'; + // Terminal Panel Store Types export type { PanelView, @@ -109,6 +119,18 @@ export type { TerminalPanelStore, } from './terminalPanelStore'; +// Queue Execution Store Types +export type { + QueueExecutionType, + QueueExecutionStatus, + QueueExecutionMode, + QueueExecution, + QueueExecutionStats, + QueueExecutionState, + QueueExecutionActions, + QueueExecutionStore, +} from './queueExecutionStore'; + // Re-export types for convenience export type { // App Store Types diff --git a/ccw/frontend/src/stores/queueExecutionStore.ts b/ccw/frontend/src/stores/queueExecutionStore.ts new file mode 100644 index 00000000..aea86a16 --- /dev/null +++ b/ccw/frontend/src/stores/queueExecutionStore.ts @@ -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; +} + +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()( + 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 = {}; + 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'); +};