Switch agents, issues, and approvals pages from query-param tabs to URL-based routes (/agents/active, /issues/backlog, /approvals/pending). Extract shared ActivityRow component used by both Dashboard and Activity pages. Redesign agent detail overview with LatestRunCard showing live/ recent run status, move permissions toggle to Configuration tab, add budget progress bar, and reorder tabs (Runs before Configuration). Dashboard now counts idle agents as active and shows "Recent Tasks" instead of "Stale Tasks". Remove unused MyIssues page and sidebar link. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
import { useNavigate } from "react-router-dom";
|
|
import { Identity } from "./Identity";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { cn } from "../lib/utils";
|
|
import type { ActivityEvent } from "@paperclip/shared";
|
|
import type { Agent } from "@paperclip/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.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): string | null {
|
|
switch (entityType) {
|
|
case "issue": return `/issues/${entityId}`;
|
|
case "agent": return `/agents/${entityId}`;
|
|
case "project": return `/projects/${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>;
|
|
className?: string;
|
|
}
|
|
|
|
export function ActivityRow({ event, agentMap, entityNameMap, className }: ActivityRowProps) {
|
|
const navigate = useNavigate();
|
|
|
|
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 link = isHeartbeatEvent && heartbeatAgentId
|
|
? `/agents/${heartbeatAgentId}/runs/${event.entityId}`
|
|
: entityLink(event.entityType, event.entityId);
|
|
|
|
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"px-4 py-2 flex items-center justify-between gap-2 text-sm",
|
|
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
|
className,
|
|
)}
|
|
onClick={link ? () => navigate(link) : undefined}
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<Identity
|
|
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
|
|
size="sm"
|
|
/>
|
|
<span className="text-muted-foreground shrink-0">{verb}</span>
|
|
{name && <span className="truncate">{name}</span>}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
|
{timeAgo(event.createdAt)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|