import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Box, FolderTree, Layers3, PencilLine, RefreshCw, RotateCcw, UserRound, Workflow } from 'lucide-react'; import { motion } from 'framer-motion'; import { supabase } from '../services/supabase'; import { useAuth } from '../context/useAuth'; interface SpatialDashboardProps { selectedProjectId: string | null; onSelectProject: (projectId: string) => void; onOpenTask: (projectId: string, taskId: string) => void; } interface ProjectSummary { id: string; name: string; status: string; } interface AgentSummary { id: string; name: string; role?: string | null; model: string; } interface TaskNode { id: string; title: string; description: string | null; status: string; priority?: number; project_id: string; assigned_agent_id?: string | null; } interface TaskDependency { task_id: string; depends_on_task_id: string; } interface EdgeLine { fromX: number; fromY: number; toX: number; toY: number; key: string; } const statusLabels: Record = { todo: 'Queued', in_progress: 'Running', awaiting_approval: 'Review', done: 'Done', failed: 'Failed', cancelled: 'Cancelled' }; const stageOrder = ['todo', 'in_progress', 'awaiting_approval', 'done', 'failed']; const SpatialDashboard: React.FC = ({ selectedProjectId, onSelectProject, onOpenTask }) => { const { user } = useAuth(); const planeRef = useRef(null); const nodeRefs = useRef>({}); const [projects, setProjects] = useState([]); const [tasks, setTasks] = useState([]); const [agents, setAgents] = useState([]); const [dependencies, setDependencies] = useState([]); const [dependencyTableAvailable, setDependencyTableAvailable] = useState(true); const [selectedId, setSelectedId] = useState(null); const [compact, setCompact] = useState(false); const [edgeLines, setEdgeLines] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [reloadNonce, setReloadNonce] = useState(0); useEffect(() => { const loadProjects = async () => { if (!user) return; setError(null); const { data, error: projectError } = await supabase .from('projects') .select('id,name,status') .eq('owner_id', user.id) .order('created_at', { ascending: false }); if (projectError) { setError(projectError.message); return; } const nextProjects = data ?? []; setProjects(nextProjects); if (!selectedProjectId && nextProjects[0]) { onSelectProject(nextProjects[0].id); } }; loadProjects(); }, [onSelectProject, reloadNonce, selectedProjectId, user]); useEffect(() => { const loadProjectGraph = async () => { if (!selectedProjectId) { setTasks([]); setDependencies([]); return; } setLoading(true); setError(null); const [ { data: taskData, error: taskError }, { data: agentData, error: agentError }, dependencyResponse ] = await Promise.all([ supabase .from('tasks') .select('id,title,description,status,priority,project_id,assigned_agent_id') .eq('project_id', selectedProjectId) .order('priority', { ascending: false }) .order('created_at', { ascending: true }), supabase.from('agents').select('id,name,role,model').order('created_at', { ascending: false }), supabase.from('task_dependencies').select('task_id,depends_on_task_id').eq('project_id', selectedProjectId) ]); if (taskError) setError(taskError.message); if (agentError) setError(agentError.message); if (dependencyResponse.error) { setDependencyTableAvailable(false); setDependencies([]); if (dependencyResponse.error.code !== '42P01') { setError(dependencyResponse.error.message); } } else { setDependencyTableAvailable(true); setDependencies(dependencyResponse.data ?? []); } const nextTasks = taskData ?? []; setTasks(nextTasks); setAgents(agentData ?? []); setSelectedId((current) => current && nextTasks.some((task) => task.id === current) ? current : nextTasks[0]?.id ?? null); setLoading(false); }; loadProjectGraph(); }, [reloadNonce, selectedProjectId]); const visibleTasks = tasks; const selectedTask = visibleTasks.find((task) => task.id === selectedId) ?? visibleTasks[0] ?? null; const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null; const agentMap = useMemo( () => new Map(agents.map((agent) => [agent.id, agent])), [agents] ); const dependencyMap = useMemo(() => { const map = new Map(); for (const dependency of dependencies) { const current = map.get(dependency.task_id) ?? []; current.push(dependency.depends_on_task_id); map.set(dependency.task_id, current); } return map; }, [dependencies]); const dependentMap = useMemo(() => { const map = new Map(); for (const dependency of dependencies) { const current = map.get(dependency.depends_on_task_id) ?? []; current.push(dependency.task_id); map.set(dependency.depends_on_task_id, current); } return map; }, [dependencies]); const metrics = useMemo(() => { return visibleTasks.reduce( (acc, task) => { acc.total += 1; acc[task.status] = (acc[task.status] ?? 0) + 1; if (task.assigned_agent_id) acc.assigned += 1; return acc; }, { total: 0, assigned: 0 } as Record ); }, [visibleTasks]); const tasksByStage = stageOrder.map((status) => ({ status, tasks: visibleTasks.filter((task) => task.status === status) })); useEffect(() => { const updateEdges = () => { if (!planeRef.current || dependencies.length === 0) { setEdgeLines([]); return; } const planeBounds = planeRef.current.getBoundingClientRect(); const nextLines = dependencies.flatMap((dependency) => { const sourceNode = nodeRefs.current[dependency.depends_on_task_id]; const targetNode = nodeRefs.current[dependency.task_id]; if (!sourceNode || !targetNode) return []; const sourceBounds = sourceNode.getBoundingClientRect(); const targetBounds = targetNode.getBoundingClientRect(); return [{ key: `${dependency.depends_on_task_id}-${dependency.task_id}`, fromX: sourceBounds.left + sourceBounds.width / 2 - planeBounds.left, fromY: sourceBounds.top + sourceBounds.height / 2 - planeBounds.top, toX: targetBounds.left + targetBounds.width / 2 - planeBounds.left, toY: targetBounds.top + targetBounds.height / 2 - planeBounds.top }]; }); setEdgeLines(nextLines); }; updateEdges(); window.addEventListener('resize', updateEdges); return () => window.removeEventListener('resize', updateEdges); }, [compact, dependencies, tasksByStage]); const selectedDependencies = selectedTask ? dependencyMap.get(selectedTask.id) ?? [] : []; const selectedDependents = selectedTask ? dependentMap.get(selectedTask.id) ?? [] : []; const selectedAgent = selectedTask?.assigned_agent_id ? agentMap.get(selectedTask.assigned_agent_id) : null; return (

Spatial Project View

Inspect one project as a task graph with agents and blockers.

{error &&
{error}
} {!dependencyTableAvailable && (
Task links are disabled until `database/task_dependencies.sql` is applied in Supabase.
)}
Project Graph

{selectedProject?.name ?? 'No project selected'}

{visibleTasks.length} tasks
{edgeLines.length > 0 && ( {edgeLines.map((line) => ( ))} )} {tasksByStage.map(({ status, tasks: stageTasks }, stageIndex) => (
{statusLabels[status]} {stageTasks.length}
{stageTasks.length === 0 &&
No tasks
} {stageTasks.map((task, taskIndex) => { const assignedAgent = task.assigned_agent_id ? agentMap.get(task.assigned_agent_id) : null; const dependencyCount = dependencyMap.get(task.id)?.length ?? 0; return ( { nodeRefs.current[task.id] = element; }} type="button" className={`spatial-node status-${task.status} ${selectedTask?.id === task.id ? 'is-selected' : ''}`} onClick={() => setSelectedId(task.id)} initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: (stageIndex + taskIndex) * 0.035 }} > {statusLabels[task.status] ?? task.status} {task.title} {assignedAgent ? assignedAgent.name : 'Unassigned'} {dependencyCount > 0 && {dependencyCount} blockers} ); })}
))}
); }; const Metric: React.FC<{ label: string; value: number }> = ({ label, value }) => (
{value} {label}
); export default SpatialDashboard;