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:
@@ -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<string, number> = {
|
||||
|
||||
@@ -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() {
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="org" element={<Navigate to="/agents" replace />} />
|
||||
<Route path="agents" element={<Agents />} />
|
||||
<Route path="org" element={<Navigate to="/agents/all" replace />} />
|
||||
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
||||
<Route path="agents/all" element={<Agents />} />
|
||||
<Route path="agents/active" element={<Agents />} />
|
||||
<Route path="agents/paused" element={<Agents />} />
|
||||
<Route path="agents/error" element={<Agents />} />
|
||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
||||
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
||||
<Route path="issues" element={<Issues />} />
|
||||
<Route path="issues" element={<Navigate to="/issues/active" replace />} />
|
||||
<Route path="issues/all" element={<Issues />} />
|
||||
<Route path="issues/active" element={<Issues />} />
|
||||
<Route path="issues/backlog" element={<Issues />} />
|
||||
<Route path="issues/done" element={<Issues />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
<Route path="approvals" element={<Approvals />} />
|
||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
||||
<Route path="approvals/pending" element={<Approvals />} />
|
||||
<Route path="approvals/all" element={<Approvals />} />
|
||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||
<Route path="costs" element={<Costs />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
<Route path="inbox" element={<Inbox />} />
|
||||
<Route path="my-issues" element={<MyIssues />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
127
ui/src/components/ActivityRow.tsx
Normal file
127
ui/src/components/ActivityRow.tsx
Normal file
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
History,
|
||||
Search,
|
||||
SquarePen,
|
||||
ListTodo,
|
||||
ShieldCheck,
|
||||
BookOpen,
|
||||
Paperclip,
|
||||
@@ -79,7 +78,6 @@ export function Sidebar() {
|
||||
icon={Inbox}
|
||||
badge={sidebarBadges?.inbox}
|
||||
/>
|
||||
<SidebarNavItem to="/my-issues" label="My Issues" icon={ListTodo} />
|
||||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
|
||||
@@ -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<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.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<string, Agent>) {
|
||||
if (actorType === "agent") {
|
||||
const agent = agentMap.get(actorId);
|
||||
return <Identity name={agent?.name ?? actorId.slice(0, 8)} size="sm" />;
|
||||
}
|
||||
if (actorType === "system") return <Identity name="System" size="sm" />;
|
||||
return <Identity name={actorId || "You"} size="sm" />;
|
||||
}
|
||||
|
||||
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<string, string>();
|
||||
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title);
|
||||
@@ -182,31 +114,14 @@ export function Activity() {
|
||||
|
||||
{filtered && filtered.length > 0 && (
|
||||
<div className="border border-border divide-y divide-border">
|
||||
{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 (
|
||||
<div
|
||||
key={event.id}
|
||||
className={`px-4 py-2.5 flex items-center justify-between gap-4 ${
|
||||
link ? "cursor-pointer hover:bg-accent/50 transition-colors" : ""
|
||||
}`}
|
||||
onClick={link ? () => navigate(link) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{actorIdentity(event.actorType, event.actorId, agentMap)}
|
||||
<span className="text-sm text-muted-foreground">{verb}</span>
|
||||
{name && (
|
||||
<span className="text-sm truncate">{name}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.map((event) => (
|
||||
<ActivityRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
agentMap={agentMap}
|
||||
entityNameMap={entityNameMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-400" },
|
||||
@@ -152,17 +153,16 @@ function asRecord(value: unknown): Record<string, unknown> | 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<string | null>(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 <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
@@ -435,8 +420,8 @@ export function AgentDetail() {
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "runs", label: "Runs" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "runs", label: `Runs${heartbeats ? ` (${heartbeats.length})` : ""}` },
|
||||
{ value: "issues", label: `Issues (${assignedIssues.length})` },
|
||||
{ value: "costs", label: "Costs" },
|
||||
{ value: "keys", label: "API Keys" },
|
||||
@@ -475,20 +460,27 @@ export function AgentDetail() {
|
||||
: <span className="text-muted-foreground">Never</span>
|
||||
}
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Session">
|
||||
{(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId)
|
||||
? <span className="font-mono text-xs">{String(runtimeState?.sessionDisplayId ?? runtimeState?.sessionId).slice(0, 16)}...</span>
|
||||
: <span className="text-muted-foreground">No session</span>
|
||||
}
|
||||
<SummaryRow label="Budget">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
(() => {
|
||||
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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
|
||||
</span>
|
||||
</div>
|
||||
</SummaryRow>
|
||||
<SummaryRow label="Task sessions">
|
||||
<span>{taskSessions?.length ?? 0}</span>
|
||||
</SummaryRow>
|
||||
{runtimeState && (
|
||||
<SummaryRow label="Total spend">
|
||||
<span>{formatCents(runtimeState.totalCostCents)}</span>
|
||||
</SummaryRow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -502,7 +494,7 @@ export function AgentDetail() {
|
||||
to={`/agents/${reportsToAgent.id}`}
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
{reportsToAgent.name}
|
||||
<Identity name={reportsToAgent.name} size="sm" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Nobody (top-level)</span>
|
||||
@@ -542,33 +534,16 @@ export function AgentDetail() {
|
||||
<p className="text-sm mt-0.5">{agent.capabilities}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 border-t border-border/60">
|
||||
<span className="text-xs text-muted-foreground">Permissions</span>
|
||||
<div className="mt-1 flex items-center justify-between text-sm">
|
||||
<span>Can create new agents</span>
|
||||
<Button
|
||||
variant={agent.permissions?.canCreateAgents ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() =>
|
||||
updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
|
||||
}
|
||||
disabled={updatePermissions.isPending}
|
||||
>
|
||||
{agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskSessionsCard
|
||||
sessions={taskSessions ?? []}
|
||||
onResetTask={(taskKey) => resetTaskSession.mutate(taskKey)}
|
||||
onResetAll={() => resetTaskSession.mutate(null)}
|
||||
resetting={resetTaskSession.isPending}
|
||||
/>
|
||||
<LatestRunCard runs={heartbeats ?? []} agentId={agentId!} />
|
||||
</TabsContent>
|
||||
|
||||
{/* RUNS TAB */}
|
||||
<TabsContent value="runs" className="mt-4">
|
||||
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} agentId={agentId!} selectedRunId={urlRunId ?? null} adapterType={agent.adapterType} />
|
||||
</TabsContent>
|
||||
|
||||
{/* CONFIGURATION TAB */}
|
||||
@@ -579,14 +554,10 @@ export function AgentDetail() {
|
||||
onSaveActionChange={setSaveConfigAction}
|
||||
onCancelActionChange={setCancelConfigAction}
|
||||
onSavingChange={setConfigSaving}
|
||||
updatePermissions={updatePermissions}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* RUNS TAB */}
|
||||
<TabsContent value="runs" className="mt-4">
|
||||
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} agentId={agentId!} selectedRunId={urlRunId ?? null} adapterType={agent.adapterType} />
|
||||
</TabsContent>
|
||||
|
||||
{/* ISSUES TAB */}
|
||||
<TabsContent value="issues" className="mt-4">
|
||||
{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<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||
: run.error ?? "";
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<div className={cn(
|
||||
"border rounded-lg p-4 space-y-3",
|
||||
isLive ? "border-cyan-500/30 shadow-[0_0_12px_rgba(6,182,212,0.08)]" : "border-border"
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Task Sessions</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={onResetAll}
|
||||
disabled={resetting || sessions.length === 0}
|
||||
<div className="flex items-center gap-2">
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-cyan-400" />
|
||||
</span>
|
||||
)}
|
||||
<h3 className="text-sm font-medium">{isLive ? "Live Run" : "Latest Run"}</h3>
|
||||
</div>
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => navigate(`/agents/${agentId}/runs/${run.id}`)}
|
||||
>
|
||||
Reset all
|
||||
</Button>
|
||||
View details →
|
||||
</button>
|
||||
</div>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No task-scoped sessions.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sessions.slice(0, 20).map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between border border-border/70 rounded-md px-3 py-2 gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs truncate">{session.taskKey}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{session.sessionDisplayId
|
||||
? `session: ${session.sessionDisplayId}`
|
||||
: "session: <none>"}
|
||||
{session.lastError ? ` | error: ${session.lastError}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs shrink-0"
|
||||
onClick={() => onResetTask(session.taskKey)}
|
||||
disabled={resetting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={cn("h-3.5 w-3.5", statusInfo.color, run.status === "running" && "animate-spin")} />
|
||||
<StatusBadge status={run.status} />
|
||||
<span className="font-mono text-xs text-muted-foreground">{run.id.slice(0, 8)}</span>
|
||||
<span className={cn(
|
||||
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
|
||||
run.invocationSource === "timer" ? "bg-blue-900/50 text-blue-300"
|
||||
: run.invocationSource === "assignment" ? "bg-violet-900/50 text-violet-300"
|
||||
: run.invocationSource === "on_demand" ? "bg-cyan-900/50 text-cyan-300"
|
||||
: "bg-neutral-800 text-neutral-400"
|
||||
)}>
|
||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{relativeTime(run.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<p className="text-xs text-muted-foreground truncate">{summary}</p>
|
||||
)}
|
||||
|
||||
{(metrics.totalTokens > 0 || metrics.cost > 0) && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{metrics.totalTokens > 0 && <span>{formatTokens(metrics.totalTokens)} tokens</span>}
|
||||
{metrics.cost > 0 && <span>${metrics.cost.toFixed(3)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium">Permissions</h3>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Can create new agents</span>
|
||||
<Button
|
||||
variant={agent.permissions?.canCreateAgents ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() =>
|
||||
updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
|
||||
}
|
||||
disabled={updatePermissions.isPending}
|
||||
>
|
||||
{agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Configuration Revisions</h3>
|
||||
@@ -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}`)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={cn("h-3.5 w-3.5 shrink-0", statusInfo.color, run.status === "running" && "animate-spin")} />
|
||||
|
||||
@@ -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<FilterTab>("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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as FilterTab)}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="paused">Paused</TabsTrigger>
|
||||
<TabsTrigger value="error">Error</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs value={tab} onValueChange={(v) => navigate(`/agents/${v}`)}>
|
||||
<PageTabBar items={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "error", label: "Error" },
|
||||
]} />
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filters */}
|
||||
@@ -214,14 +217,12 @@ export function Agents() {
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
</span>
|
||||
{agent.lastHeartbeatAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{relativeTime(agent.lastHeartbeatAt)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -235,11 +236,13 @@ export function Agents() {
|
||||
style={{ width: `${Math.min(100, budgetPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-20 text-right">
|
||||
<span className="text-xs text-muted-foreground w-24 text-right">
|
||||
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={agent.status} />
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={agent.status} />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -328,14 +331,12 @@ function OrgTreeNode({
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{agent && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
|
||||
{adapterLabels[agent.adapterType] ?? agent.adapterType}
|
||||
</span>
|
||||
{agent.lastHeartbeatAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{relativeTime(agent.lastHeartbeatAt)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -349,13 +350,15 @@ function OrgTreeNode({
|
||||
style={{ width: `${Math.min(100, budgetPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-20 text-right">
|
||||
<span className="text-xs text-muted-foreground w-24 text-right">
|
||||
{formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<StatusBadge status={node.status} />
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={node.status} />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{node.reports && node.reports.length > 0 && (
|
||||
|
||||
@@ -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<StatusFilter>("pending");
|
||||
const location = useLocation();
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "pending";
|
||||
const statusFilter: StatusFilter = pathSegment === "all" ? "all" : "pending";
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,21 +80,18 @@ export function Approvals() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="pending">
|
||||
Pending
|
||||
{pendingCount > 0 && (
|
||||
<span className={cn(
|
||||
"ml-1.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium",
|
||||
"bg-yellow-500/20 text-yellow-500"
|
||||
)}>
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs value={statusFilter} onValueChange={(v) => navigate(`/approvals/${v}`)}>
|
||||
<PageTabBar items={[
|
||||
{ value: "pending", label: <>Pending{pendingCount > 0 && (
|
||||
<span className={cn(
|
||||
"ml-1.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium",
|
||||
"bg-yellow-500/20 text-yellow-500"
|
||||
)}>
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}</> },
|
||||
{ value: "all", label: "All" },
|
||||
]} />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user