// ======================================== // Issue Board Panel // ======================================== // Kanban board view for issues (status-driven) with local ordering. import { useCallback, useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import type { DropResult } from '@hello-pangea/dnd'; import { AlertCircle, LayoutGrid } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Switch } from '@/components/ui/Switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'; import { KanbanBoard, type KanbanColumn, type KanbanItem } from '@/components/shared/KanbanBoard'; import { IssueCard } from '@/components/shared/IssueCard'; import { IssueDrawer } from '@/components/issue/hub/IssueDrawer'; import { cn } from '@/lib/utils'; import { useIssues, useIssueMutations } from '@/hooks'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { createCliSession, executeInCliSession } from '@/lib/api'; import type { Issue } from '@/lib/api'; import { useTerminalPanelStore } from '@/stores/terminalPanelStore'; type IssueBoardStatus = Issue['status']; type ToolName = 'claude' | 'codex' | 'gemini'; type ResumeStrategy = 'nativeResume' | 'promptConcat'; const BOARD_COLUMNS: Array<{ id: IssueBoardStatus; titleKey: string }> = [ { id: 'registered', titleKey: 'issues.status.registered' }, { id: 'planning', titleKey: 'issues.status.planning' }, { id: 'planned', titleKey: 'issues.status.planned' }, { id: 'executing', titleKey: 'issues.status.executing' }, { id: 'completed', titleKey: 'issues.status.completed' }, { id: 'failed', titleKey: 'issues.status.failed' }, ]; type BoardOrder = Partial>; function storageKey(projectPath: string | null | undefined): string { const base = projectPath ? encodeURIComponent(projectPath) : 'global'; return `ccw.issueBoard.order:${base}`; } interface AutoStartConfig { enabled: boolean; tool: ToolName; mode: 'analysis' | 'write'; resumeStrategy: ResumeStrategy; } function autoStartStorageKey(projectPath: string | null | undefined): string { const base = projectPath ? encodeURIComponent(projectPath) : 'global'; return `ccw.issueBoard.autoStart:${base}`; } function safeParseAutoStart(value: string | null): AutoStartConfig { const defaults: AutoStartConfig = { enabled: false, tool: 'claude', mode: 'write', resumeStrategy: 'nativeResume', }; if (!value) return defaults; try { const parsed = JSON.parse(value) as Partial; return { enabled: Boolean(parsed.enabled), tool: parsed.tool === 'codex' || parsed.tool === 'gemini' ? parsed.tool : 'claude', mode: parsed.mode === 'analysis' ? 'analysis' : 'write', resumeStrategy: parsed.resumeStrategy === 'promptConcat' ? 'promptConcat' : 'nativeResume', }; } catch { return defaults; } } function safeParseOrder(value: string | null): BoardOrder { if (!value) return {}; try { const parsed = JSON.parse(value) as unknown; if (!parsed || typeof parsed !== 'object') return {}; return parsed as BoardOrder; } catch { return {}; } } function buildColumns( issues: Issue[], order: BoardOrder, formatTitle: (statusId: IssueBoardStatus) => string ): KanbanColumn[] { const byId = new Map(issues.map((i) => [i.id, i])); const columns: KanbanColumn[] = []; for (const col of BOARD_COLUMNS) { const desired = (order[col.id] ?? []).map((id) => byId.get(id)).filter(Boolean) as Issue[]; const desiredIds = new Set(desired.map((i) => i.id)); const remaining = issues .filter((i) => i.status === col.id && !desiredIds.has(i.id)) .sort((a, b) => { const at = a.updatedAt || a.createdAt; const bt = b.updatedAt || b.createdAt; return bt.localeCompare(at); }); const items = [...desired, ...remaining].map((issue) => ({ ...issue, id: issue.id, title: issue.title, status: issue.status, })); columns.push({ id: col.id, title: formatTitle(col.id), items, icon: , }); } return columns; } function syncOrderWithIssues(prev: BoardOrder, issues: Issue[]): BoardOrder { const statusById = new Map(issues.map((i) => [i.id, i.status])); const next: BoardOrder = {}; for (const { id: status } of BOARD_COLUMNS) { const existing = prev[status] ?? []; const filtered = existing.filter((id) => statusById.get(id) === status); const present = new Set(filtered); const missing = issues .filter((i) => i.status === status && !present.has(i.id)) .map((i) => i.id); next[status] = [...filtered, ...missing]; } return next; } function reorderIds(list: string[], from: number, to: number): string[] { const next = [...list]; const [moved] = next.splice(from, 1); if (moved === undefined) return list; next.splice(to, 0, moved); return next; } function buildIssueAutoPrompt(issue: Issue): string { const lines: string[] = []; lines.push(`Issue: ${issue.id}`); lines.push(`Status: ${issue.status}`); lines.push(`Priority: ${issue.priority}`); lines.push(''); lines.push(`Title: ${issue.title}`); if (issue.context) { lines.push(''); lines.push('Context:'); lines.push(String(issue.context)); } if (Array.isArray(issue.solutions) && issue.solutions.length > 0) { lines.push(''); lines.push('Solutions:'); for (const s of issue.solutions) { lines.push(`- [${s.status}] ${s.description}`); if (s.approach) lines.push(` Approach: ${s.approach}`); } } lines.push(''); lines.push('Instruction:'); lines.push( 'Start working on this issue in this repository. Prefer small, testable changes; run relevant tests; report blockers if any.' ); return lines.join('\n'); } import { useNotificationStore } from '@/stores'; // ... export function IssueBoardPanel() { const { formatMessage } = useIntl(); const projectPath = useWorkflowStore(selectProjectPath); const { addToast } = useNotificationStore(); const { issues, isLoading, error } = useIssues(); const { updateIssue } = useIssueMutations(); // ... } const [order, setOrder] = useState({}); const [selectedIssue, setSelectedIssue] = useState(null); const [drawerInitialTab, setDrawerInitialTab] = useState<'overview' | 'terminal'>('overview'); const [optimisticError, setOptimisticError] = useState(null); const [autoStart, setAutoStart] = useState(() => safeParseAutoStart(null)); // Load order when project changes useEffect(() => { const key = storageKey(projectPath); const loaded = safeParseOrder(localStorage.getItem(key)); setOrder(loaded); }, [projectPath]); // Load auto-start config when project changes useEffect(() => { const key = autoStartStorageKey(projectPath); setAutoStart(safeParseAutoStart(localStorage.getItem(key))); }, [projectPath]); // Keep order consistent with current issues (status moves, deletions, new issues) useEffect(() => { setOrder((prev) => syncOrderWithIssues(prev, issues)); }, [issues]); // Persist order useEffect(() => { const key = storageKey(projectPath); try { localStorage.setItem(key, JSON.stringify(order)); } catch { // ignore quota errors } }, [order, projectPath]); // Persist auto-start config useEffect(() => { const key = autoStartStorageKey(projectPath); try { localStorage.setItem(key, JSON.stringify(autoStart)); } catch { // ignore quota errors } }, [autoStart, projectPath]); const columns = useMemo( () => buildColumns(issues, order, (statusId) => { const col = BOARD_COLUMNS.find((c) => c.id === statusId); if (!col) return statusId; return formatMessage({ id: col.titleKey }); }), [issues, order, formatMessage] ); const idsByStatus = useMemo(() => { const map: Record = {}; for (const col of columns) { map[col.id] = col.items.map((i) => i.id); } return map; }, [columns]); const handleItemClick = useCallback((issue: Issue) => { setDrawerInitialTab('overview'); setSelectedIssue(issue); }, []); const handleCloseDrawer = useCallback(() => { setSelectedIssue(null); setOptimisticError(null); }, []); const handleDragEnd = useCallback( async (result: DropResult, sourceColumn: string, destColumn: string) => { const issueId = result.draggableId; const issue = issues.find((i) => i.id === issueId); if (!issue) return; setOptimisticError(null); const sourceStatus = sourceColumn as IssueBoardStatus; const destStatus = destColumn as IssueBoardStatus; const sourceIds = idsByStatus[sourceStatus] ?? []; const destIds = idsByStatus[destStatus] ?? []; // Update local order first (optimistic) setOrder((prev) => { const next = { ...prev }; if (sourceStatus === destStatus) { next[sourceStatus] = reorderIds(sourceIds, result.source.index, result.destination!.index); return next; } const nextSource = [...sourceIds]; nextSource.splice(result.source.index, 1); const nextDest = [...destIds]; nextDest.splice(result.destination!.index, 0, issueId); next[sourceStatus] = nextSource; next[destStatus] = nextDest; return next; }); // Status update if (sourceStatus !== destStatus) { try { await updateIssue(issueId, { status: destStatus }); // Auto action: drag to executing opens the drawer on terminal tab. if (destStatus === 'executing' && sourceStatus !== 'executing') { setDrawerInitialTab('terminal'); setSelectedIssue({ ...issue, status: destStatus }); if (autoStart.enabled) { if (!projectPath) { setOptimisticError('Auto-start failed: no project path selected'); return; } try { const created = await createCliSession({ workingDir: projectPath, preferredShell: 'bash', tool: autoStart.tool, resumeKey: issueId, }, projectPath); await executeInCliSession(created.session.sessionKey, { tool: autoStart.tool, prompt: buildIssueAutoPrompt({ ...issue, status: destStatus }), mode: autoStart.mode, resumeKey: issueId, resumeStrategy: autoStart.resumeStrategy, }, projectPath); // Auto-open terminal panel to show execution output useTerminalPanelStore.getState().openTerminal(created.session.sessionKey); } catch (e) { const errorMsg = `Auto-start failed: ${e instanceof Error ? e.message : String(e)}`; setOptimisticError(errorMsg); addToast('error', errorMsg); } } } } catch (e) { setOptimisticError(e instanceof Error ? e.message : String(e)); } } }, [autoStart, issues, idsByStatus, projectPath, updateIssue, addToast] ); if (error) { return (

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

{error.message}

); } return ( <>
setAutoStart((prev) => ({ ...prev, enabled: checked }))} />
{formatMessage({ id: 'issues.board.autoStart.label' })}
{optimisticError && (
{optimisticError}
)} columns={columns} onDragEnd={handleDragEnd} onItemClick={(item) => handleItemClick(item as unknown as Issue)} isLoading={isLoading} emptyColumnMessage={formatMessage({ id: 'issues.emptyState.message' })} className={cn('gap-4', 'grid')} renderItem={(item, provided) => ( handleItemClick(i)} innerRef={provided.innerRef} draggableProps={provided.draggableProps} dragHandleProps={provided.dragHandleProps} className="w-full" /> )} /> ); } export default IssueBoardPanel;