'use client'; import { useMemo, useState } from 'react'; import { ChevronDown, ChevronRight, Folder, FolderOpen, Star } from 'lucide-react'; import type { BeadIssue } from '../../lib/types'; import { cn } from '../../lib/utils'; import { useUrlState, type ViewType } from '../../hooks/use-url-state'; export type LeftPanelStatusFilter = 'all' | 'ready' | 'in_progress' | 'blocked' | 'deferred' | 'done'; export type LeftPanelPriorityFilter = 'all' | 'P0' | 'P1' | 'P2' | 'P3' | 'P4'; export type LeftPanelPresetFilter = 'all' | 'active' | 'blocked_agents'; export interface LeftPanelFilters { query: string; status: LeftPanelStatusFilter; priority: LeftPanelPriorityFilter; preset: LeftPanelPresetFilter; hideClosed: boolean; } export interface LeftPanelProps { issues: BeadIssue[]; selectedEpicId?: string | null; onEpicSelect?: (epicId: string | null) => void; filters: LeftPanelFilters; onFiltersChange: (filters: LeftPanelFilters) => void; } interface EpicEntry { epic: BeadIssue; children: BeadIssue[]; blockedCount: number; activeCount: number; readyCount: number; deferredCount: number; doneCount: number; agentBlockedCount: number; latestTimestamp: string; } function mapStatus(task: BeadIssue): LeftPanelStatusFilter { if (task.status === 'open') return 'ready'; if (task.status === 'in_progress') return 'in_progress'; if (task.status === 'blocked') return 'blocked'; if (task.status === 'closed' || task.status === 'tombstone') return 'done'; return 'deferred'; } function mapPriority(task: BeadIssue): LeftPanelPriorityFilter { if (task.priority <= 0) return 'P0'; if (task.priority === 1) return 'P1'; if (task.priority === 2) return 'P2'; if (task.priority === 3) return 'P3'; return 'P4'; } function formatRelative(timestamp: string): string { const then = new Date(timestamp); const now = new Date(); const diffMinutes = Math.floor((now.getTime() - then.getTime()) / 60000); if (diffMinutes < 1) return 'just now'; if (diffMinutes < 60) return `${diffMinutes}m ago`; const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) return `${diffHours}h ago`; const diffDays = Math.floor(diffHours / 24); return `${diffDays}d ago`; } function buildEntries(issues: BeadIssue[]): EpicEntry[] { const epics = issues.filter((issue) => issue.issue_type === 'epic'); const tasks = issues.filter((issue) => issue.issue_type !== 'epic'); const taskById = new Map(tasks.map((task) => [task.id, task] as const)); const incomingBlockers = new Map(); for (const task of tasks) { incomingBlockers.set(task.id, []); } for (const task of tasks) { for (const dependency of task.dependencies) { if (dependency.type !== 'blocks') continue; if (!taskById.has(dependency.target)) continue; const current = incomingBlockers.get(dependency.target) ?? []; current.push(task.id); incomingBlockers.set(dependency.target, current); } } const isEffectivelyBlocked = (task: BeadIssue): boolean => { if (task.status === 'blocked') return true; if (task.status === 'closed' || task.status === 'tombstone') return false; const blockers = incomingBlockers.get(task.id) ?? []; return blockers.some((blockerId) => { const blocker = taskById.get(blockerId); return blocker ? blocker.status !== 'closed' && blocker.status !== 'tombstone' : false; }); }; return epics .map((epic) => { const children = tasks .filter((task) => task.dependencies.some((dep) => dep.type === 'parent' && dep.target === epic.id)) .sort((a, b) => a.id.localeCompare(b.id)); const blockedCount = children.filter((task) => isEffectivelyBlocked(task)).length; const activeCount = children.filter((task) => task.status === 'in_progress').length; const readyCount = children.filter((task) => task.status === 'open' && !isEffectivelyBlocked(task)).length; const deferredCount = children.filter((task) => task.status === 'deferred').length; const doneCount = children.filter((task) => task.status === 'closed' || task.status === 'tombstone').length; const agentBlockedCount = children.filter( (task) => isEffectivelyBlocked(task) && (Boolean(task.assignee) || task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:') || label.startsWith('gt:agent:'))), ).length; const latestTimestamp = [epic.updated_at, ...children.map((child) => child.updated_at)] .sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? epic.updated_at; return { epic, children, blockedCount, activeCount, readyCount, deferredCount, doneCount, agentBlockedCount, latestTimestamp, }; }) .sort((a, b) => a.epic.id.localeCompare(b.epic.id)); } function statusDot(status: BeadIssue['status']): string { if (status === 'blocked') return 'bg-[var(--ui-accent-blocked)]'; if (status === 'in_progress') return 'bg-[var(--ui-accent-warning)]'; if (status === 'closed') return 'bg-[var(--ui-text-muted)]'; return 'bg-[var(--ui-accent-ready)]'; } function rowTone(entry: EpicEntry): string { if (entry.blockedCount > 0) { return '#22111a'; } if (entry.activeCount > 0) { return '#221a11'; } if (entry.readyCount > 0) { return '#0f221c'; } return '#111f2b'; } function isTaskMatch(task: BeadIssue, filters: LeftPanelFilters): boolean { if (filters.hideClosed && (task.status === 'closed' || task.status === 'tombstone')) return false; const normalizedQuery = filters.query.trim().toLowerCase(); if (normalizedQuery.length > 0) { const searchable = `${task.id} ${task.title} ${task.labels.join(' ')}`.toLowerCase(); if (!searchable.includes(normalizedQuery)) return false; } if (filters.status !== 'all' && mapStatus(task) !== filters.status) return false; if (filters.priority !== 'all' && mapPriority(task) !== filters.priority) return false; if (filters.preset === 'active' && task.status !== 'in_progress') return false; if ( filters.preset === 'blocked_agents' && !( task.status === 'blocked' && (Boolean(task.assignee) || task.labels.some((label) => label === 'gt:agent' || label.startsWith('agent:'))) ) ) { return false; } return true; } export function LeftPanel({ issues, selectedEpicId, onEpicSelect, filters, onFiltersChange }: LeftPanelProps) { const { view, setView } = useUrlState(); const entries = useMemo(() => buildEntries(issues), [issues]); const [expanded, setExpanded] = useState>({}); const hasActiveFilters = filters.query.trim().length > 0 || filters.status !== 'all' || filters.priority !== 'all' || filters.preset !== 'all' || filters.hideClosed; const views: Array<{ id: ViewType; label: string }> = [ { id: 'social', label: 'Social' }, { id: 'graph', label: 'Graph' }, ]; return ( ); } export default LeftPanel;