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 (
+
+
+
+
+ {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 && (
+
+
+
+ )}
+
+ {/* 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');
+};