Make agents list force list view on mobile with condensed trailing info. Add mobile bottom bar for config save/cancel and live run indicator on agent detail. Make MetricCard, PageTabBar, Dashboard tasks, and ActivityRow responsive for small screens. Add xs avatar size for inline text flow. Remove redundant budget displays from agent overview, properties panel, costs tab, and config form. Add attachment activity verb labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
130 lines
4.6 KiB
TypeScript
130 lines
4.6 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.attachment_added": "attached file to",
|
|
"issue.attachment_removed": "removed attachment 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): 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 text-sm",
|
|
link && "cursor-pointer hover:bg-accent/50 transition-colors",
|
|
className,
|
|
)}
|
|
onClick={link ? () => navigate(link) : undefined}
|
|
>
|
|
<div className="flex gap-3">
|
|
<p className="flex-1 min-w-0">
|
|
<Identity
|
|
name={actor?.name ?? (event.actorType === "system" ? "System" : event.actorId || "You")}
|
|
size="xs"
|
|
className="align-baseline"
|
|
/>
|
|
<span className="text-muted-foreground ml-1">{verb} </span>
|
|
{name && <span className="font-medium">{name}</span>}
|
|
</p>
|
|
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">{timeAgo(event.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|