From 370709c224e5d98f7e3cd6a0c741ca87feea235c Mon Sep 17 00:00:00 2001 From: eipasteur <85672662+eipasteur@users.noreply.github.com> Date: Thu, 21 May 2026 12:09:11 -0700 Subject: [PATCH 01/11] feat(frontend): unified navigation sidebar and project cache Replace sprint-centric sidebar with a global navigation layout that shows all projects with their current agent status. The sidebar is now always visible, providing quick access to projects, observability, and backlog views. - Add stale-while-revalidate cache for projects and sprint lists - Show running/waiting agent indicators per project in sidebar - Activity panel auto-opens only when inside a sprint - Add Collapsible UI component (radix-ui) --- frontend/src/components/layout/AppShell.tsx | 42 ++-- frontend/src/components/layout/AppSidebar.tsx | 219 +++++++++++------- frontend/src/components/ui/collapsible.tsx | 19 ++ frontend/src/hooks/useProjectsCache.ts | 195 ++++++++++++++++ 4 files changed, 371 insertions(+), 104 deletions(-) create mode 100644 frontend/src/components/ui/collapsible.tsx create mode 100644 frontend/src/hooks/useProjectsCache.ts diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index cd648f0..51c6ed9 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -5,19 +5,33 @@ import { AppHeader } from '@/components/layout/AppHeader'; import { ActivityPanel } from '@/components/layout/ActivityPanel'; import { StatusBar } from '@/components/layout/StatusBar'; import { CommandPalette } from '@/components/layout/CommandPalette'; -import { useState, useCallback } from 'react'; +import { useProjectSprintsCache } from '@/hooks/useProjectsCache'; +import { useState, useCallback, useMemo, useEffect } from 'react'; export function AppShell() { - const { sprintId } = useParams<{ sprintId: string }>(); + const { sprintId, projectId } = useParams<{ sprintId: string; projectId: string }>(); const inSprint = !!sprintId; + const onProjectPage = !!projectId && !inSprint; - const [activityPanelOpen, setActivityPanelOpen] = useState(true); + const { sprints: projectSprints } = useProjectSprintsCache(onProjectPage ? projectId : null); + const latestActiveSprintId = useMemo(() => { + if (inSprint) return sprintId; + const active = projectSprints.find( + (s) => s.currentAgentStatus === 'running' || s.currentAgentStatus === 'waiting', + ); + return active?.id ?? projectSprints[0]?.id ?? null; + }, [inSprint, sprintId, projectSprints]); + + const [activityPanelOpen, setActivityPanelOpen] = useState(inSprint); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [commandOpen, setCommandOpen] = useState(false); - // Only show panels when inside a sprint - const showSidebar = inSprint && !sidebarCollapsed; - const showActivity = inSprint && activityPanelOpen; + useEffect(() => { + setActivityPanelOpen(inSprint); + }, [inSprint]); + + const showSidebar = !sidebarCollapsed; + const showActivity = (inSprint || onProjectPage) && activityPanelOpen; const toggleSidebar = useCallback(() => setSidebarCollapsed((prev) => !prev), []); const toggleActivity = useCallback(() => setActivityPanelOpen((prev) => !prev), []); @@ -25,7 +39,6 @@ export function AppShell() { return (
- {/* Header */} - {/* Main content area */}
- {/* Sidebar - only in sprint views */} {showSidebar && ( )} - {/* Main content */}
- {/* Activity panel - only in sprint views */} {showActivity && ( )}
- {/* Status bar */} - - {/* Command palette */}
diff --git a/frontend/src/components/layout/AppSidebar.tsx b/frontend/src/components/layout/AppSidebar.tsx index 9a8cc42..e692e57 100644 --- a/frontend/src/components/layout/AppSidebar.tsx +++ b/frontend/src/components/layout/AppSidebar.tsx @@ -1,112 +1,155 @@ -import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation, useParams } from 'react-router-dom'; +import { + Activity, + ClipboardList, + LayoutDashboard, + Loader2, + MessageCircleQuestion, + Settings, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Button } from '@/components/ui/button'; -import { Settings, ArrowLeft } from 'lucide-react'; -import { PipelineView } from '@/components/layout/PipelineView'; -import { useState, useEffect, useCallback } from 'react'; -import { sprintsService, type Sprint } from '@/services/sprints'; -import { realtimeService } from '@/services/realtime'; +import { useProjectsCache } from '@/hooks/useProjectsCache'; -const PHASE_URL_SUFFIX: Record = { - INCEPTION: '', - CONSTRUCTION: '/construction', - REVIEW: '/review', +const STATUS_DOT: Record = { + running: 'bg-agent-running', + waiting: 'bg-agent-waiting', + completed: 'bg-agent-success', + failed: 'bg-agent-error', }; export function AppSidebar() { const navigate = useNavigate(); - const params = useParams(); const location = useLocation(); - const [sprint, setSprint] = useState(null); + const params = useParams<{ projectId?: string }>(); + const { projects } = useProjectsCache(); - const projectId = params.projectId || ''; - const sprintId = params.sprintId || ''; + const runningCount = projects.filter((p) => { + const s = p.latestSprint?.currentAgentStatus; + return s === 'running' || s === 'waiting'; + }).length; - const loadSprint = useCallback(async () => { - if (!projectId || !sprintId) return; - try { - const data = await sprintsService.get(projectId, sprintId); - setSprint(data); - } catch (err) { - console.error('Failed to load sprint:', err); - } - }, [projectId, sprintId]); - - useEffect(() => { - loadSprint(); - }, [loadSprint]); - - // Re-fetch sprint on agent/phase events and auto-navigate on phase change - useEffect(() => { - if (!sprintId) return; - const unsubs = [ - realtimeService.on('agent.started', () => loadSprint()), - realtimeService.on('agent.completed', () => loadSprint()), - realtimeService.on('agent.error', () => loadSprint()), - realtimeService.on('sprint.phaseChanged', (data: { phase?: string }) => { - loadSprint(); - if (data.phase && PHASE_URL_SUFFIX[data.phase] !== undefined) { - navigate(`/project/${projectId}/sprint/${sprintId}${PHASE_URL_SUFFIX[data.phase]}`); - } - }), - ]; - return () => unsubs.forEach((unsub) => unsub()); - }, [sprintId, projectId, loadSprint, navigate]); - - const currentPhase = location.pathname.includes('/construction') - ? 'CONSTRUCTION' - : location.pathname.includes('/review') - ? 'REVIEW' - : location.pathname.includes('/graph') - ? 'GRAPH' - : location.pathname.includes('/agent') - ? 'AGENT' - : 'INCEPTION'; + const isOnDashboard = location.pathname === '/dashboard'; + const isOnObservability = location.pathname === '/observability'; + const isOnBacklog = location.pathname === '/backlog'; + const isOnAdmin = location.pathname === '/admin'; + const activeProjectId = params.projectId ?? null; return (
- {/* Brand header */} -
- AI-DLC - AI-DLC -
+ + +
+
+ Projects +
- {/* Pipeline view -- the core of the sidebar */} - -
- + +
+ {projects.map(({ project, latestSprint }) => { + const status = latestSprint?.currentAgentStatus; + const isActive = status === 'running' || status === 'waiting'; + const dotColor = status ? STATUS_DOT[status] : undefined; + const isSelected = activeProjectId === project.id; + + return ( + + ); + })}
- {/* Bottom: Settings */}
- + + Admin & Settings +
); diff --git a/frontend/src/components/ui/collapsible.tsx b/frontend/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..e935b61 --- /dev/null +++ b/frontend/src/components/ui/collapsible.tsx @@ -0,0 +1,19 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; + +function Collapsible({ ...props }: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/frontend/src/hooks/useProjectsCache.ts b/frontend/src/hooks/useProjectsCache.ts new file mode 100644 index 0000000..35fda5c --- /dev/null +++ b/frontend/src/hooks/useProjectsCache.ts @@ -0,0 +1,195 @@ +import { useState, useEffect, useCallback, useSyncExternalStore } from 'react'; +import { projectsService, type Project } from '@/services/projects'; +import { sprintsService, type Sprint } from '@/services/sprints'; + +const PROJECTS_TTL = 120_000; +const SPRINTS_TTL = 60_000; + +export interface ProjectWithSprint { + project: Project; + latestSprint: Sprint | null; +} + +interface CacheEntry { + data: T; + fetchedAt: number; +} + +let projectsCache: CacheEntry | null = null; +let projectsFetching = false; +let projectsListeners = new Set<() => void>(); +let projectsVersion = 0; + +const sprintsCache = new Map>(); +const sprintsFetching = new Set(); +const sprintsListeners = new Map void>>(); +let sprintsVersion = 0; + +function notifyProjectListeners() { + projectsVersion++; + projectsListeners.forEach((fn) => fn()); +} + +function notifySprintListeners(projectId: string) { + sprintsVersion++; + sprintsListeners.get(projectId)?.forEach((fn) => fn()); +} + +function isStale(entry: { fetchedAt: number } | null, ttl: number): boolean { + if (!entry) return true; + return Date.now() - entry.fetchedAt > ttl; +} + +async function fetchProjects(): Promise { + const projs = await projectsService.list(); + const results = await Promise.allSettled( + projs.map(async (project): Promise => { + const cached = sprintsCache.get(project.id); + if (cached && !isStale(cached, SPRINTS_TTL)) { + const sorted = [...cached.data].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + return { project, latestSprint: sorted[0] ?? null }; + } + try { + const sprints = await sprintsService.list(project.id); + sprintsCache.set(project.id, { data: sprints, fetchedAt: Date.now() }); + notifySprintListeners(project.id); + const sorted = [...sprints].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + return { project, latestSprint: sorted[0] ?? null }; + } catch { + return { project, latestSprint: cached?.data?.[0] ?? null }; + } + }), + ); + return results + .map((r) => (r.status === 'fulfilled' ? r.value : null)) + .filter((v): v is ProjectWithSprint => v !== null); +} + +async function revalidateProjects(force = false) { + if (projectsFetching) return; + if (!force && !isStale(projectsCache, PROJECTS_TTL)) return; + projectsFetching = true; + try { + const data = await fetchProjects(); + projectsCache = { data, fetchedAt: Date.now() }; + notifyProjectListeners(); + } catch { + /* network failure — keep stale cache */ + } finally { + projectsFetching = false; + } +} + +async function revalidateSprints(projectId: string, force = false) { + if (sprintsFetching.has(projectId)) return; + if (!force && !isStale(sprintsCache.get(projectId) ?? null, SPRINTS_TTL)) return; + sprintsFetching.add(projectId); + try { + const sprints = await sprintsService.list(projectId); + sprintsCache.set(projectId, { data: sprints, fetchedAt: Date.now() }); + notifySprintListeners(projectId); + if (projectsCache) { + const updated = projectsCache.data.map((p) => { + if (p.project.id !== projectId) return p; + const sorted = [...sprints].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + return { ...p, latestSprint: sorted[0] ?? null }; + }); + projectsCache = { data: updated, fetchedAt: projectsCache.fetchedAt }; + notifyProjectListeners(); + } + } catch { + /* network failure — keep stale cache */ + } finally { + sprintsFetching.delete(projectId); + } +} + +let initialFetchFired = false; + +export function useProjectsCache() { + const subscribe = useCallback((onStoreChange: () => void) => { + projectsListeners.add(onStoreChange); + return () => { + projectsListeners.delete(onStoreChange); + }; + }, []); + + const getSnapshot = useCallback(() => projectsVersion, []); + + useSyncExternalStore(subscribe, getSnapshot); + + useEffect(() => { + if (!initialFetchFired) { + initialFetchFired = true; + revalidateProjects(); + } + const timer = setInterval(() => revalidateProjects(), 120_000); + return () => clearInterval(timer); + }, []); + + return { + projects: projectsCache?.data ?? [], + loading: !projectsCache && projectsFetching, + refresh: () => revalidateProjects(true), + invalidate: () => { + projectsCache = null; + revalidateProjects(true); + }, + }; +} + +export function useProjectCache(projectId: string | null) { + const { projects, loading } = useProjectsCache(); + const project = projectId + ? (projects.find((p) => p.project.id === projectId)?.project ?? null) + : null; + return { project, loading }; +} + +export function useProjectSprintsCache(projectId: string | null) { + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!projectId) return () => {}; + let listeners = sprintsListeners.get(projectId); + if (!listeners) { + listeners = new Set(); + sprintsListeners.set(projectId, listeners); + } + listeners.add(onStoreChange); + return () => { + listeners!.delete(onStoreChange); + }; + }, + [projectId], + ); + + const getSnapshot = useCallback(() => sprintsVersion, []); + + useSyncExternalStore(subscribe, getSnapshot); + + useEffect(() => { + if (!projectId) return; + revalidateSprints(projectId); + }, [projectId]); + + const cached = projectId ? sprintsCache.get(projectId) : null; + const sorted = cached?.data + ? [...cached.data].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) + : []; + + return { + sprints: sorted, + loading: projectId ? !cached && sprintsFetching.has(projectId) : false, + refresh: () => { + if (projectId) revalidateSprints(projectId, true); + }, + }; +} From 4138ab60cdc8e6ab01d601925f8c5d812fab949e Mon Sep 17 00:00:00 2001 From: eipasteur <85672662+eipasteur@users.noreply.github.com> Date: Thu, 21 May 2026 12:12:40 -0700 Subject: [PATCH 02/11] feat(frontend): add observability dashboard with agent monitoring Add a dedicated observability view for monitoring agent activity across projects. Includes real-time status cards, task boards, metric summaries, and per-project detail views with collapsible phase sections. - Add MetricCard, DashboardMetrics, AgentStatusCards components - Add TaskBoard for sprint task status overview - Add ObservabilitySidebar for project navigation within the view - Add ProjectDetailView with agent stream and phase breakdown - Add ObservabilityLayout and ObservabilityDashboard pages - Update barrel exports --- .../observability/AgentStatusCards.tsx | 200 +++++++++ .../observability/DashboardMetrics.tsx | 66 +++ .../components/observability/MetricCard.tsx | 43 ++ .../observability/ObservabilitySidebar.tsx | 99 +++++ .../observability/ProjectDetailView.tsx | 410 ++++++++++++++++++ .../components/observability/TaskBoard.tsx | 161 +++++++ .../src/components/observability/index.ts | 6 + frontend/src/pages/ObservabilityDashboard.tsx | 106 +++++ frontend/src/pages/ObservabilityLayout.tsx | 117 +++++ 9 files changed, 1208 insertions(+) create mode 100644 frontend/src/components/observability/AgentStatusCards.tsx create mode 100644 frontend/src/components/observability/DashboardMetrics.tsx create mode 100644 frontend/src/components/observability/MetricCard.tsx create mode 100644 frontend/src/components/observability/ObservabilitySidebar.tsx create mode 100644 frontend/src/components/observability/ProjectDetailView.tsx create mode 100644 frontend/src/components/observability/TaskBoard.tsx create mode 100644 frontend/src/pages/ObservabilityDashboard.tsx create mode 100644 frontend/src/pages/ObservabilityLayout.tsx diff --git a/frontend/src/components/observability/AgentStatusCards.tsx b/frontend/src/components/observability/AgentStatusCards.tsx new file mode 100644 index 0000000..6ca80c5 --- /dev/null +++ b/frontend/src/components/observability/AgentStatusCards.tsx @@ -0,0 +1,200 @@ +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Loader2, MessageCircleQuestion, CheckCircle2, XCircle, Clock, AlertTriangle, TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import { buildFocusSentence } from '@/lib/observability/buildFocusSentence'; +import type { ProjectAgentInfo, LastToolMap, PendingQuestionsMap, VelocityMetrics } from '@/hooks/useObservability'; + +interface AgentStatusCardsProps { + projects: ProjectAgentInfo[]; + lastToolMap: LastToolMap; + pendingQuestions: PendingQuestionsMap; + velocityMap: Record; + onSelectProject?: (projectId: string) => void; +} + +const STATUS_CONFIG: Record = { + running: { + icon: Loader2, + borderClass: 'border-agent-running/25', + bgClass: 'bg-agent-running/[0.04] shadow-[0_12px_32px_rgba(59,130,246,0.06)]', + label: 'Running', + color: 'text-agent-running', + }, + waiting: { + icon: MessageCircleQuestion, + borderClass: 'border-agent-waiting/25', + bgClass: 'bg-agent-waiting/[0.04]', + label: 'Waiting', + color: 'text-agent-waiting', + }, + completed: { + icon: CheckCircle2, + borderClass: 'border-border', + bgClass: 'bg-background/70', + label: 'Completed', + color: 'text-agent-success', + }, + failed: { + icon: XCircle, + borderClass: 'border-border', + bgClass: 'bg-background/70', + label: 'Failed', + color: 'text-agent-error', + }, +}; + +function formatDuration(startedAt: string | null): string { + if (!startedAt) return ''; + const ms = Date.now() - new Date(startedAt).getTime(); + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + +function sortByStatus(a: ProjectAgentInfo, b: ProjectAgentInfo): number { + const order: Record = { running: 0, waiting: 1, failed: 2, completed: 3 }; + const aStatus = a.sprint?.currentAgentStatus ?? 'completed'; + const bStatus = b.sprint?.currentAgentStatus ?? 'completed'; + return (order[aStatus] ?? 4) - (order[bStatus] ?? 4); +} + +export function AgentStatusCards({ projects, lastToolMap, pendingQuestions, velocityMap, onSelectProject }: AgentStatusCardsProps) { + const withSprints = projects + .filter(p => p.sprint?.currentAgentStatus) + .sort(sortByStatus); + + return ( +
+

+ Agents +

+ {withSprints.length === 0 ? ( +
+

No recent agent activity.

+
+ ) : ( +
+ {withSprints.map(({ project, sprint, progress }) => { + if (!sprint) return null; + const status = sprint.currentAgentStatus ?? 'completed'; + const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.completed; + const StatusIcon = cfg.icon; + const isActive = status === 'running' || status === 'waiting'; + const lastTool = lastToolMap[sprint.id]; + const questions = pendingQuestions[sprint.id] ?? 0; + const velocity = velocityMap[sprint.id]; + + const focus = status === 'waiting' + ? 'Waiting for answer' + : buildFocusSentence(sprint.currentAgentType, sprint, progress, lastTool); + + const TrendIcon = velocity?.trend === 'improving' ? TrendingUp + : velocity?.trend === 'declining' ? TrendingDown + : Minus; + const trendColor = velocity?.trend === 'improving' ? 'text-green-600' + : velocity?.trend === 'declining' ? 'text-red-500' + : 'text-muted-foreground'; + + return ( +
onSelectProject?.(project.id)} + className={cn( + 'flex flex-col overflow-hidden rounded-xl border shadow-sm transition-shadow', + cfg.borderClass, + cfg.bgClass, + onSelectProject && 'cursor-pointer hover:shadow-md', + )} + > +
+
+
+
+ {isActive ? ( + + + + + ) : ( + + )} + {project.name} +
+
+ {isActive ? 'Live now' : `${cfg.label} ${formatDuration(sprint.agentStartedAt)}`} +
+
+ + + {sprint.currentAgentType && ( + {sprint.currentAgentType.replace(/[_-]/g, ' ')} + )} + +
+ + {questions > 0 && ( +
+ + {questions} question{questions > 1 ? 's' : ''} blocking +
+ )} +
+ +
+ {focus && ( +

+ {focus} +

+ )} + +
+ + {sprint.phase} + + + {sprint.agentStartedAt && isActive && ( + + + {formatDuration(sprint.agentStartedAt)} + + )} + + {velocity && ( + + + {velocity.tasksPerHour} tasks/hr + + )} +
+ + {progress && progress.taskCount > 0 && ( +
+ {progress.taskDoneCount}/{progress.taskCount} tasks + {progress.codeFileCount > 0 && ` · ${progress.codeFileCount} files`} +
+ )} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/observability/DashboardMetrics.tsx b/frontend/src/components/observability/DashboardMetrics.tsx new file mode 100644 index 0000000..793f449 --- /dev/null +++ b/frontend/src/components/observability/DashboardMetrics.tsx @@ -0,0 +1,66 @@ +import { Bot, CircleDot, AlertTriangle, Zap } from 'lucide-react'; +import { MetricCard } from './MetricCard'; +import type { ProjectAgentInfo, StuckDetection, ActivityEvent } from '@/hooks/useObservability'; + +interface DashboardMetricsProps { + projects: ProjectAgentInfo[]; + stuckDetections: StuckDetection[]; + activityFeed: ActivityEvent[]; +} + +export function DashboardMetrics({ projects, stuckDetections, activityFeed }: DashboardMetricsProps) { + const running = projects.filter(p => p.sprint?.currentAgentStatus === 'running').length; + const waiting = projects.filter(p => p.sprint?.currentAgentStatus === 'waiting').length; + const activeAgents = running + waiting; + + let totalRunning = 0; + let totalPending = 0; + let totalDone = 0; + for (const p of projects) { + for (const t of p.taskStatuses) { + if (t.executionStatus === 'RUNNING') totalRunning++; + else if (t.executionStatus === 'SUCCEEDED') totalDone++; + else if (!t.executionStatus) totalPending++; + } + } + + const criticalAlerts = stuckDetections.filter(d => d.severity === 'critical').length; + const alertDesc = stuckDetections.length === 0 + ? 'All clear' + : criticalAlerts > 0 + ? `${criticalAlerts} critical` + : `${stuckDetections.length} warning${stuckDetections.length > 1 ? 's' : ''}`; + + return ( +
+ {running} running, {waiting} waiting + } + /> + {totalPending} pending, {totalDone} done + } + /> + {alertDesc}} + /> + Live updates via WebSocket} + /> +
+ ); +} diff --git a/frontend/src/components/observability/MetricCard.tsx b/frontend/src/components/observability/MetricCard.tsx new file mode 100644 index 0000000..08d3219 --- /dev/null +++ b/frontend/src/components/observability/MetricCard.tsx @@ -0,0 +1,43 @@ +import { cn } from '@/lib/utils'; +import { Card, CardContent } from '@/components/ui/card'; +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface MetricCardProps { + icon: LucideIcon; + value: string | number; + label: string; + description?: ReactNode; + onClick?: () => void; +} + +export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) { + return ( + + +
+
+

+ {value} +

+

+ {label} +

+ {description && ( +
+ {description} +
+ )} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/observability/ObservabilitySidebar.tsx b/frontend/src/components/observability/ObservabilitySidebar.tsx new file mode 100644 index 0000000..702d271 --- /dev/null +++ b/frontend/src/components/observability/ObservabilitySidebar.tsx @@ -0,0 +1,99 @@ +import { Activity, LayoutDashboard, Loader2, MessageCircleQuestion } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import type { ProjectAgentInfo } from '@/hooks/useObservability'; + +interface ObservabilitySidebarProps { + projects: ProjectAgentInfo[]; + selectedProjectId: string | null; + onSelectProject: (id: string | null) => void; +} + +const STATUS_DOT: Record = { + running: 'bg-agent-running', + waiting: 'bg-agent-waiting', + completed: 'bg-agent-success', + failed: 'bg-agent-error', +}; + +export function ObservabilitySidebar({ projects, selectedProjectId, onSelectProject }: ObservabilitySidebarProps) { + const runningCount = projects.filter(p => + p.sprint?.currentAgentStatus === 'running' || p.sprint?.currentAgentStatus === 'waiting' + ).length; + + return ( +
+
+ + Observability +
+ + + +
+
+ Projects +
+
+ + +
+ {projects.map(({ project, sprint }) => { + const status = sprint?.currentAgentStatus; + const isActive = status === 'running' || status === 'waiting'; + const dotColor = status ? STATUS_DOT[status] : undefined; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/observability/ProjectDetailView.tsx b/frontend/src/components/observability/ProjectDetailView.tsx new file mode 100644 index 0000000..0fee399 --- /dev/null +++ b/frontend/src/components/observability/ProjectDetailView.tsx @@ -0,0 +1,410 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { cn } from '@/lib/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { + FolderGit2, GitBranch, ExternalLink, Bot, Loader2, CheckCircle2, + XCircle, MessageCircleQuestion, ChevronRight, Clock, Send, GitPullRequest, + ArrowLeft, +} from 'lucide-react'; +import { AgentStreamPanel } from '@/components/AgentStreamPanel'; +import { useAgentStatus } from '@/hooks/useAgentStatus'; +import { useSprintEvents } from '@/hooks/useSprintEvents'; +import { agentsService } from '@/services/agents'; +import { sprintsService, type Sprint } from '@/services/sprints'; +import { realtimeService } from '@/services/realtime'; +import type { ProjectAgentInfo, LastToolMap, PendingQuestionsMap, VelocityMetrics } from '@/hooks/useObservability'; +import type { Project } from '@/services/projects'; + +interface ProjectDetailViewProps { + info: ProjectAgentInfo; + allSprints: Sprint[]; + lastTool?: { name: string; timestamp: number }; + pendingQuestions: number; + velocity?: VelocityMetrics; + onNavigate: (path: string) => void; + onBack?: () => void; +} + +const STATUS_ICON: Record = { + running: Loader2, + waiting: MessageCircleQuestion, + completed: CheckCircle2, + failed: XCircle, +}; + +const STATUS_LABEL: Record = { + running: 'Running', + waiting: 'Waiting for input', + completed: 'Completed', + failed: 'Failed', +}; + +function formatRelativeTime(dateStr: string | null): string { + if (!dateStr) return ''; + const ms = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(ms / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +export function ProjectDetailView({ + info, allSprints, lastTool, pendingQuestions, velocity, onNavigate, onBack, +}: ProjectDetailViewProps) { + const { project, sprint, progress, taskStatuses } = info; + const agentStatus = sprint?.currentAgentStatus; + const navigate = useNavigate(); + + const [instructions, setInstructions] = useState(''); + const [sending, setSending] = useState(false); + + const agentStream = useAgentStatus({ + executionArn: sprint?.currentExecutionArn ?? null, + executionId: sprint?.currentExecutionId ?? null, + projectId: project.id, + sprintId: sprint?.id, + sprintAgentStatus: agentStatus, + }); + + useSprintEvents(sprint?.id ?? '', useCallback(() => {}, [])); + + const handleSendInstruction = async () => { + if (!instructions.trim()) return; + setSending(true); + try { + await agentsService.startWorkflow(project.id, { + phase: 'bugfix', + sprintId: sprint?.id, + description: instructions.trim(), + }); + setInstructions(''); + } catch (err) { + console.error('Failed to start agent:', err); + } finally { + setSending(false); + } + }; + + const isAgentActive = agentStatus === 'running' || agentStatus === 'waiting'; + const activeSprints = allSprints.filter(s => + s.currentAgentStatus === 'running' || s.currentAgentStatus === 'waiting' + ); + const pastSprints = allSprints.filter(s => + s.currentAgentStatus !== 'running' && s.currentAgentStatus !== 'waiting' + ); + + return ( +
+
+
+ {onBack && ( + + )} + +

{project.name}

+ {sprint && ( + + {sprint.phase} + + )} + {isAgentActive && ( + + + Live + + )} +
+ +
+ +
+ + +
+ + Repository +
+

{project.gitRepo || 'Not configured'}

+ {sprint?.branch && ( +

+ Branch: {sprint.branch} +

+ )} +
+
+ + +
+ + Agent +
+
+ {agentStatus && STATUS_ICON[agentStatus] && (() => { + const Icon = STATUS_ICON[agentStatus]; + return ; + })()} +

+ {agentStatus ? STATUS_LABEL[agentStatus] : 'Idle'} +

+
+ {sprint?.currentAgentType && ( +

+ {sprint.currentAgentType.replace(/[_-]/g, ' ')} +

+ )} +
+
+ + +
+ + Progress +
+ {progress ? ( + <> +

+ {progress.taskDoneCount}/{progress.taskCount} tasks +

+

+ {progress.codeFileCount} files · {progress.requirementCount} reqs + {velocity && ` · ${velocity.tasksPerHour} tasks/hr`} +

+ + ) : ( +

No sprint data

+ )} +
+
+
+ + {sprint?.prUrl && ( + + + +
+

Pull Request #{sprint.prNumber}

+
+ + View PR + +
+
+ )} + +
+
+

+ Agent Workspace +

+ + +
+
+ + Chat with Worker + {isAgentActive && ( + Connected + )} +
+

+ Send instructions to an available worker for quick fixes or information +

+
+ + {(isAgentActive || agentStream.streamingText || agentStream.completedOutput) && ( + + )} + +
+
+