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 (