← Optimotive UI/blocks/Agent Activity Feed
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-feed

Preview

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>
  )
}