import { AgentAvatar } from "@/components/AgentAvatar"; import { useMemo, useState, useRef, useCallback, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { useVisibilityRefetchInterval } from "@/lib/polling"; import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling"; import type { ActivityEvent, Agent } from "@paperclipai/shared"; import { activityApi } from "../api/activity"; import { agentsApi } from "../api/agents"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { FeedCard } from "./FeedCard"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuCheckboxItem, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { ListFilter, Layers, ChevronDown, ChevronRight, User, Settings } from "lucide-react"; import { timeAgo } from "../lib/timeAgo"; /* ------------------------------------------------------------------ */ /* Event Tier Classification */ /* ------------------------------------------------------------------ */ type EventTier = 1 | 2 | 3; /** Tier 1 = cards (high weight), Tier 2 = one-liners, Tier 3 = hidden by default */ const ACTION_TIER: Record = { // Tier 1 — Cards "issue.created": 1, "issue.document_created": 1, "approval.created": 1, "approval.approved": 1, "approval.rejected": 1, "approval.revision_requested": 1, "agent.created": 1, "issue.work_product_created": 1, // Tier 2 — One-liners "issue.updated": 2, "issue.work_product_updated": 2, "issue.work_product_deleted": 2, "issue.checked_out": 2, "issue.comment_added": 2, "issue.commented": 2, "heartbeat.invoked": 2, "heartbeat.cancelled": 2, "agent.paused": 2, "agent.resumed": 2, "agent.updated": 2, // Tier 3 — Hidden "issue.read_marked": 3, "issue.read_unmarked": 3, "issue.inbox_archived": 3, "issue.inbox_unarchived": 3, "issue.released": 3, "issue.attachment_added": 3, "issue.attachment_removed": 3, "issue.document_deleted": 3, "issue.document_updated": 2, "issue.deleted": 3, "issue.feedback_vote_saved": 3, "agent.key_created": 3, "agent.budget_updated": 3, "agent.runtime_session_reset": 3, "agent.skills_synced": 3, "agent.terminated": 2, "approval.requester_wakeup_queued": 3, "approval.requester_wakeup_failed": 3, "company.created": 3, "company.updated": 3, "company.archived": 3, "company.budget_updated": 3, "company.skill_created": 3, "company.skill_deleted": 3, "project.created": 2, "project.updated": 3, "project.deleted": 3, "goal.created": 2, "goal.updated": 3, "goal.deleted": 3, "cost.reported": 3, "cost.recorded": 3, }; function getEventTier(event: ActivityEvent): EventTier { // Special case: issue.updated with status → in_review is tier 1 if (event.action === "issue.updated" && event.details) { const details = event.details as Record; if (details.status === "in_review") return 1; } return ACTION_TIER[event.action] ?? 3; } /* ------------------------------------------------------------------ */ /* Filter & Group Types */ /* ------------------------------------------------------------------ */ type FilterValue = "all" | "in-progress" | "for-review" | "completed"; type GroupMode = "flat" | "by-task"; const FILTER_OPTIONS: Array<{ value: FilterValue; label: string }> = [ { value: "all", label: "All" }, { value: "in-progress", label: "In Progress" }, { value: "for-review", label: "In Review" }, { value: "completed", label: "Done" }, ]; const FILTER_ACTIONS: Record | null> = { all: null, "in-progress": new Set(["issue.created", "issue.checked_out", "heartbeat.invoked"]), "for-review": new Set(["approval.created", "issue.document_created", "issue.document_updated"]), completed: new Set(["approval.approved"]), }; const STATUS_FILTER_MAP: Record | null> = { all: null, "in-progress": new Set(["in_progress"]), "for-review": new Set(["in_review"]), completed: new Set(["done"]), }; function matchesFilter(event: ActivityEvent, filter: FilterValue): boolean { if (filter === "all") return true; const actions = FILTER_ACTIONS[filter]; if (actions?.has(event.action)) return true; if (event.action === "issue.updated" && event.details) { const statusSet = STATUS_FILTER_MAP[filter]; const details = event.details as Record; if (statusSet && typeof details.status === "string" && statusSet.has(details.status)) return true; } return false; } /* ------------------------------------------------------------------ */ /* Collapse Sequential Events */ /* ------------------------------------------------------------------ */ interface CollapsedGroup { type: "collapsed"; events: ActivityEvent[]; entityId: string; entityType: string; latestEvent: ActivityEvent; } type FeedItem = ActivityEvent | CollapsedGroup; const COLLAPSE_WINDOW_MS = 5 * 60 * 1000; // 5 minutes function collapseSequential(events: ActivityEvent[]): FeedItem[] { if (events.length === 0) return []; const result: FeedItem[] = []; let currentGroup: ActivityEvent[] = [events[0]]; let currentKey = `${events[0].entityType}:${events[0].entityId}`; for (let i = 1; i < events.length; i++) { const evt = events[i]; const key = `${evt.entityType}:${evt.entityId}`; const prevTime = new Date(currentGroup[currentGroup.length - 1].createdAt).getTime(); const thisTime = new Date(evt.createdAt).getTime(); const withinWindow = Math.abs(prevTime - thisTime) <= COLLAPSE_WINDOW_MS; // Only collapse tier-2 events, never collapse tier-1 cards const tier = getEventTier(evt); if (key === currentKey && withinWindow && tier >= 2) { currentGroup.push(evt); } else { flushGroup(currentGroup, result); currentGroup = [evt]; currentKey = key; } } flushGroup(currentGroup, result); return result; } function flushGroup(group: ActivityEvent[], result: FeedItem[]) { if (group.length <= 2) { // Don't collapse 1-2 items for (const evt of group) result.push(evt); } else { result.push({ type: "collapsed", events: group, entityId: group[0].entityId, entityType: group[0].entityType, latestEvent: group[0], }); } } /* ------------------------------------------------------------------ */ /* Recency Helpers */ /* ------------------------------------------------------------------ */ const FIVE_MINUTES = 5 * 60 * 1000; function isRecent(createdAt: Date | string): boolean { return Date.now() - new Date(createdAt).getTime() < FIVE_MINUTES; } /* ------------------------------------------------------------------ */ /* Animation keyframes (injected once via style tag) */ /* ------------------------------------------------------------------ */ const FEED_ANIMATION_CSS = ` @keyframes feed-slide-in { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } } .feed-item-new { animation: feed-slide-in 300ms ease-out; } `; const INITIAL_VISIBLE = 50; const LOAD_MORE_COUNT = 30; /* ------------------------------------------------------------------ */ /* Collapsed Group Component */ /* ------------------------------------------------------------------ */ function CollapsedFeedGroup({ group, agentMap, entityNameMap, entityTitleMap, }: { group: CollapsedGroup; agentMap: Map; entityNameMap: Map; entityTitleMap: Map; }) { const [expanded, setExpanded] = useState(false); const actor = group.latestEvent.actorType === "agent" ? agentMap.get(group.latestEvent.actorId) : null; const actorName = actor?.name ?? (group.latestEvent.actorType === "system" ? "System" : group.latestEvent.actorType === "user" ? "Board" : "Unknown"); const entityName = entityNameMap.get(`${group.entityType}:${group.entityId}`); return (
{/* design-allow(card-pattern): interactive {expanded && (
{group.events.map((evt) => ( ))}
)}
); } /* ------------------------------------------------------------------ */ /* Main Component */ /* ------------------------------------------------------------------ */ interface ActivityFeedProps { className?: string; } export function ActivityFeed({ className }: ActivityFeedProps) { const { selectedCompanyId } = useCompany(); const [filter, setFilter] = useState("all"); const [groupMode, setGroupMode] = useState("flat"); const [showAllActivity, setShowAllActivity] = useState(false); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE); const scrollRef = useRef(null); const seenIdsRef = useRef>(new Set()); const initialLoadRef = useRef(true); // Inject animation CSS once useEffect(() => { const id = "feed-animation-styles"; if (document.getElementById(id)) return; const style = document.createElement("style"); style.id = id; style.textContent = FEED_ANIMATION_CSS; document.head.appendChild(style); }, []); // Fetch company-level activity. Poll ~5s when the tab is focused; slow/stop in the // background so restored tabs don't storm the activity endpoint (PAP-12556). const activityRefetchInterval = useVisibilityRefetchInterval({ visibleMs: 5000 }); const activityQueryKey = queryKeys.activity(selectedCompanyId ?? ""); const sharedActivity = useSharedPollingQuery({ companyId: selectedCompanyId, resourceKey: "activity", queryKey: activityQueryKey, enabled: !!selectedCompanyId, refetchInterval: activityRefetchInterval, leaderOnly: true, }); const { data: activity, dataUpdatedAt: activityUpdatedAt } = useQuery({ queryKey: activityQueryKey, queryFn: ({ signal }) => activityApi.list(selectedCompanyId!, undefined, { signal }), enabled: sharedActivity.enabled, refetchInterval: sharedActivity.refetchInterval, }); usePublishSharedQueryData(sharedActivity, activity, activityUpdatedAt); // Fetch agents for name resolution + empty state const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId ?? ""), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); // Fetch issues for name resolution const { data: issues } = useQuery({ queryKey: queryKeys.issues.list(selectedCompanyId ?? ""), queryFn: () => issuesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const agentMap = useMemo(() => { const map = new Map(); for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); const entityNameMap = useMemo(() => { const map = new Map(); for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name); for (const i of issues ?? []) map.set(`issue:${i.id}`, i.identifier ?? i.id); return map; }, [agents, issues]); const entityTitleMap = useMemo(() => { const map = new Map(); for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title); return map; }, [issues]); const entityStatusMap = useMemo(() => { const map = new Map(); for (const i of issues ?? []) map.set(`issue:${i.id}`, i.status); return map; }, [issues]); // Filter, tier, sort events const processedItems = useMemo(() => { const events = (activity ?? []) .filter((evt) => { const tier = getEventTier(evt); if (!showAllActivity && tier === 3) return false; return matchesFilter(evt, filter); }) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return collapseSequential(events); }, [activity, filter, showAllActivity]); const visibleItems = processedItems.slice(0, visibleCount); const hasMore = processedItems.length > visibleCount; // Track seen IDs for entrance animation useEffect(() => { if (!activity) return; if (initialLoadRef.current) { // On first load, mark all as seen (no animation for initial batch) for (const evt of activity) seenIdsRef.current.add(evt.id); initialLoadRef.current = false; } }, [activity]); const isNewItem = useCallback((id: string) => { if (initialLoadRef.current) return false; if (seenIdsRef.current.has(id)) return false; seenIdsRef.current.add(id); return true; }, []); // Reset visible count when filter changes useEffect(() => { setVisibleCount(INITIAL_VISIBLE); }, [filter]); // Load more on scroll to bottom const handleScroll = useCallback(() => { const el = scrollRef.current; if (!el || !hasMore) return; if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) { setVisibleCount((prev) => prev + LOAD_MORE_COUNT); } }, [hasMore]); // Check for active heartbeat runs (recent invoked without cancelled) const activeHeartbeatEntityIds = useMemo(() => { const ids = new Set(); for (const evt of activity ?? []) { if (evt.action === "heartbeat.invoked" && isRecent(evt.createdAt)) { ids.add(evt.entityId); } } // Remove any that have been cancelled for (const evt of activity ?? []) { if (evt.action === "heartbeat.cancelled") { ids.delete(evt.entityId); } } return ids; }, [activity]); /* ---------------------------------------------------------------- */ /* Render helpers */ /* ---------------------------------------------------------------- */ const renderItem = (item: FeedItem, index: number, items: FeedItem[]) => { // Insert recency separator let separator: React.ReactNode = null; if (index > 0) { const prevCreatedAt = "type" in item ? item.latestEvent.createdAt : item.createdAt; const prevItem = items[index - 1]; const prevPrevCreatedAt = "type" in prevItem ? prevItem.latestEvent.createdAt : prevItem.createdAt; const prevIsRecent = isRecent(prevPrevCreatedAt); const thisIsRecent = isRecent(prevCreatedAt); if (prevIsRecent && !thisIsRecent) { separator = (
Earlier
); } } if ("type" in item && item.type === "collapsed") { const animClass = isNewItem(item.latestEvent.id) ? "feed-item-new" : ""; return (
{separator}
); } const evt = item as ActivityEvent; const tier = getEventTier(evt); const animClass = isNewItem(evt.id) ? "feed-item-new" : ""; const isActiveHeartbeat = activeHeartbeatEntityIds.has(evt.entityId) && evt.action === "heartbeat.invoked"; if (tier === 1) { return (
{separator}
); } // Tier 2 (and tier 3 if showAll) return (
{separator}
); }; const renderGrouped = () => { // Group by issue entityId const groups = new Map(); for (const item of visibleItems) { const key = "type" in item ? item.entityId : (item.entityType === "issue" ? item.entityId : "__other__"); const existing = groups.get(key) ?? []; existing.push(item); groups.set(key, existing); } return Array.from(groups.entries()).map(([groupKey, items]) => { const isOther = groupKey === "__other__"; const issueName = entityNameMap.get(`issue:${groupKey}`); const issueTitle = entityTitleMap.get(`issue:${groupKey}`); const label = isOther ? "Other activity" : `${issueName ?? groupKey}${issueTitle ? ` — ${issueTitle}` : ""}`; return (

{label}

{items.map((item, i) => renderItem(item, i, items))}
); }); }; /* ---------------------------------------------------------------- */ /* Empty state */ /* ---------------------------------------------------------------- */ const emptyMessage = useMemo(() => { if (!agents) return null; if (agents.length === 0) { return { text: "No agents set up yet. Add an agent to get started.", showPulse: false, }; } const allPaused = agents.every((a) => a.status === "paused"); if (allPaused) { return { text: "All agents are paused. Resume agents from the sidebar to see activity.", showPulse: false, }; } return { text: "Your agents are running — activity will appear here shortly.", showPulse: true, }; }, [agents]); const isEmpty = visibleItems.length === 0; return ( ); }