import { Link } from "@/lib/router"; import { AgentIcon } from "./AgentIconPicker"; import { timeAgo } from "../lib/timeAgo"; import { cn } from "../lib/utils"; import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared"; import { issueStatusIcon, issueStatusIconDefault } from "../lib/status-colors"; import { FileText, UserPlus, Loader2, Package, User, Settings, CircleAlert, CircleCheck, CircleSlash, PencilLine, PauseCircle, PlayCircle, MessageCircle, LogIn, Target, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Card } from "@/components/ui/card"; /* ------------------------------------------------------------------ */ /* Canonical verb table — one verb per action, used on every card. */ /* ------------------------------------------------------------------ */ type VerbContext = "pinned" | "chronological"; function humanize(value: unknown): string { return typeof value === "string" ? value.replace(/_/g, " ") : String(value ?? ""); } /** One verb per action. Pinned context (Tier 0) swaps a couple of verbs to * emphasize that user action is needed. */ function formatVerb( action: string, details: Record | null | undefined, context: VerbContext = "chronological", ): string { switch (action) { case "issue.created": return "opened"; case "issue.updated": { const status = details?.status; if (typeof status === "string") return `moved to ${humanize(status)}`; const priority = details?.priority; if (typeof priority === "string") return `set priority to ${humanize(priority)} on`; return "updated"; } case "issue.document_created": return "wrote doc on"; case "issue.document_updated": return "edited doc on"; case "issue.document_deleted": return "deleted doc from"; case "issue.work_product_created": return "delivered work on"; case "issue.work_product_updated": return "updated work on"; case "issue.work_product_deleted": return "removed work from"; case "issue.checked_out": return "picked up"; case "issue.released": return "released"; case "issue.commented": case "issue.comment_added": return "commented on"; case "issue.attachment_added": return "attached a file to"; case "issue.attachment_removed": return "removed attachment from"; case "issue.deleted": return "deleted"; case "approval.created": return context === "pinned" ? "needs approval on" : "requested approval on"; case "approval.approved": return "approved"; case "approval.rejected": return "rejected"; case "approval.revision_requested": return "requested changes on"; case "agent.created": return context === "pinned" ? "wants to hire" : "hired"; case "agent.paused": return "paused"; case "agent.resumed": return "resumed"; case "agent.updated": return "updated"; case "agent.terminated": return "terminated"; case "heartbeat.invoked": return "started a run on"; case "heartbeat.cancelled": return "cancelled a run on"; case "project.created": return "created project"; case "project.updated": return "updated project"; case "project.deleted": return "deleted project"; case "goal.created": return "created goal"; case "goal.updated": return "updated goal"; case "goal.deleted": return "deleted goal"; case "company.created": return "created organization"; case "company.updated": return "updated organization"; case "company.archived": return "archived organization"; case "company.budget_updated": return "updated organization budget"; default: return action.replace(/[._]/g, " "); } } /* ------------------------------------------------------------------ */ /* Event-time task status (for issue events without a lifecycle wrap) */ /* ------------------------------------------------------------------ */ function deriveTaskStatus( action: string, details: Record | null | undefined, ): string | null { switch (action) { case "issue.created": return "todo"; case "issue.updated": { const status = details?.status; return typeof status === "string" ? status : null; } case "issue.document_created": case "issue.document_updated": return "in_progress"; case "issue.work_product_created": return "in_review"; case "approval.created": return "in_review"; case "approval.approved": return "done"; case "approval.rejected": case "approval.revision_requested": return "blocked"; default: return null; } } /* ------------------------------------------------------------------ */ /* Leading icon — carries entity type AND state via color/shape. */ /* ------------------------------------------------------------------ */ type IconSpec = | { kind: "lucide"; Icon: LucideIcon; color: string; filled?: boolean; spin?: boolean } | { kind: "status-circle"; status: string }; function getIconSpec( event: ActivityEvent, details: Record | null | undefined, isActive: boolean, ): IconSpec { const action = event.action; // Heartbeat — animated when active, static otherwise if (action.startsWith("heartbeat.")) { if (isActive && action === "heartbeat.invoked") { return { kind: "lucide", Icon: Loader2, color: "text-blue-600 dark:text-blue-400", spin: true }; } return { kind: "lucide", Icon: Loader2, color: "text-muted-foreground" }; } // Approval — distinct from task status icons (those still use StatusCircle). // Rendered unfilled so the stroke glyph (check / alert / slash) stays visible. switch (action) { case "approval.created": return { kind: "lucide", Icon: CircleAlert, color: "text-amber-600 dark:text-amber-400" }; case "approval.approved": return { kind: "lucide", Icon: CircleCheck, color: "text-green-600 dark:text-green-400" }; case "approval.rejected": return { kind: "lucide", Icon: CircleSlash, color: "text-red-600 dark:text-red-400" }; case "approval.revision_requested": return { kind: "lucide", Icon: PencilLine, color: "text-amber-600 dark:text-amber-400", filled: true }; } // Agent switch (action) { case "agent.created": return { kind: "lucide", Icon: UserPlus, color: "text-purple-600 dark:text-purple-400" }; case "agent.paused": return { kind: "lucide", Icon: PauseCircle, color: "text-muted-foreground" }; case "agent.resumed": return { kind: "lucide", Icon: PlayCircle, color: "text-muted-foreground" }; case "agent.updated": case "agent.terminated": return { kind: "lucide", Icon: Settings, color: "text-muted-foreground" }; } // Document on issue if (action === "issue.document_created" || action === "issue.document_updated") { return { kind: "lucide", Icon: FileText, color: "text-blue-600 dark:text-blue-400" }; } // Work product / artifact on issue if (action.startsWith("issue.work_product_")) { return { kind: "lucide", Icon: Package, color: "text-indigo-600 dark:text-indigo-400" }; } // Comments if (action === "issue.commented" || action === "issue.comment_added") { return { kind: "lucide", Icon: MessageCircle, color: "text-muted-foreground" }; } // Issue check-out if (action === "issue.checked_out") { return { kind: "lucide", Icon: LogIn, color: "text-muted-foreground" }; } // Generic issue lifecycle → StatusCircle with event-derived status if (event.entityType === "issue") { const status = deriveTaskStatus(action, details) ?? "backlog"; return { kind: "status-circle", status }; } if (event.entityType === "goal") { return { kind: "lucide", Icon: Target, color: "text-muted-foreground" }; } return { kind: "lucide", Icon: Settings, color: "text-muted-foreground" }; } function StatusCircle({ status }: { status: string }) { const colorClass = issueStatusIcon[status] ?? issueStatusIconDefault; const isFilled = status === "done"; return ( {isFilled && } ); } function EntityIcon({ spec }: { spec: IconSpec }) { if (spec.kind === "status-circle") { return ; } const { Icon, color, filled, spin } = spec; return ( ); } /* ------------------------------------------------------------------ */ /* Content resolution — identifier (mono) + title */ /* ------------------------------------------------------------------ */ interface CardContent { actorName: string; actorType: ActivityEvent["actorType"]; actor: Agent | null; identifier: string | null; /** When true, render identifier in JetBrains Mono. Reserved for task * slugs (e.g. FOA-2) and agent names. */ identifierMono: boolean; title: string | null; link: string | null; } function resolveContent( event: ActivityEvent, agentMap: Map, entityNameMap: Map, entityTitleMap: Map | undefined, ): CardContent { const details = event.details as Record | null; const actor = event.actorType === "agent" ? agentMap.get(event.actorId) ?? null : null; const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown"); const entityTitle = entityTitleMap?.get(`${event.entityType}:${event.entityId}`) ?? null; const isHeartbeatEvent = event.entityType === "heartbeat_run"; const heartbeatAgentId = isHeartbeatEvent ? (details?.agentId as string | undefined) : undefined; const entityName = isHeartbeatEvent ? heartbeatAgentId ? entityNameMap.get(`agent:${heartbeatAgentId}`) ?? null : null : entityNameMap.get(`${event.entityType}:${event.entityId}`) ?? null; const docKey = details?.key as string | undefined; const isDocEvent = event.action === "issue.document_created" || event.action === "issue.document_updated"; const issueSlug = entityName ?? event.entityId; const hiredAgentId = details?.hiredAgentId as string | undefined; const approvalAgentId = details?.requestedByAgentId as string | undefined; const approvalAgentName = approvalAgentId ? agentMap.get(approvalAgentId)?.name ?? null : null; const approvalType = details?.type as string | undefined; const link = isHeartbeatEvent && heartbeatAgentId ? `/agents/${heartbeatAgentId}/runs/${event.entityId}` : event.entityType === "issue" ? isDocEvent && docKey ? `/issues/${issueSlug}#document-${encodeURIComponent(docKey)}` : `/issues/${issueSlug}` : event.entityType === "agent" ? `/agents/${event.entityId}` : event.entityType === "approval" ? event.action === "approval.approved" && hiredAgentId ? `/agents/${hiredAgentId}` : `/approvals/${event.entityId}` : event.entityType === "project" ? `/projects/${deriveProjectUrlKey(entityName, event.entityId)}` : event.entityType === "goal" ? `/goals/${event.entityId}` : null; let identifier: string | null = null; let identifierMono = true; let title: string | null = null; if (event.entityType === "issue") { // Docs (e.g. FOA-2#hiring-plan) previously showed the doc key, but it // duplicates the human-readable title that follows. Show just the task // slug; the title carries the document name. identifier = entityName; title = entityTitle; } else if (event.entityType === "approval") { if (approvalAgentName) { identifier = approvalAgentName; } else { identifier = approvalType ? humanize(approvalType) : "approval"; identifierMono = false; } title = entityTitle; } else if (event.entityType === "agent") { identifier = (details?.name as string | undefined) ?? entityName ?? event.entityId; title = null; } else if (isHeartbeatEvent) { identifier = entityName; title = null; } else { identifier = entityName; title = entityTitle; } return { actorName, actorType: event.actorType, actor, identifier, identifierMono, title, link, }; } function ActorGlyph({ content }: { content: CardContent }) { if (content.actorType === "agent") { return ( ); } if (content.actorType === "user") { return ; } return ; } /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ interface FeedCardProps { event: ActivityEvent; agentMap: Map; entityNameMap: Map; entityTitleMap?: Map; /** Retained for call-site compatibility; no longer read — the collapsed * card always shows the event-derived status. Lifecycle aggregation (a * later pass) will pass the live status through its own wrapper. */ entityStatusMap?: Map; isActive?: boolean; /** Tier 2 treatment: mutes the verb and title text. Actor name and * timestamp retain their color; leading icon retains its color. */ isMuted?: boolean; /** Tier 0 treatment: adds a trailing "Review →" affordance and swaps in * pinned-context verb phrasing ("needs approval on", "wants to hire"). */ isPinned?: boolean; className?: string; } export function FeedCard({ event, agentMap, entityNameMap, entityTitleMap, isActive = false, isMuted = false, isPinned = false, className, }: FeedCardProps) { const details = event.details as Record | null; const content = resolveContent(event, agentMap, entityNameMap, entityTitleMap); const verb = formatVerb(event.action, details, isPinned ? "pinned" : "chronological"); const iconSpec = getIconSpec(event, details, isActive); const mutedTextBase = isMuted ? "text-muted-foreground/70" : "text-(--hex-959596)"; const mutedTextHover = isMuted ? "" : "group-hover:text-white"; const card = ( {content.actorName} {verb} {content.identifier && ( {content.identifier} )} {content.title && ( {content.title} )} {isPinned && ( Review → )} {timeAgo(event.createdAt)} ); if (content.link) { return ( {card} ); } return card; }