block
Agent Activity Feed
Scrollable real-time feed of agent actions with avatars, status badges, and relative timestamps.
Installation
npx shadcn@latest add https://optimotive-ui.dev.optimotive-tools.co.uk/registry.json agent-activity-feedPreview
Source
blocks/agent-activity-feed.tsx
"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ActivityType =
| "task_started"
| "task_completed"
| "task_failed"
| "agent_online"
| "agent_offline"
| "delegated"
| "memory_written"
| "skill_used"
export interface ActivityItem {
id: string
agentName: string
agentEmoji?: string
agentAvatarUrl?: string
department?: string
type: ActivityType
/** Human-readable action description */
action: string
/** Optional contextual detail (task name, skill name, etc.) */
detail?: string
timestamp: Date
}
// ---------------------------------------------------------------------------
// Activity type config
// ---------------------------------------------------------------------------
const ACTIVITY_CONFIG: Record<
ActivityType,
{ icon: string; badgeClass: string; badgeLabel: string }
> = {
task_started: {
icon: "▶",
badgeLabel: "Started",
badgeClass: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800",
},
task_completed: {
icon: "✓",
badgeLabel: "Done",
badgeClass: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-300 dark:border-emerald-800",
},
task_failed: {
icon: "✕",
badgeLabel: "Failed",
badgeClass: "bg-red-50 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800",
},
agent_online: {
icon: "↑",
badgeLabel: "Online",
badgeClass: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950 dark:text-emerald-300 dark:border-emerald-800",
},
agent_offline: {
icon: "↓",
badgeLabel: "Offline",
badgeClass: "bg-zinc-100 text-zinc-500 border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700",
},
delegated: {
icon: "⇢",
badgeLabel: "Delegated",
badgeClass: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300 dark:border-violet-800",
},
memory_written: {
icon: "◈",
badgeLabel: "Memory",
badgeClass: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800",
},
skill_used: {
icon: "◆",
badgeLabel: "Skill",
badgeClass: "bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-800",
},
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function relativeTime(date: Date): string {
const diff = Date.now() - date.getTime()
const s = Math.floor(diff / 1000)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
const d = Math.floor(h / 24)
if (s < 10) return "just now"
if (s < 60) return `${s}s ago`
if (m < 60) return `${m}m ago`
if (h < 24) return `${h}h ago`
return `${d}d ago`
}
function getInitials(name: string): string {
return name.split(/[\s_-]+/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("")
}
// ---------------------------------------------------------------------------
// Demo data
// ---------------------------------------------------------------------------
const now = new Date()
const ago = (ms: number) => new Date(now.getTime() - ms)
export const DEMO_ACTIVITY_ITEMS: ActivityItem[] = [
{
id: "1",
agentName: "Chloe",
agentEmoji: "🎨",
department: "Design",
type: "task_completed",
action: "Finished building",
detail: "AgentCard component spec",
timestamp: ago(45_000),
},
{
id: "2",
agentName: "Elena",
agentEmoji: "⚡",
department: "Engineering",
type: "task_started",
action: "Started infrastructure review",
detail: "Scaling analysis for agent queue",
timestamp: ago(2 * 60_000),
},
{
id: "3",
agentName: "Marcus",
agentEmoji: "🏛️",
department: "Strategy",
type: "memory_written",
action: "Wrote memory",
detail: "Competitive positioning: AI-native platforms",
timestamp: ago(5 * 60_000),
},
{
id: "4",
agentName: "Iris",
agentEmoji: "🌸",
department: "Product",
type: "skill_used",
action: "Applied skill",
detail: "ux-heuristics on onboarding flow",
timestamp: ago(11 * 60_000),
},
{
id: "5",
agentName: "Victor",
agentEmoji: "🛡️",
department: "Risk",
type: "delegated",
action: "Delegated to Elena",
detail: "Retry queue failure mode analysis",
timestamp: ago(18 * 60_000),
},
{
id: "6",
agentName: "Darren",
agentEmoji: "🖌️",
department: "Design",
type: "task_completed",
action: "Generated concept art",
detail: "Platform hero illustration v3",
timestamp: ago(34 * 60_000),
},
{
id: "7",
agentName: "Elena",
agentEmoji: "⚡",
department: "Engineering",
type: "agent_online",
action: "Came online",
timestamp: ago(60 * 60_000),
},
{
id: "8",
agentName: "Memory Worker",
agentEmoji: "🧠",
department: "Memory",
type: "task_completed",
action: "Compressed conversation context",
detail: "Saved 42 semantic facts",
timestamp: ago(2 * 60 * 60_000),
},
]
// ---------------------------------------------------------------------------
// ActivityRow sub-component
// ---------------------------------------------------------------------------
interface ActivityRowProps {
item: ActivityItem
isLast: boolean
}
function ActivityRow({ item, isLast }: ActivityRowProps) {
const config = ACTIVITY_CONFIG[item.type]
const [relTime, setRelTime] = React.useState(() => relativeTime(item.timestamp))
// Refresh relative timestamps every 30 s
React.useEffect(() => {
const id = setInterval(() => setRelTime(relativeTime(item.timestamp)), 30_000)
return () => clearInterval(id)
}, [item.timestamp])
return (
<div className="group relative">
<div className="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/40">
{/* Avatar with activity type pip */}
<div className="relative mt-0.5 shrink-0">
<Avatar className="size-8 border border-border/50 text-sm">
{item.agentAvatarUrl && (
<AvatarImage src={item.agentAvatarUrl} alt={item.agentName} />
)}
<AvatarFallback className="bg-muted text-muted-foreground text-xs font-medium">
{item.agentEmoji ?? getInitials(item.agentName)}
</AvatarFallback>
</Avatar>
<span className="absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full border border-background bg-muted text-[8px] leading-none">
{config.icon}
</span>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-semibold text-foreground leading-tight">
{item.agentName}
</span>
{item.department && (
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
{item.department}
</span>
)}
<Badge
variant="outline"
className={cn(
"px-1.5 py-0 text-[10px] leading-4 rounded-full border",
config.badgeClass
)}
>
{config.badgeLabel}
</Badge>
</div>
<p className="mt-0.5 text-sm text-muted-foreground leading-snug">
{item.action}
{item.detail && (
<span className="text-foreground/70">
{" — "}
<em className="not-italic font-medium">{item.detail}</em>
</span>
)}
</p>
</div>
{/* Timestamp */}
<time
dateTime={item.timestamp.toISOString()}
className="mt-0.5 shrink-0 text-[11px] text-muted-foreground tabular-nums"
title={item.timestamp.toLocaleString()}
>
{relTime}
</time>
</div>
{!isLast && <Separator className="mx-4" />}
</div>
)
}
// ---------------------------------------------------------------------------
// AgentActivityFeed — main export
// ---------------------------------------------------------------------------
export interface AgentActivityFeedProps {
items?: ActivityItem[]
title?: string
/** Max height of the scroll area (px) */
maxHeight?: number
className?: string
showHeader?: boolean
/** Optional slot for actions in the header (e.g. refresh, filter buttons) */
headerAction?: React.ReactNode
}
/**
* AgentActivityFeed
*
* A live-feeling, scrollable feed of agent activity events.
* Timestamps auto-refresh every 30 s. Ships with demo data.
*
* ### Usage
* ```tsx
* <AgentActivityFeed items={myItems} title="Platform Activity" />
* <AgentActivityFeed /> // renders with DEMO_ACTIVITY_ITEMS
* ```
*
* ### shadcn dependencies
* - Avatar, AvatarFallback, AvatarImage
* - Badge
* - ScrollArea
* - Separator
*/
export function AgentActivityFeed({
items = DEMO_ACTIVITY_ITEMS,
title = "Agent Activity",
maxHeight = 480,
className,
showHeader = true,
headerAction,
}: AgentActivityFeedProps) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
className
)}
>
{showHeader && (
<>
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
{/* Live indicator */}
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
<h3 className="text-sm font-semibold tracking-tight">{title}</h3>
<Badge variant="secondary" className="px-1.5 py-0 text-xs tabular-nums">
{items.length}
</Badge>
</div>
{headerAction}
</div>
<Separator />
</>
)}
<ScrollArea style={{ maxHeight }} className="w-full">
<div className="flex flex-col">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<span className="text-3xl">◎</span>
<p className="text-sm">No activity yet</p>
</div>
) : (
items.map((item, i) => (
<ActivityRow key={item.id} item={item} isLast={i === items.length - 1} />
))
)}
</div>
</ScrollArea>
</div>
)
}