UI: URL-based tab routing, ActivityRow extraction, and agent detail redesign

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>
This commit is contained in:
Forgotten
2026-02-19 14:39:48 -06:00
parent 38bde7d2ab
commit 3b81557f7c
10 changed files with 351 additions and 386 deletions

View File

@@ -14,86 +14,16 @@ import { MetricCard } from "../components/MetricCard";
import { EmptyState } from "../components/EmptyState";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { ActivityRow } from "../components/ActivityRow";
import { Identity } from "../components/Identity";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, Clock } from "lucide-react";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import type { Agent, Issue } from "@paperclip/shared";
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
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",
"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",
"goal.created": "created",
"goal.updated": "updated",
"cost.reported": "reported cost for",
"cost.recorded": "recorded cost for",
"company.created": "created company",
"company.updated": "updated company",
};
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}`;
default: return null;
}
}
function getStaleIssues(issues: Issue[]): Issue[] {
const now = Date.now();
return issues
.filter(
(i) =>
["in_progress", "todo"].includes(i.status) &&
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS
)
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
function getRecentIssues(issues: Issue[]): Issue[] {
return [...issues]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
export function Dashboard() {
@@ -140,7 +70,7 @@ export function Dashboard() {
enabled: !!selectedCompanyId,
});
const staleIssues = issues ? getStaleIssues(issues) : [];
const recentIssues = issues ? getRecentIssues(issues) : [];
const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]);
useEffect(() => {
@@ -247,11 +177,13 @@ export function Dashboard() {
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
<MetricCard
icon={Bot}
value={data.agents.running}
label="Agents Running"
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
label="Agents Enabled"
onClick={() => navigate("/agents")}
description={
<span>
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.running} running</span>
{", "}
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.paused} paused</span>
{", "}
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.error} errors</span>
@@ -303,58 +235,36 @@ export function Dashboard() {
Recent Activity
</h3>
<div className="border border-border divide-y divide-border">
{recentActivity.map((event) => {
const verb = formatVerb(event.action, event.details);
const name = entityNameMap.get(`${event.entityType}:${event.entityId}`);
const link = entityLink(event.entityType, event.entityId);
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) : null;
const isAnimated = animatedActivityIds.has(event.id);
return (
<div
key={event.id}
className={cn(
"px-4 py-2 flex items-center justify-between gap-2 text-sm",
link && "cursor-pointer hover:bg-accent/50 transition-colors",
isAnimated && "activity-row-enter",
)}
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>
);
})}
{recentActivity.map((event) => (
<ActivityRow
key={event.id}
event={event}
agentMap={agentMap}
entityNameMap={entityNameMap}
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}
/>
))}
</div>
</div>
)}
{/* Stale Tasks */}
{/* Recent Tasks */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Stale Tasks
Recent Tasks
</h3>
{staleIssues.length === 0 ? (
{recentIssues.length === 0 ? (
<div className="border border-border p-4">
<p className="text-sm text-muted-foreground">No stale tasks. All work is up to date.</p>
<p className="text-sm text-muted-foreground">No tasks yet.</p>
</div>
) : (
<div className="border border-border divide-y divide-border">
{staleIssues.slice(0, 10).map((issue) => (
{recentIssues.slice(0, 10).map((issue) => (
<div
key={issue.id}
className="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate(`/issues/${issue.id}`)}
>
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="truncate flex-1">{issue.title}</span>