Files
paperclip/ui/src/components/ActivityRow.tsx
2026-03-13 21:30:48 -05:00

147 lines
5.2 KiB
TypeScript

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