From 3b81557f7cf8b71b633e3f78a067a9c446ea4847 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 19 Feb 2026 14:39:48 -0600 Subject: [PATCH] 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 --- server/src/services/dashboard.ts | 5 +- ui/src/App.tsx | 21 ++- ui/src/components/ActivityRow.tsx | 127 ++++++++++++++++ ui/src/components/Sidebar.tsx | 2 - ui/src/pages/Activity.tsx | 103 ++----------- ui/src/pages/AgentDetail.tsx | 234 +++++++++++++++--------------- ui/src/pages/Agents.tsx | 55 +++---- ui/src/pages/Approvals.tsx | 36 ++--- ui/src/pages/Dashboard.tsx | 138 +++--------------- ui/src/pages/Issues.tsx | 16 +- 10 files changed, 351 insertions(+), 386 deletions(-) create mode 100644 ui/src/components/ActivityRow.tsx diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index 8f975af8..0828d914 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -52,7 +52,10 @@ export function dashboardService(db: Db) { error: 0, }; for (const row of agentRows) { - agentCounts[row.status] = Number(row.count); + const count = Number(row.count); + // "idle" agents are operational — count them as active + const bucket = row.status === "idle" ? "active" : row.status; + agentCounts[bucket] = (agentCounts[bucket] ?? 0) + count; } const taskCounts: Record = { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a997db64..4eeb7200 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -15,7 +15,6 @@ import { ApprovalDetail } from "./pages/ApprovalDetail"; import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; -import { MyIssues } from "./pages/MyIssues"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; @@ -27,22 +26,32 @@ export function App() { } /> } /> } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> + } /> } /> } /> } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> } /> - } /> } /> diff --git a/ui/src/components/ActivityRow.tsx b/ui/src/components/ActivityRow.tsx new file mode 100644 index 00000000..bdd314a8 --- /dev/null +++ b/ui/src/components/ActivityRow.tsx @@ -0,0 +1,127 @@ +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 = { + "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 | null): string { + if (action === "issue.updated" && details) { + const previous = (details._previous ?? {}) as Record; + 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; + entityNameMap: Map; + 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 | 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 ( +
navigate(link) : undefined} + > +
+ + {verb} + {name && {name}} +
+ + {timeAgo(event.createdAt)} + +
+ ); +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 45ca6dee..b6a63d6b 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -9,7 +9,6 @@ import { History, Search, SquarePen, - ListTodo, ShieldCheck, BookOpen, Paperclip, @@ -79,7 +78,6 @@ export function Sidebar() { icon={Inbox} badge={sidebarBadges?.inbox} /> - diff --git a/ui/src/pages/Activity.tsx b/ui/src/pages/Activity.tsx index 4a7a5390..198a983b 100644 --- a/ui/src/pages/Activity.tsx +++ b/ui/src/pages/Activity.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { activityApi } from "../api/activity"; import { agentsApi } from "../api/agents"; @@ -10,8 +9,7 @@ import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; -import { Identity } from "../components/Identity"; -import { timeAgo } from "../lib/timeAgo"; +import { ActivityRow } from "../components/ActivityRow"; import { Select, SelectContent, @@ -22,74 +20,9 @@ import { import { History } from "lucide-react"; import type { Agent } from "@paperclip/shared"; -// Maps action → verb phrase. When the entity name is available it reads as: -// "[Actor] commented on "Fix the bug"" -// When not available, it falls back to just the verb. -const ACTION_VERBS: Record = { - "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.updated": "updated", - "company.archived": "archived", - "company.budget_updated": "updated budget for", -}; - -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; - } -} - -function actorIdentity(actorType: string, actorId: string, agentMap: Map) { - if (actorType === "agent") { - const agent = agentMap.get(actorId); - return ; - } - if (actorType === "system") return ; - return ; -} - export function Activity() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); - const navigate = useNavigate(); const [filter, setFilter] = useState("all"); useEffect(() => { @@ -132,7 +65,6 @@ export function Activity() { return map; }, [agents]); - // Unified map: "entityType:entityId" → display name const entityNameMap = useMemo(() => { const map = new Map(); for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title); @@ -182,31 +114,14 @@ export function Activity() { {filtered && filtered.length > 0 && (
- {filtered.map((event) => { - const link = entityLink(event.entityType, event.entityId); - const verb = ACTION_VERBS[event.action] ?? event.action.replace(/[._]/g, " "); - const name = entityNameMap.get(`${event.entityType}:${event.entityId}`); - return ( -
navigate(link) : undefined} - > -
- {actorIdentity(event.actorType, event.actorId, agentMap)} - {verb} - {name && ( - {name} - )} -
- - {timeAgo(event.createdAt)} - -
- ); - })} + {filtered.map((event) => ( + + ))}
)} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 55d1b4e3..03eff5e4 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, Link, useBeforeUnload, useSearchParams } from "react-router-dom"; +import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { agentsApi, type AgentKey } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; @@ -18,6 +18,7 @@ import type { TranscriptEntry } from "../adapters"; import { StatusBadge } from "../components/StatusBadge"; import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; +import { Identity } from "../components/Identity"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { cn } from "../lib/utils"; import { Tabs, TabsContent } from "@/components/ui/tabs"; @@ -48,7 +49,7 @@ import { ChevronRight, } from "lucide-react"; import { Input } from "@/components/ui/input"; -import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState, AgentTaskSession } from "@paperclip/shared"; +import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared"; const runStatusIcons: Record = { succeeded: { icon: CheckCircle2, color: "text-green-400" }, @@ -152,17 +153,16 @@ function asRecord(value: unknown): Record | null { } export function AgentDetail() { - const { agentId, runId: urlRunId } = useParams<{ agentId: string; runId?: string }>(); + const { agentId, tab: urlTab, runId: urlRunId } = useParams<{ agentId: string; tab?: string; runId?: string }>(); const { selectedCompanyId } = useCompany(); const { closePanel } = usePanel(); const { openNewIssue } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); const [actionError, setActionError] = useState(null); const [moreOpen, setMoreOpen] = useState(false); - const activeTab = urlRunId ? "runs" as AgentDetailTab : parseAgentDetailTab(searchParams.get("tab")); + const activeTab = urlRunId ? "runs" as AgentDetailTab : parseAgentDetailTab(urlTab ?? null); const [configDirty, setConfigDirty] = useState(false); const [configSaving, setConfigSaving] = useState(false); const saveConfigActionRef = useRef<(() => void) | null>(null); @@ -182,12 +182,6 @@ export function AgentDetail() { enabled: !!agentId, }); - const { data: taskSessions } = useQuery({ - queryKey: queryKeys.agents.taskSessions(agentId!), - queryFn: () => agentsApi.taskSessions(agentId!), - enabled: !!agentId, - }); - const { data: heartbeats } = useQuery({ queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId), queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId), @@ -284,17 +278,8 @@ export function AgentDetail() { const setActiveTab = useCallback((nextTab: string) => { if (configDirty && !window.confirm("You have unsaved changes. Discard them?")) return; const next = parseAgentDetailTab(nextTab); - // If we're on a /runs/:runId URL and switching tabs, navigate back to base agent URL - if (urlRunId) { - const tabParam = next === "overview" ? "" : `?tab=${next}`; - navigate(`/agents/${agentId}${tabParam}`, { replace: true }); - return; - } - const params = new URLSearchParams(searchParams); - if (next === "overview") params.delete("tab"); - else params.set("tab", next); - setSearchParams(params); - }, [searchParams, setSearchParams, urlRunId, agentId, navigate, configDirty]); + navigate(`/agents/${agentId}/${next}`, { replace: !!urlRunId }); + }, [agentId, navigate, configDirty, urlRunId]); if (isLoading) return

Loading...

; if (error) return

{error.message}

; @@ -435,8 +420,8 @@ export function AgentDetail() { Never } - - {(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId) - ? {String(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId).slice(0, 16)}... - : No session - } + +
+
+
{ + const pct = agent.budgetMonthlyCents > 0 + ? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100) + : 0; + return pct > 90 ? "bg-red-400" : pct > 70 ? "bg-yellow-400" : "bg-green-400"; + })(), + )} + style={{ width: `${Math.min(100, agent.budgetMonthlyCents > 0 ? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100) : 0)}%` }} + /> +
+ + {formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)} + +
- - {taskSessions?.length ?? 0} - - {runtimeState && ( - - {formatCents(runtimeState.totalCostCents)} - - )}
@@ -502,7 +494,7 @@ export function AgentDetail() { to={`/agents/${reportsToAgent.id}`} className="text-blue-400 hover:underline" > - {reportsToAgent.name} + ) : ( Nobody (top-level) @@ -542,33 +534,16 @@ export function AgentDetail() {

{agent.capabilities}

)} -
- Permissions -
- Can create new agents - -
-
- resetTaskSession.mutate(taskKey)} - onResetAll={() => resetTaskSession.mutate(null)} - resetting={resetTaskSession.isPending} - /> + + + + {/* RUNS TAB */} + + {/* CONFIGURATION TAB */} @@ -579,14 +554,10 @@ export function AgentDetail() { onSaveActionChange={setSaveConfigAction} onCancelActionChange={setCancelConfigAction} onSavingChange={setConfigSaving} + updatePermissions={updatePermissions} /> - {/* RUNS TAB */} - - - - {/* ISSUES TAB */} {assignedIssues.length === 0 ? ( @@ -631,60 +602,72 @@ function SummaryRow({ label, children }: { label: string; children: React.ReactN ); } -function TaskSessionsCard({ - sessions, - onResetTask, - onResetAll, - resetting, -}: { - sessions: AgentTaskSession[]; - onResetTask: (taskKey: string) => void; - onResetAll: () => void; - resetting: boolean; -}) { +function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: string }) { + const navigate = useNavigate(); + + if (runs.length === 0) return null; + + const sorted = [...runs].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + const liveRun = sorted.find((r) => r.status === "running" || r.status === "queued"); + const run = liveRun ?? sorted[0]; + const isLive = run.status === "running" || run.status === "queued"; + const metrics = runMetrics(run); + const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; + const StatusIcon = statusInfo.icon; + const summary = run.resultJson + ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") + : run.error ?? ""; + return ( -
+
-

Task Sessions

-
+ + View details → +
- {sessions.length === 0 ? ( -

No task-scoped sessions.

- ) : ( -
- {sessions.slice(0, 20).map((session) => ( -
-
-
{session.taskKey}
-
- {session.sessionDisplayId - ? `session: ${session.sessionDisplayId}` - : "session: "} - {session.lastError ? ` | error: ${session.lastError}` : ""} -
-
- -
- ))} + +
+ + + {run.id.slice(0, 8)} + + {sourceLabels[run.invocationSource] ?? run.invocationSource} + + {relativeTime(run.createdAt)} +
+ + {summary && ( +

{summary}

+ )} + + {(metrics.totalTokens > 0 || metrics.cost > 0) && ( +
+ {metrics.totalTokens > 0 && {formatTokens(metrics.totalTokens)} tokens} + {metrics.cost > 0 && ${metrics.cost.toFixed(3)}}
)}
@@ -699,12 +682,14 @@ function ConfigurationTab({ onSaveActionChange, onCancelActionChange, onSavingChange, + updatePermissions, }: { agent: Agent; onDirtyChange: (dirty: boolean) => void; onSaveActionChange: (save: (() => void) | null) => void; onCancelActionChange: (cancel: (() => void) | null) => void; onSavingChange: (saving: boolean) => void; + updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; }) { const queryClient = useQueryClient(); @@ -753,6 +738,23 @@ function ConfigurationTab({ hideInlineSave />
+
+

Permissions

+
+ Can create new agents + +
+

Configuration Revisions

@@ -837,7 +839,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { run "flex flex-col gap-1 w-full px-3 py-2.5 text-left border-b border-border last:border-b-0 transition-colors", isSelected ? "bg-accent/40" : "hover:bg-accent/20", )} - onClick={() => navigate(isSelected ? `/agents/${agentId}?tab=runs` : `/agents/${agentId}/runs/${run.id}`)} + onClick={() => navigate(isSelected ? `/agents/${agentId}/runs` : `/agents/${agentId}/runs/${run.id}`)} >
diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 05cfdf47..5f4bb416 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; @@ -10,7 +10,8 @@ import { StatusBadge } from "../components/StatusBadge"; import { EntityRow } from "../components/EntityRow"; import { EmptyState } from "../components/EmptyState"; import { formatCents, relativeTime, cn } from "../lib/utils"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PageTabBar } from "../components/PageTabBar"; +import { Tabs } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react"; import type { Agent } from "@paperclip/shared"; @@ -58,7 +59,9 @@ export function Agents() { const { openNewAgent } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); - const [tab, setTab] = useState("all"); + const location = useLocation(); + const pathSegment = location.pathname.split("/").pop() ?? "all"; + const tab: FilterTab = (pathSegment === "all" || pathSegment === "active" || pathSegment === "paused" || pathSegment === "error") ? pathSegment : "all"; const [view, setView] = useState<"list" | "org">("org"); const [showTerminated, setShowTerminated] = useState(false); const [filtersOpen, setFiltersOpen] = useState(false); @@ -95,13 +98,13 @@ export function Agents() { return (
- setTab(v as FilterTab)}> - - All - Active - Paused - Error - + navigate(`/agents/${v}`)}> +
{/* Filters */} @@ -214,14 +217,12 @@ export function Agents() { } trailing={
- + {adapterLabels[agent.adapterType] ?? agent.adapterType} - {agent.lastHeartbeatAt && ( - - {relativeTime(agent.lastHeartbeatAt)} - - )} + + {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} +
- + {formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
- + + +
} /> @@ -328,14 +331,12 @@ function OrgTreeNode({
{agent && ( <> - + {adapterLabels[agent.adapterType] ?? agent.adapterType} - {agent.lastHeartbeatAt && ( - - {relativeTime(agent.lastHeartbeatAt)} - - )} + + {agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"} +
- + {formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
)} - + + +
{node.reports && node.reports.length > 0 && ( diff --git a/ui/src/pages/Approvals.tsx b/ui/src/pages/Approvals.tsx index 7c326c55..bb8e856f 100644 --- a/ui/src/pages/Approvals.tsx +++ b/ui/src/pages/Approvals.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; import { agentsApi } from "../api/agents"; @@ -7,7 +7,8 @@ import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PageTabBar } from "../components/PageTabBar"; +import { Tabs } from "@/components/ui/tabs"; import { ShieldCheck } from "lucide-react"; import { ApprovalCard } from "../components/ApprovalCard"; @@ -18,7 +19,9 @@ export function Approvals() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const [statusFilter, setStatusFilter] = useState("pending"); + const location = useLocation(); + const pathSegment = location.pathname.split("/").pop() ?? "pending"; + const statusFilter: StatusFilter = pathSegment === "all" ? "all" : "pending"; const [actionError, setActionError] = useState(null); useEffect(() => { @@ -77,21 +80,18 @@ export function Approvals() { return (
- setStatusFilter(v as StatusFilter)}> - - - Pending - {pendingCount > 0 && ( - - {pendingCount} - - )} - - All - + navigate(`/approvals/${v}`)}> + Pending{pendingCount > 0 && ( + + {pendingCount} + + )} }, + { value: "all", label: "All" }, + ]} />
diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 1b4f825a..e2c44a00 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -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 = { - "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 | null): string { - if (action === "issue.updated" && details) { - const previous = (details._previous ?? {}) as Record; - 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() {
navigate("/agents")} description={ + navigate("/agents")}>{data.agents.running} running + {", "} navigate("/agents")}>{data.agents.paused} paused {", "} navigate("/agents")}>{data.agents.error} errors @@ -303,58 +235,36 @@ export function Dashboard() { Recent Activity
- {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 ( -
navigate(link) : undefined} - > -
- - {verb} - {name && {name}} -
- - {timeAgo(event.createdAt)} - -
- ); - })} + {recentActivity.map((event) => ( + + ))}
)} - {/* Stale Tasks */} + {/* Recent Tasks */}

- Stale Tasks + Recent Tasks

- {staleIssues.length === 0 ? ( + {recentIssues.length === 0 ? (
-

No stale tasks. All work is up to date.

+

No tasks yet.

) : (
- {staleIssues.slice(0, 10).map((issue) => ( + {recentIssues.slice(0, 10).map((issue) => (
navigate(`/issues/${issue.id}`)} > - {issue.title} diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 58693248..eaa12008 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -36,8 +36,8 @@ const issueTabItems = [ ] as const; function parseIssueTab(value: string | null): TabFilter { - if (value === "active" || value === "backlog" || value === "done") return value; - return "all"; + if (value === "all" || value === "active" || value === "backlog" || value === "done") return value; + return "active"; } function filterIssues(issues: Issue[], tab: TabFilter): Issue[] { @@ -59,8 +59,9 @@ export function Issues() { const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const [searchParams, setSearchParams] = useSearchParams(); - const tab = parseIssueTab(searchParams.get("tab")); + const location = useLocation(); + const pathSegment = location.pathname.split("/").pop() ?? "active"; + const tab = parseIssueTab(pathSegment); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -102,10 +103,7 @@ export function Issues() { .map((s) => ({ status: s, items: grouped[s]! })); const setTab = (nextTab: TabFilter) => { - const next = new URLSearchParams(searchParams); - if (nextTab === "all") next.delete("tab"); - else next.set("tab", nextTab); - setSearchParams(next); + navigate(`/issues/${nextTab}`); }; return (