aubm / frontend /src /components /SpatialDashboard.tsx
cesjavi's picture
Add guided mode, profiles admin, and output quality guardrails
ad68e43
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<string, string> = {
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<SpatialDashboardProps> = ({ selectedProjectId, onSelectProject, onOpenTask }) => {
const { user } = useAuth();
const planeRef = useRef<HTMLDivElement | null>(null);
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [tasks, setTasks] = useState<TaskNode[]>([]);
const [agents, setAgents] = useState<AgentSummary[]>([]);
const [dependencies, setDependencies] = useState<TaskDependency[]>([]);
const [dependencyTableAvailable, setDependencyTableAvailable] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [compact, setCompact] = useState(false);
const [edgeLines, setEdgeLines] = useState<EdgeLine[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string, string[]>();
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<string, string[]>();
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<string, number>
);
}, [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 (
<div className="spatial-page animate-fade-in">
<div className="spatial-toolbar">
<div className="panel-heading" style={{ marginBottom: 0 }}>
<Box size={32} color="var(--accent)" />
<div style={{ display: 'grid', gap: 'var(--space-sm)' }}>
<div>
<h2>Spatial Project View</h2>
<p style={{ color: 'var(--text-dim)', fontSize: '0.9rem' }}>Inspect one project as a task graph with agents and blockers.</p>
</div>
<label style={{ display: 'grid', gap: '6px', maxWidth: '360px' }}>
<span style={{ color: 'var(--text-dim)', fontSize: '0.75rem', textTransform: 'uppercase', fontWeight: 800 }}>Project</span>
<select
value={selectedProjectId ?? ''}
onChange={(event) => onSelectProject(event.target.value)}
className="spatial-project-select"
>
<option value="">Select a project</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name} ({project.status})
</option>
))}
</select>
</label>
</div>
</div>
<div className="spatial-actions">
<button className="btn btn-glass" onClick={() => setCompact(!compact)}>
<Layers3 size={18} />
{compact ? 'Expand' : 'Compact'}
</button>
<button className="btn btn-glass" onClick={() => setSelectedId(null)}>
<RotateCcw size={18} />
Reset
</button>
<button className="btn btn-glass" onClick={() => setReloadNonce((current) => current + 1)} disabled={!selectedProjectId || loading}>
<RefreshCw size={18} />
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{error && <div className="inline-status">{error}</div>}
{!dependencyTableAvailable && (
<div className="inline-status">
Task links are disabled until `database/task_dependencies.sql` is applied in Supabase.
</div>
)}
<div className="spatial-grid">
<section className="glass-panel spatial-stage-panel">
<div className="spatial-stage-header">
<div>
<span style={{ color: 'var(--text-dim)', fontSize: '0.8rem', textTransform: 'uppercase', fontWeight: 700 }}>Project Graph</span>
<h3>{selectedProject?.name ?? 'No project selected'}</h3>
</div>
<div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>
{visibleTasks.length} tasks
</div>
</div>
<div className={`spatial-stage ${compact ? 'is-compact' : ''}`}>
<div className="spatial-plane" ref={planeRef}>
{edgeLines.length > 0 && (
<svg className="spatial-edge-layer" viewBox={`0 0 ${planeRef.current?.clientWidth ?? 900} ${planeRef.current?.clientHeight ?? 430}`} preserveAspectRatio="none">
{edgeLines.map((line) => (
<g key={line.key}>
<line x1={line.fromX} y1={line.fromY} x2={line.toX} y2={line.toY} className="spatial-edge-line" />
<circle cx={line.toX} cy={line.toY} r="3" className="spatial-edge-dot" />
</g>
))}
</svg>
)}
{tasksByStage.map(({ status, tasks: stageTasks }, stageIndex) => (
<div key={status} className="spatial-lane">
<div className="spatial-lane-header">
<i className={`status-dot status-${status}`} />
<span>{statusLabels[status]}</span>
<strong>{stageTasks.length}</strong>
</div>
<div className="spatial-lane-stack">
{stageTasks.length === 0 && <div className="spatial-empty">No tasks</div>}
{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 (
<motion.button
key={task.id}
ref={(element) => {
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 }}
>
<span>{statusLabels[task.status] ?? task.status}</span>
<strong>{task.title}</strong>
<small>{assignedAgent ? assignedAgent.name : 'Unassigned'}</small>
{dependencyCount > 0 && <em>{dependencyCount} blockers</em>}
</motion.button>
);
})}
</div>
</div>
))}
</div>
</div>
</section>
<aside className="glass-panel spatial-inspector">
<div className="spatial-metrics">
<Metric label="Total" value={metrics.total ?? 0} />
<Metric label="Assigned" value={metrics.assigned ?? 0} />
<Metric label="Review" value={metrics.awaiting_approval ?? 0} />
<Metric label="Failed" value={metrics.failed ?? 0} />
</div>
<div className="spatial-selected">
<Workflow size={24} color="var(--accent)" />
<div>
<span>Selected Task</span>
<h3>{selectedTask?.title ?? 'No task selected'}</h3>
<p>{selectedTask ? statusLabels[selectedTask.status] ?? selectedTask.status : 'Unknown'}</p>
</div>
</div>
{selectedTask && (
<div className="default-agent-panel" style={{ padding: 'var(--space-md)' }}>
<div className="spatial-inspector-row">
<FolderTree size={16} />
<span>{selectedProject?.name ?? 'Project not loaded'}</span>
</div>
<div className="spatial-inspector-row">
<UserRound size={16} />
<span>{selectedAgent ? `${selectedAgent.name} (${selectedAgent.model})` : 'Unassigned'}</span>
</div>
<p style={{ color: 'var(--text-dim)', fontSize: '0.9rem' }}>
{selectedTask.description || 'No task description provided.'}
</p>
<div style={{ display: 'grid', gap: 'var(--space-sm)' }}>
<div>
<strong style={{ fontSize: '0.85rem' }}>Depends on</strong>
{selectedDependencies.length === 0 ? (
<p style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>No upstream blockers.</p>
) : (
selectedDependencies.map((dependencyTaskId) => (
<p key={dependencyTaskId} style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>
{visibleTasks.find((task) => task.id === dependencyTaskId)?.title ?? 'Unknown task'}
</p>
))
)}
</div>
<div>
<strong style={{ fontSize: '0.85rem' }}>Unblocks</strong>
{selectedDependents.length === 0 ? (
<p style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>No downstream tasks.</p>
) : (
selectedDependents.map((dependentTaskId) => (
<p key={dependentTaskId} style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>
{visibleTasks.find((task) => task.id === dependentTaskId)?.title ?? 'Unknown task'}
</p>
))
)}
</div>
</div>
{selectedProjectId && (
<button className="btn btn-primary" type="button" onClick={() => onOpenTask(selectedProjectId, selectedTask.id)}>
<PencilLine size={16} />
Open Task Editor
</button>
)}
</div>
)}
<div className="spatial-legend">
{stageOrder.map((status) => (
<div key={status}>
<i className={`status-dot status-${status}`} />
<span>{statusLabels[status]}</span>
</div>
))}
</div>
</aside>
</div>
</div>
);
};
const Metric: React.FC<{ label: string; value: number }> = ({ label, value }) => (
<div>
<strong>{value}</strong>
<span>{label}</span>
</div>
);
export default SpatialDashboard;