Spaces:
Paused
Paused
| import { Link } from "@/lib/router"; | |
| import { Identity } from "./Identity"; | |
| import { timeAgo } from "../lib/timeAgo"; | |
| import { cn } from "../lib/utils"; | |
| import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared"; | |
| const ACTION_VERBS: Record<string, string> = { | |
| "issue.created": "created", | |
| "issue.updated": "updated", | |
| "issue.checked_out": "checked out", | |
| "issue.released": "released", | |
| "issue.comment_added": "commented on", | |
| "issue.attachment_added": "attached file to", | |
| "issue.attachment_removed": "removed attachment from", | |
| "issue.document_created": "created document for", | |
| "issue.document_updated": "updated document on", | |
| "issue.document_deleted": "deleted document from", | |
| "issue.commented": "commented on", | |
| "issue.deleted": "deleted", | |
| "agent.created": "created", | |
| "agent.updated": "updated", | |
| "agent.paused": "paused", | |
| "agent.resumed": "resumed", | |
| "agent.terminated": "terminated", | |
| "agent.key_created": "created API key for", | |
| "agent.budget_updated": "updated budget for", | |
| "agent.runtime_session_reset": "reset session for", | |
| "heartbeat.invoked": "invoked heartbeat for", | |
| "heartbeat.cancelled": "cancelled heartbeat for", | |
| "approval.created": "requested approval", | |
| "approval.approved": "approved", | |
| "approval.rejected": "rejected", | |
| "project.created": "created", | |
| "project.updated": "updated", | |
| "project.deleted": "deleted", | |
| "goal.created": "created", | |
| "goal.updated": "updated", | |
| "goal.deleted": "deleted", | |
| "cost.reported": "reported cost for", | |
| "cost.recorded": "recorded cost for", | |
| "company.created": "created company", | |
| "company.updated": "updated company", | |
| "company.archived": "archived", | |
| "company.budget_updated": "updated budget for", | |
| }; | |
| function humanizeValue(value: unknown): string { | |
| if (typeof value !== "string") return String(value ?? "none"); | |
| return value.replace(/_/g, " "); | |
| } | |
| function formatVerb(action: string, details?: Record<string, unknown> | null): string { | |
| if (action === "issue.updated" && details) { | |
| const previous = (details._previous ?? {}) as Record<string, unknown>; | |
| if (details.status !== undefined) { | |
| const from = previous.status; | |
| return from | |
| ? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on` | |
| : `changed status to ${humanizeValue(details.status)} on`; | |
| } | |
| if (details.priority !== undefined) { | |
| const from = previous.priority; | |
| return from | |
| ? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on` | |
| : `changed priority to ${humanizeValue(details.priority)} on`; | |
| } | |
| } | |
| return ACTION_VERBS[action] ?? action.replace(/[._]/g, " "); | |
| } | |
| function entityLink(entityType: string, entityId: string, name?: string | null): string | null { | |
| switch (entityType) { | |
| case "issue": return `/issues/${name ?? entityId}`; | |
| case "agent": return `/agents/${entityId}`; | |
| case "project": return `/projects/${deriveProjectUrlKey(name, entityId)}`; | |
| case "goal": return `/goals/${entityId}`; | |
| case "approval": return `/approvals/${entityId}`; | |
| default: return null; | |
| } | |
| } | |
| interface ActivityRowProps { | |
| event: ActivityEvent; | |
| agentMap: Map<string, Agent>; | |
| entityNameMap: Map<string, string>; | |
| entityTitleMap?: Map<string, string>; | |
| className?: string; | |
| } | |
| export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) { | |
| const verb = formatVerb(event.action, event.details); | |
| const isHeartbeatEvent = event.entityType === "heartbeat_run"; | |
| const heartbeatAgentId = isHeartbeatEvent | |
| ? (event.details as Record<string, unknown> | null)?.agentId as string | undefined | |
| : undefined; | |
| const name = isHeartbeatEvent | |
| ? (heartbeatAgentId ? entityNameMap.get(`agent:${heartbeatAgentId}`) : null) | |
| : entityNameMap.get(`${event.entityType}:${event.entityId}`); | |
| const entityTitle = entityTitleMap?.get(`${event.entityType}:${event.entityId}`); | |
| const link = isHeartbeatEvent && heartbeatAgentId | |
| ? `/agents/${heartbeatAgentId}/runs/${event.entityId}` | |
| : entityLink(event.entityType, event.entityId, name); | |
| const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null; | |
| const actorName = actor?.name ?? (event.actorType === "system" ? "System" : event.actorType === "user" ? "Board" : event.actorId || "Unknown"); | |
| const inner = ( | |
| <div className="flex gap-3"> | |
| <p className="flex-1 min-w-0 truncate"> | |
| <Identity | |
| name={actorName} | |
| size="xs" | |
| className="align-baseline" | |
| /> | |
| <span className="text-muted-foreground ml-1">{verb} </span> | |
| {name && <span className="font-medium">{name}</span>} | |
| {entityTitle && <span className="text-muted-foreground ml-1">— {entityTitle}</span>} | |
| </p> | |
| <span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span> | |
| </div> | |
| ); | |
| const classes = cn( | |
| "px-4 py-2 text-sm", | |
| link && "cursor-pointer hover:bg-accent/50 transition-colors", | |
| className, | |
| ); | |
| if (link) { | |
| return ( | |
| <Link to={link} className={cn(classes, "no-underline text-inherit block")}> | |
| {inner} | |
| </Link> | |
| ); | |
| } | |
| return ( | |
| <div className={classes}> | |
| {inner} | |
| </div> | |
| ); | |
| } | |