Add agentic dashboard and phase tracking UI components
- Add project tabs with chat and dashboard views - Add agentic dashboard with progress timeline - Add phase indicator and progress timeline components - Add activity feed and metric card components - Add status badge component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { ProjectChat } from '@/components/chat/project-chat'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft, Settings } from 'lucide-react'
|
import { ArrowLeft, Settings } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import type { Database } from '@/types/database'
|
import type { Database } from '@/types/database'
|
||||||
|
import { ProjectTabs } from './project-tabs'
|
||||||
|
|
||||||
type Project = Database['public']['Tables']['projects']['Row']
|
type Project = Database['public']['Tables']['projects']['Row']
|
||||||
type Message = Database['public']['Tables']['messages']['Row']
|
type Message = Database['public']['Tables']['messages']['Row']
|
||||||
@@ -24,7 +24,6 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get messages for this project
|
|
||||||
const { data: messages } = await supabase
|
const { data: messages } = await supabase
|
||||||
.from('messages')
|
.from('messages')
|
||||||
.select('*')
|
.select('*')
|
||||||
@@ -33,7 +32,6 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-4rem)] flex flex-col">
|
<div className="h-[calc(100vh-4rem)] flex flex-col">
|
||||||
{/* Project Header */}
|
|
||||||
<div className="border-b bg-card px-4 py-3">
|
<div className="border-b bg-card px-4 py-3">
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -58,10 +56,11 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat Interface */}
|
<ProjectTabs
|
||||||
<div className="flex-1 overflow-hidden">
|
projectId={id}
|
||||||
<ProjectChat projectId={id} initialMessages={messages || []} />
|
projectName={project.name}
|
||||||
</div>
|
initialMessages={messages || []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/app/(dashboard)/projects/[id]/project-tabs.tsx
Normal file
55
src/app/(dashboard)/projects/[id]/project-tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { ProjectChat } from '@/components/chat/project-chat'
|
||||||
|
import { AgenticDashboard } from '@/components/dashboard/agentic-dashboard'
|
||||||
|
import { MessageSquare, Workflow } from 'lucide-react'
|
||||||
|
import type { Database } from '@/types/database'
|
||||||
|
|
||||||
|
type Message = Database['public']['Tables']['messages']['Row']
|
||||||
|
|
||||||
|
interface ProjectTabsProps {
|
||||||
|
projectId: string
|
||||||
|
projectName: string
|
||||||
|
initialMessages: Message[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectTabs({ projectId, projectName, initialMessages }: ProjectTabsProps) {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="chat" className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="border-b bg-card/50 px-4">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<TabsList className="h-10 bg-transparent p-0 gap-4">
|
||||||
|
<TabsTrigger
|
||||||
|
value="chat"
|
||||||
|
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
Chat
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="workflow"
|
||||||
|
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
|
||||||
|
>
|
||||||
|
<Workflow className="w-4 h-4 mr-2" />
|
||||||
|
Workflow
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="chat" className="flex-1 overflow-hidden m-0 data-[state=inactive]:hidden">
|
||||||
|
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="workflow" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<AgenticDashboard
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
298
src/components/dashboard/agentic-dashboard.tsx
Normal file
298
src/components/dashboard/agentic-dashboard.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ProgressTimeline, PHASES, type Phase } from '@/components/ui/progress-timeline'
|
||||||
|
import { PhaseIndicator } from '@/components/ui/phase-indicator'
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge'
|
||||||
|
import { ActivityFeed, type Activity } from '@/components/ui/activity-feed'
|
||||||
|
import { MetricCard } from '@/components/ui/metric-card'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Search,
|
||||||
|
Lightbulb,
|
||||||
|
Map,
|
||||||
|
Hammer,
|
||||||
|
Rocket,
|
||||||
|
BarChart3,
|
||||||
|
Sparkles,
|
||||||
|
ChevronRight,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
RotateCcw,
|
||||||
|
Zap,
|
||||||
|
Clock,
|
||||||
|
Target,
|
||||||
|
TrendingUp
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const phaseIcons: Record<Phase, React.ReactNode> = {
|
||||||
|
think: <Brain className="w-5 h-5" />,
|
||||||
|
evaluate: <Search className="w-5 h-5" />,
|
||||||
|
ideate: <Lightbulb className="w-5 h-5" />,
|
||||||
|
plan: <Map className="w-5 h-5" />,
|
||||||
|
create: <Hammer className="w-5 h-5" />,
|
||||||
|
deploy: <Rocket className="w-5 h-5" />,
|
||||||
|
analyze: <BarChart3 className="w-5 h-5" />,
|
||||||
|
enhance: <Sparkles className="w-5 h-5" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseDescriptions: Record<Phase, string> = {
|
||||||
|
think: 'Define the problem space and user needs',
|
||||||
|
evaluate: 'Assess technical feasibility and risks',
|
||||||
|
ideate: 'Generate solution options through divergent thinking',
|
||||||
|
plan: 'Select best approach and break into tasks',
|
||||||
|
create: 'Build the MVP with iterative development',
|
||||||
|
deploy: 'Ship to production with monitoring',
|
||||||
|
analyze: 'Collect metrics and user feedback',
|
||||||
|
enhance: 'Iterate based on data insights'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgenticDashboardProps {
|
||||||
|
projectId: string
|
||||||
|
projectName: string
|
||||||
|
initialPhase?: Phase
|
||||||
|
initialCompleted?: Phase[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgenticDashboard({
|
||||||
|
projectName,
|
||||||
|
initialPhase = 'think',
|
||||||
|
initialCompleted = [],
|
||||||
|
className
|
||||||
|
}: AgenticDashboardProps) {
|
||||||
|
const [currentPhase, setCurrentPhase] = useState<Phase>(initialPhase)
|
||||||
|
const [completedPhases, setCompletedPhases] = useState<Phase[]>(initialCompleted)
|
||||||
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
|
const [selectedPhase, setSelectedPhase] = useState<Phase | null>(null)
|
||||||
|
|
||||||
|
const [activities] = useState<Activity[]>([
|
||||||
|
{ id: '1', type: 'success', message: 'Project initialized successfully', timestamp: new Date(Date.now() - 3600000) },
|
||||||
|
{ id: '2', type: 'phase_change', message: 'Started Think phase', timestamp: new Date(Date.now() - 1800000) },
|
||||||
|
{ id: '3', type: 'action', message: 'Analyzing user requirements...', timestamp: new Date(Date.now() - 900000) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const progress = (completedPhases.length / PHASES.length) * 100
|
||||||
|
const currentIndex = PHASES.indexOf(currentPhase)
|
||||||
|
|
||||||
|
const handleAdvancePhase = () => {
|
||||||
|
if (currentIndex < PHASES.length - 1) {
|
||||||
|
setCompletedPhases([...completedPhases, currentPhase])
|
||||||
|
setCurrentPhase(PHASES[currentIndex + 1])
|
||||||
|
} else {
|
||||||
|
setCompletedPhases([...completedPhases, currentPhase])
|
||||||
|
setCurrentPhase('think')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setCurrentPhase('think')
|
||||||
|
setCompletedPhases([])
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-6', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">{projectName}</h2>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<StatusBadge variant={isRunning ? 'info' : 'default'} pulse={isRunning}>
|
||||||
|
{isRunning ? 'Running' : 'Paused'}
|
||||||
|
</StatusBadge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Iteration #{Math.floor(completedPhases.length / PHASES.length) + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsRunning(!isRunning)}
|
||||||
|
>
|
||||||
|
{isRunning ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAdvancePhase} className="gap-2">
|
||||||
|
Advance <ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Timeline */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<ProgressTimeline
|
||||||
|
currentPhase={currentPhase}
|
||||||
|
completedPhases={completedPhases}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-4 text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">{Math.round(progress)}%</span>
|
||||||
|
<span>complete</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Phase Grid + Details */}
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
{/* Phase Cards */}
|
||||||
|
<div className="lg:col-span-2 grid sm:grid-cols-2 gap-3">
|
||||||
|
{PHASES.map((phase) => {
|
||||||
|
const isCompleted = completedPhases.includes(phase)
|
||||||
|
const isCurrent = phase === currentPhase
|
||||||
|
const status = isCompleted ? 'completed' : isCurrent ? 'in_progress' : 'not_started'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={phase}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={() => setSelectedPhase(phase)}
|
||||||
|
>
|
||||||
|
<Card className={cn(
|
||||||
|
'cursor-pointer transition-all',
|
||||||
|
isCurrent && 'border-blue-500/50 bg-blue-500/5',
|
||||||
|
isCompleted && 'border-emerald-500/50 bg-emerald-500/5',
|
||||||
|
selectedPhase === phase && 'ring-2 ring-foreground/10'
|
||||||
|
)}>
|
||||||
|
<CardContent className="pt-4 pb-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
'p-2 rounded-lg',
|
||||||
|
isCurrent && 'bg-blue-500/10 text-blue-500',
|
||||||
|
isCompleted && 'bg-emerald-500/10 text-emerald-500',
|
||||||
|
!isCurrent && !isCompleted && 'bg-muted text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{phaseIcons[phase]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium capitalize">{phase}</span>
|
||||||
|
<PhaseIndicator phase={phase} status={status} showLabel={false} size="sm" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{phaseDescriptions[phase]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Feed */}
|
||||||
|
<div>
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
Activity
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActivityFeed activities={activities} maxItems={5} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
label="Progress"
|
||||||
|
value={progress}
|
||||||
|
format="percent"
|
||||||
|
icon={<Target className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Phases Complete"
|
||||||
|
value={completedPhases.length}
|
||||||
|
trend="up"
|
||||||
|
trendValue={`of ${PHASES.length}`}
|
||||||
|
icon={<TrendingUp className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Time in Phase"
|
||||||
|
value={2.5}
|
||||||
|
format="duration"
|
||||||
|
icon={<Clock className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Iterations"
|
||||||
|
value={Math.floor(completedPhases.length / PHASES.length) + 1}
|
||||||
|
icon={<RotateCcw className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase Detail Panel */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedPhase && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-muted">
|
||||||
|
{phaseIcons[selectedPhase]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="capitalize">{selectedPhase}</span>
|
||||||
|
<p className="text-sm font-normal text-muted-foreground mt-0.5">
|
||||||
|
{phaseDescriptions[selectedPhase]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Entry Criteria</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Previous phase completed</li>
|
||||||
|
<li>• Required inputs available</li>
|
||||||
|
<li>• Team capacity confirmed</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Key Activities</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Research & discovery</li>
|
||||||
|
<li>• Documentation</li>
|
||||||
|
<li>• Stakeholder alignment</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Exit Criteria</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Deliverables complete</li>
|
||||||
|
<li>• Quality checks passed</li>
|
||||||
|
<li>• Ready for next phase</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedPhase(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
src/components/ui/activity-feed.tsx
Normal file
80
src/components/ui/activity-feed.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Info,
|
||||||
|
Zap,
|
||||||
|
ArrowRight,
|
||||||
|
Clock
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
id: string
|
||||||
|
type: 'phase_change' | 'action' | 'notification' | 'error' | 'success'
|
||||||
|
message: string
|
||||||
|
timestamp: Date
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityFeedProps {
|
||||||
|
activities: Activity[]
|
||||||
|
maxItems?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig = {
|
||||||
|
phase_change: { icon: ArrowRight, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
||||||
|
action: { icon: Zap, color: 'text-violet-500', bg: 'bg-violet-500/10' },
|
||||||
|
notification: { icon: Info, color: 'text-muted-foreground', bg: 'bg-muted' },
|
||||||
|
error: { icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-500/10' },
|
||||||
|
success: { icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFeed({ activities, maxItems = 10, className }: ActivityFeedProps) {
|
||||||
|
const displayActivities = activities.slice(0, maxItems)
|
||||||
|
|
||||||
|
if (displayActivities.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center justify-center py-8 text-muted-foreground', className)}>
|
||||||
|
<Clock className="w-8 h-8 mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">No activity yet</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{displayActivities.map((activity, index) => {
|
||||||
|
const config = typeConfig[activity.type]
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={activity.id}
|
||||||
|
initial={{ opacity: 0, x: -20, height: 0 }}
|
||||||
|
animate={{ opacity: 1, x: 0, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, x: 20, height: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg bg-card border hover:border-foreground/10 transition-colors"
|
||||||
|
>
|
||||||
|
<div className={cn('p-1.5 rounded-full shrink-0', config.bg)}>
|
||||||
|
<Icon className={cn('w-3.5 h-3.5', config.color)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-foreground">{activity.message}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/components/ui/metric-card.tsx
Normal file
76
src/components/ui/metric-card.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion, useSpring, useTransform } from 'motion/react'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
format?: 'number' | 'percent' | 'duration'
|
||||||
|
trend?: 'up' | 'down' | 'neutral'
|
||||||
|
trendValue?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedNumber({ value, format = 'number' }: { value: number; format?: string }) {
|
||||||
|
const spring = useSpring(0, { stiffness: 100, damping: 30 })
|
||||||
|
const display = useTransform(spring, (current) => {
|
||||||
|
if (format === 'percent') return `${Math.round(current)}%`
|
||||||
|
if (format === 'duration') return `${current.toFixed(1)}s`
|
||||||
|
return Math.round(current).toLocaleString()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
spring.set(value)
|
||||||
|
}, [spring, value])
|
||||||
|
|
||||||
|
return <motion.span>{display}</motion.span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
format = 'number',
|
||||||
|
trend,
|
||||||
|
trendValue,
|
||||||
|
icon,
|
||||||
|
className
|
||||||
|
}: MetricCardProps) {
|
||||||
|
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
whileHover={{ y: -2, transition: { duration: 0.2 } }}
|
||||||
|
className={cn(
|
||||||
|
'p-4 rounded-xl border bg-card hover:border-foreground/10 transition-colors',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
{icon && <span className="text-muted-foreground">{icon}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<span className="text-2xl font-bold">
|
||||||
|
<AnimatedNumber value={value} format={format} />
|
||||||
|
</span>
|
||||||
|
{trend && trendValue && (
|
||||||
|
<span className={cn(
|
||||||
|
'flex items-center gap-0.5 text-xs font-medium pb-1',
|
||||||
|
trend === 'up' && 'text-emerald-500',
|
||||||
|
trend === 'down' && 'text-red-500',
|
||||||
|
trend === 'neutral' && 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
<TrendIcon className="w-3 h-3" />
|
||||||
|
{trendValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/components/ui/phase-indicator.tsx
Normal file
58
src/components/ui/phase-indicator.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Check, Circle, Loader2, AlertTriangle, X, SkipForward } from 'lucide-react'
|
||||||
|
|
||||||
|
export type PhaseStatus = 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'failed' | 'skipped'
|
||||||
|
|
||||||
|
interface PhaseIndicatorProps {
|
||||||
|
phase: string
|
||||||
|
status: PhaseStatus
|
||||||
|
label?: string
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
showLabel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<PhaseStatus, { icon: typeof Circle; color: string; bg: string; ring: string; animate?: boolean }> = {
|
||||||
|
not_started: { icon: Circle, color: 'text-muted-foreground', bg: 'bg-muted', ring: '' },
|
||||||
|
in_progress: { icon: Loader2, color: 'text-blue-500', bg: 'bg-blue-500/10', ring: 'ring-2 ring-blue-500/30', animate: true },
|
||||||
|
completed: { icon: Check, color: 'text-emerald-500', bg: 'bg-emerald-500/10', ring: '' },
|
||||||
|
blocked: { icon: AlertTriangle, color: 'text-amber-500', bg: 'bg-amber-500/10', ring: 'ring-2 ring-amber-500/30' },
|
||||||
|
failed: { icon: X, color: 'text-red-500', bg: 'bg-red-500/10', ring: '' },
|
||||||
|
skipped: { icon: SkipForward, color: 'text-muted-foreground/50', bg: 'bg-muted/50', ring: '' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeConfig = {
|
||||||
|
sm: { wrapper: 'p-1.5', icon: 'w-3 h-3', text: 'text-xs' },
|
||||||
|
md: { wrapper: 'p-2', icon: 'w-4 h-4', text: 'text-sm' },
|
||||||
|
lg: { wrapper: 'p-3', icon: 'w-5 h-5', text: 'text-base' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhaseIndicator({ phase, status, label, size = 'md', showLabel = true }: PhaseIndicatorProps) {
|
||||||
|
const config = statusConfig[status]
|
||||||
|
const sizes = sizeConfig[size]
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={cn('rounded-full transition-all duration-300', sizes.wrapper, config.bg, config.ring)}
|
||||||
|
animate={status === 'in_progress' ? { scale: [1, 1.05, 1] } : {}}
|
||||||
|
transition={{ repeat: Infinity, duration: 2 }}
|
||||||
|
>
|
||||||
|
<Icon className={cn(sizes.icon, config.color, config.animate && 'animate-spin')} />
|
||||||
|
</motion.div>
|
||||||
|
{showLabel && (
|
||||||
|
<span className={cn('font-medium capitalize', sizes.text, status === 'in_progress' ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
|
{label || phase}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
src/components/ui/progress-timeline.tsx
Normal file
141
src/components/ui/progress-timeline.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Check, Circle, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export const PHASES = ['think', 'evaluate', 'ideate', 'plan', 'create', 'deploy', 'analyze', 'enhance'] as const
|
||||||
|
export type Phase = typeof PHASES[number]
|
||||||
|
|
||||||
|
interface ProgressTimelineProps {
|
||||||
|
currentPhase: Phase
|
||||||
|
completedPhases: Phase[]
|
||||||
|
className?: string
|
||||||
|
variant?: 'horizontal' | 'vertical'
|
||||||
|
showLabels?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressTimeline({
|
||||||
|
currentPhase,
|
||||||
|
completedPhases,
|
||||||
|
className,
|
||||||
|
variant = 'horizontal',
|
||||||
|
showLabels = true
|
||||||
|
}: ProgressTimelineProps) {
|
||||||
|
const progress = (completedPhases.length / PHASES.length) * 100
|
||||||
|
|
||||||
|
if (variant === 'vertical') {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-1', className)}>
|
||||||
|
{PHASES.map((phase, index) => {
|
||||||
|
const isCompleted = completedPhases.includes(phase)
|
||||||
|
const isCurrent = phase === currentPhase
|
||||||
|
const isPast = PHASES.indexOf(phase) < PHASES.indexOf(currentPhase)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={phase}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center transition-all duration-300',
|
||||||
|
isCompleted && 'bg-emerald-500 text-white',
|
||||||
|
isCurrent && 'bg-blue-500 text-white ring-4 ring-blue-500/20',
|
||||||
|
!isCompleted && !isCurrent && 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
animate={isCurrent ? { scale: [1, 1.1, 1] } : {}}
|
||||||
|
transition={{ repeat: Infinity, duration: 2 }}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
) : isCurrent ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
{index < PHASES.length - 1 && (
|
||||||
|
<div className={cn(
|
||||||
|
'w-0.5 h-6 transition-colors duration-300',
|
||||||
|
isPast || isCompleted ? 'bg-emerald-500' : 'bg-muted'
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showLabels && (
|
||||||
|
<span className={cn(
|
||||||
|
'text-sm capitalize transition-colors',
|
||||||
|
isCurrent && 'font-semibold text-foreground',
|
||||||
|
isCompleted && 'text-emerald-600',
|
||||||
|
!isCurrent && !isCompleted && 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{phase}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full', className)}>
|
||||||
|
<div className="relative h-2 bg-muted rounded-full overflow-hidden mb-4">
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 via-violet-500 to-emerald-500"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500/50 via-violet-500/50 to-emerald-500/50 blur-sm"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
{PHASES.map((phase, index) => {
|
||||||
|
const isCompleted = completedPhases.includes(phase)
|
||||||
|
const isCurrent = phase === currentPhase
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={phase}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 rounded-full transition-all duration-300 mb-1',
|
||||||
|
isCompleted && 'bg-emerald-500',
|
||||||
|
isCurrent && 'bg-blue-500 ring-4 ring-blue-500/20',
|
||||||
|
!isCompleted && !isCurrent && 'bg-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
animate={isCurrent ? { scale: [1, 1.3, 1] } : {}}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||||
|
/>
|
||||||
|
{showLabels && (
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs capitalize hidden sm:block',
|
||||||
|
isCurrent && 'font-semibold text-foreground',
|
||||||
|
!isCurrent && 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{phase}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
src/components/ui/status-badge.tsx
Normal file
50
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-muted text-muted-foreground border-muted',
|
||||||
|
success: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
|
||||||
|
warning: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
|
||||||
|
error: 'bg-red-500/10 text-red-600 border-red-500/20',
|
||||||
|
info: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
|
||||||
|
purple: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
variant?: keyof typeof variants
|
||||||
|
children: React.ReactNode
|
||||||
|
pulse?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ variant = 'default', children, pulse, className }: StatusBadgeProps) {
|
||||||
|
const pulseColor = {
|
||||||
|
default: 'bg-muted-foreground',
|
||||||
|
success: 'bg-emerald-500',
|
||||||
|
warning: 'bg-amber-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
info: 'bg-blue-500',
|
||||||
|
purple: 'bg-violet-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||||
|
variants[variant],
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{pulse && (
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className={cn(
|
||||||
|
'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75',
|
||||||
|
pulseColor[variant]
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
'relative inline-flex rounded-full h-2 w-2',
|
||||||
|
pulseColor[variant]
|
||||||
|
)} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user