feat(ui): active agents panel, sidebar context, and page enhancements

Add live ActiveAgentsPanel with real-time transcript feed, SidebarContext
for responsive sidebar state, agent config form with reasoning effort,
improved inbox with failed run alerts, enriched issue detail with project
picker, and various component refinements across pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 10:32:32 -06:00
parent b327687c92
commit adca44849a
29 changed files with 1461 additions and 146 deletions

View File

@@ -297,22 +297,22 @@ export function AgentDetail() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{agent.name}</h2>
<p className="text-sm text-muted-foreground">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h2 className="text-xl font-bold truncate">{agent.name}</h2>
<p className="text-sm text-muted-foreground truncate">
{roleLabels[agent.role] ?? agent.role}
{agent.title ? ` - ${agent.title}` : ""}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => openNewIssue({ assigneeAgentId: agentId })}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Assign Task
<Plus className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Assign Task</span>
</Button>
<Button
variant="outline"
@@ -320,8 +320,8 @@ export function AgentDetail() {
onClick={() => agentAction.mutate("invoke")}
disabled={agentAction.isPending || isPendingApproval}
>
<Play className="h-3.5 w-3.5 mr-1" />
Invoke
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Invoke</span>
</Button>
{agent.status === "paused" ? (
<Button
@@ -330,8 +330,8 @@ export function AgentDetail() {
onClick={() => agentAction.mutate("resume")}
disabled={agentAction.isPending || isPendingApproval}
>
<Play className="h-3.5 w-3.5 mr-1" />
Resume
<Play className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Resume</span>
</Button>
) : (
<Button
@@ -340,11 +340,11 @@ export function AgentDetail() {
onClick={() => agentAction.mutate("pause")}
disabled={agentAction.isPending || isPendingApproval}
>
<Pause className="h-3.5 w-3.5 mr-1" />
Pause
<Pause className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Pause</span>
</Button>
)}
<StatusBadge status={agent.status} />
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
{/* Overflow menu */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
@@ -434,6 +434,8 @@ export function AgentDetail() {
{ value: "costs", label: "Costs" },
{ value: "keys", label: "API Keys" },
]}
value={activeTab}
onValueChange={setActiveTab}
/>
{/* OVERVIEW TAB */}
@@ -732,7 +734,7 @@ function ConfigurationTab({
}, [onSavingChange, updateAgent.isPending]);
return (
<div className="max-w-2xl space-y-4">
<div className="space-y-4">
<div className="border border-border rounded-lg overflow-hidden">
<AgentConfigForm
mode="edit"
@@ -913,6 +915,23 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
queryKey: queryKeys.runIssues(run.id),
queryFn: () => activityApi.issuesForRun(run.id),
});
const touchedIssueIds = useMemo(
() => Array.from(new Set((touchedIssues ?? []).map((issue) => issue.issueId))),
[touchedIssues],
);
const clearSessionsForTouchedIssues = useMutation({
mutationFn: async () => {
if (touchedIssueIds.length === 0) return 0;
await Promise.all(touchedIssueIds.map((issueId) => agentsApi.resetSession(run.agentId, issueId)));
return touchedIssueIds.length;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(run.agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(run.agentId) });
queryClient.invalidateQueries({ queryKey: queryKeys.runIssues(run.id) });
},
});
const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null;
@@ -1027,6 +1046,34 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<CopyText text={run.sessionIdAfter} className="font-mono" />
</div>
)}
{touchedIssueIds.length > 0 && (
<div className="pt-1">
<button
type="button"
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:opacity-60"
disabled={clearSessionsForTouchedIssues.isPending}
onClick={() => {
const issueCount = touchedIssueIds.length;
const confirmed = window.confirm(
`Clear session for ${issueCount} issue${issueCount === 1 ? "" : "s"} touched by this run?`,
);
if (!confirmed) return;
clearSessionsForTouchedIssues.mutate();
}}
>
{clearSessionsForTouchedIssues.isPending
? "clearing session..."
: "clear session for these issues"}
</button>
{clearSessionsForTouchedIssues.isError && (
<p className="text-[11px] text-destructive mt-1">
{clearSessionsForTouchedIssues.error instanceof Error
? clearSessionsForTouchedIssues.error.message
: "Failed to clear sessions"}
</p>
)}
</div>
)}
</div>
)}
</div>
@@ -1086,6 +1133,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const [logLoading, setLogLoading] = useState(!!run.logRef);
const [logError, setLogError] = useState<string | null>(null);
const [logOffset, setLogOffset] = useState(0);
const [isFollowing, setIsFollowing] = useState(true);
const logEndRef = useRef<HTMLDivElement>(null);
const pendingLogLineRef = useRef("");
const isLive = run.status === "running" || run.status === "queued";
@@ -1135,12 +1183,36 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
}
}, [initialEvents]);
// Auto-scroll only for live runs
const updateFollowingState = useCallback(() => {
const el = logEndRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const inView = rect.top <= window.innerHeight && rect.bottom >= 0;
setIsFollowing((prev) => (prev === inView ? prev : inView));
}, []);
useEffect(() => {
if (isLive) {
if (!isLive) return;
setIsFollowing(true);
}, [isLive, run.id]);
useEffect(() => {
if (!isLive) return;
updateFollowingState();
window.addEventListener("scroll", updateFollowingState, { passive: true });
window.addEventListener("resize", updateFollowingState);
return () => {
window.removeEventListener("scroll", updateFollowingState);
window.removeEventListener("resize", updateFollowingState);
};
}, [isLive, updateFollowingState]);
// Auto-scroll only for live runs when following
useEffect(() => {
if (isLive && isFollowing) {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [events, logLines, isLive]);
}, [events, logLines, isLive, isFollowing]);
// Fetch persisted shell log
useEffect(() => {
@@ -1315,15 +1387,29 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<span className="text-xs font-medium text-muted-foreground">
Transcript ({transcript.length})
</span>
{isLive && (
<span className="flex items-center gap-1 text-xs text-cyan-400">
<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" />
<div className="flex items-center gap-2">
{isLive && !isFollowing && (
<Button
variant="ghost"
size="xs"
onClick={() => {
setIsFollowing(true);
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}}
>
Jump to live
</Button>
)}
{isLive && (
<span className="flex items-center gap-1 text-xs text-cyan-400">
<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>
Live
</span>
Live
</span>
)}
)}
</div>
</div>
<div className="bg-neutral-950 rounded-lg p-3 font-mono text-xs space-y-0.5">
{transcript.length === 0 && !run.logRef && (
@@ -1536,7 +1622,7 @@ function CostsTab({
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6">
{/* Cumulative totals */}
{runtimeState && (
<div className="border border-border rounded-lg p-4">

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -78,6 +79,24 @@ export function Agents() {
enabled: !!selectedCompanyId && view === "org",
});
const { data: runs } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
refetchInterval: 15_000,
});
// Map agentId -> first live run (running or queued)
const liveRunByAgent = useMemo(() => {
const map = new Map<string, { runId: string }>();
for (const r of runs ?? []) {
if ((r.status === "running" || r.status === "queued") && !map.has(r.agentId)) {
map.set(r.agentId, { runId: r.id });
}
}
return map;
}, [runs]);
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
@@ -97,14 +116,18 @@ export function Agents() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<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" },
]} />
<PageTabBar
items={[
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "paused", label: "Paused" },
{ value: "error", label: "Error" },
]}
value={tab}
onValueChange={(v) => navigate(`/agents/${v}`)}
/>
</Tabs>
<div className="flex items-center gap-2">
{/* Filters */}
@@ -217,6 +240,13 @@ export function Agents() {
}
trailing={
<div className="flex items-center gap-3">
{liveRunByAgent.has(agent.id) && (
<LiveRunIndicator
agentId={agent.id}
runId={liveRunByAgent.get(agent.id)!.runId}
navigate={navigate}
/>
)}
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
</span>
@@ -261,7 +291,7 @@ export function Agents() {
{view === "org" && filteredOrg.length > 0 && (
<div className="border border-border py-1">
{filteredOrg.map((node) => (
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} />
<OrgTreeNode key={node.id} node={node} depth={0} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
))}
</div>
)}
@@ -286,11 +316,13 @@ function OrgTreeNode({
depth,
navigate,
agentMap,
liveRunByAgent,
}: {
node: OrgNode;
depth: number;
navigate: (path: string) => void;
agentMap: Map<string, Agent>;
liveRunByAgent: Map<string, { runId: string }>;
}) {
const agent = agentMap.get(node.id);
@@ -329,6 +361,13 @@ function OrgTreeNode({
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
{liveRunByAgent.has(node.id) && (
<LiveRunIndicator
agentId={node.id}
runId={liveRunByAgent.get(node.id)!.runId}
navigate={navigate}
/>
)}
{agent && (
<>
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
@@ -364,10 +403,36 @@ function OrgTreeNode({
{node.reports && node.reports.length > 0 && (
<div className="border-l border-border/50 ml-4">
{node.reports.map((child) => (
<OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} agentMap={agentMap} />
<OrgTreeNode key={child.id} node={child} depth={depth + 1} navigate={navigate} agentMap={agentMap} liveRunByAgent={liveRunByAgent} />
))}
</div>
)}
</div>
);
}
function LiveRunIndicator({
agentId,
runId,
navigate,
}: {
agentId: string;
runId: string;
navigate: (path: string) => void;
}) {
return (
<button
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors"
onClick={(e) => {
e.stopPropagation();
navigate(`/agents/${agentId}/runs/${runId}`);
}}
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-400">Live</span>
</button>
);
}

View File

@@ -19,6 +19,7 @@ import { Identity } from "../components/Identity";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import type { Agent, Issue } from "@paperclip/shared";
function getRecentIssues(issues: Issue[]): Issue[] {
@@ -271,8 +272,8 @@ export function Dashboard() {
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" className="shrink-0" />
: <span className="text-xs text-muted-foreground font-mono shrink-0">{issue.assigneeAgentId.slice(0, 8)}</span>;
? <Identity name={name} size="sm" className="shrink-0 hidden sm:flex" />
: <span className="text-xs text-muted-foreground font-mono shrink-0 hidden sm:inline">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()}
<span className="text-xs text-muted-foreground shrink-0">
{timeAgo(issue.updatedAt)}
@@ -283,6 +284,8 @@ export function Dashboard() {
)}
</div>
</div>
<ActiveAgentsPanel companyId={selectedCompanyId!} />
</>
)}
</div>

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
@@ -12,6 +13,7 @@ import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState";
import { ApprovalCard } from "../components/ApprovalCard";
import { StatusBadge } from "../components/StatusBadge";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -20,11 +22,21 @@ import {
AlertTriangle,
Clock,
ExternalLink,
ArrowUpRight,
XCircle,
} from "lucide-react";
import { Identity } from "../components/Identity";
import type { Issue } from "@paperclip/shared";
import type { HeartbeatRun, Issue } from "@paperclip/shared";
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
const RUN_SOURCE_LABELS: Record<string, string> = {
timer: "Scheduled",
assignment: "Assignment",
on_demand: "Manual",
automation: "Automation",
};
function getStaleIssues(issues: Issue[]): Issue[] {
const now = Date.now();
@@ -40,6 +52,50 @@ function getStaleIssues(issues: Issue[]): Issue[] {
);
}
function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const latestByAgent = new Map<string, HeartbeatRun>();
for (const run of sorted) {
if (!latestByAgent.has(run.agentId)) {
latestByAgent.set(run.agentId, run);
}
}
return Array.from(latestByAgent.values()).filter((run) =>
FAILED_RUN_STATUSES.has(run.status),
);
}
function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
return line ?? null;
}
function runFailureMessage(run: HeartbeatRun): string {
return (
firstNonEmptyLine(run.error) ??
firstNonEmptyLine(run.stderrExcerpt) ??
"Run exited with an error."
);
}
function readIssueIdFromRun(run: HeartbeatRun): string | null {
const context = run.contextSnapshot;
if (!context) return null;
const issueId = context["issueId"];
if (typeof issueId === "string" && issueId.length > 0) return issueId;
const taskId = context["taskId"];
if (typeof taskId === "string" && taskId.length > 0) return taskId;
return null;
}
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -57,7 +113,7 @@ export function Inbox() {
setBreadcrumbs([{ label: "Inbox" }]);
}, [setBreadcrumbs]);
const { data: approvals, isLoading, error } = useQuery({
const { data: approvals, isLoading: isApprovalsLoading, error } = useQuery({
queryKey: queryKeys.approvals.list(selectedCompanyId!),
queryFn: () => approvalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
@@ -75,12 +131,34 @@ export function Inbox() {
enabled: !!selectedCompanyId,
});
const { data: heartbeatRuns } = useQuery({
queryKey: queryKeys.heartbeats(selectedCompanyId!),
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const staleIssues = issues ? getStaleIssues(issues) : [];
const agentById = useMemo(() => {
const map = new Map<string, string>();
for (const agent of agents ?? []) map.set(agent.id, agent.name);
return map;
}, [agents]);
const issueById = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues ?? []) map.set(issue.id, issue);
return map;
}, [issues]);
const failedRuns = useMemo(
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []),
[heartbeatRuns],
);
const agentName = (id: string | null) => {
if (!id || !agents) return null;
const agent = agents.find((a) => a.id === id);
return agent?.name ?? null;
if (!id) return null;
return agentById.get(id) ?? null;
};
const approveMutation = useMutation({
@@ -112,35 +190,37 @@ export function Inbox() {
(approval) => approval.status === "pending" || approval.status === "revision_requested",
);
const hasActionableApprovals = actionableApprovals.length > 0;
const hasRunFailures = failedRuns.length > 0;
const showAggregateAgentError =
!!dashboard && dashboard.agents.error > 0 && !hasRunFailures;
const hasAlerts =
dashboard &&
(dashboard.agents.error > 0 ||
dashboard.costs.monthUtilizationPercent >= 80);
!!dashboard &&
(showAggregateAgentError || dashboard.costs.monthUtilizationPercent >= 80);
const hasStale = staleIssues.length > 0;
const hasContent = hasActionableApprovals || hasAlerts || hasStale;
const hasContent = hasActionableApprovals || hasRunFailures || hasAlerts || hasStale;
return (
<div className="space-y-6">
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{isApprovalsLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{!isLoading && !hasContent && (
{!isApprovalsLoading && !hasContent && (
<EmptyState icon={InboxIcon} message="You're all caught up!" />
)}
{/* Pending Approvals */}
{hasActionableApprovals && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Approvals
</h3>
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => navigate("/approvals")}
>
See all approvals <ExternalLink className="inline h-3 w-3 ml-0.5" />
See all approvals <ExternalLink className="ml-0.5 inline h-3 w-3" />
</button>
</div>
<div className="grid gap-3">
@@ -159,21 +239,100 @@ export function Inbox() {
</div>
)}
{/* Alerts */}
{hasAlerts && (
{/* Failed Runs */}
{hasRunFailures && (
<>
{hasActionableApprovals && <Separator />}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Failed Runs
</h3>
<div className="grid gap-3">
{failedRuns.map((run) => {
const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null;
const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual";
const displayError = runFailureMessage(run);
const linkedAgentName = agentName(run.agentId);
return (
<div
key={run.id}
className="group relative overflow-hidden rounded-xl border border-red-500/30 bg-gradient-to-br from-red-500/10 via-card to-card p-4"
>
<div className="absolute right-0 top-0 h-24 w-24 rounded-full bg-red-500/10 blur-2xl" />
<div className="relative space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded-md bg-red-500/20 p-1.5">
<XCircle className="h-4 w-4 text-red-400" />
</span>
{linkedAgentName
? <Identity name={linkedAgentName} size="sm" />
: <span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>}
<StatusBadge status={run.status} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{sourceLabel} run failed {timeAgo(run.createdAt)}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2.5"
onClick={() => navigate(`/agents/${run.agentId}/runs/${run.id}`)}
>
Open run
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
</Button>
</div>
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm">
{displayError}
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
{issue ? (
<button
type="button"
className="truncate text-muted-foreground transition-colors hover:text-foreground"
onClick={() => navigate(`/issues/${issue.id}`)}
>
{issue.identifier ?? issue.id.slice(0, 8)} · {issue.title}
</button>
) : (
<span className="text-muted-foreground">
{run.errorCode ? `code: ${run.errorCode}` : "No linked issue"}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</>
)}
{/* Alerts */}
{hasAlerts && (
<>
{(hasActionableApprovals || hasRunFailures) && <Separator />}
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Alerts
</h3>
<div className="border border-border divide-y divide-border">
{dashboard!.agents.error > 0 && (
<div className="divide-y divide-border border border-border">
{showAggregateAgentError && (
<div
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => navigate("/agents")}
>
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" />
<AlertTriangle className="h-4 w-4 shrink-0 text-red-400" />
<span className="text-sm">
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
@@ -182,10 +341,10 @@ export function Inbox() {
)}
{dashboard!.costs.monthUtilizationPercent >= 80 && (
<div
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => navigate("/costs")}
>
<AlertTriangle className="h-4 w-4 text-yellow-400 shrink-0" />
<AlertTriangle className="h-4 w-4 shrink-0 text-yellow-400" />
<span className="text-sm">
Budget at{" "}
<span className="font-medium">
@@ -203,32 +362,32 @@ export function Inbox() {
{/* Stale Work */}
{hasStale && (
<>
{(hasActionableApprovals || hasAlerts) && <Separator />}
{(hasActionableApprovals || hasRunFailures || hasAlerts) && <Separator />}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Stale Work
</h3>
<div className="border border-border divide-y divide-border">
<div className="divide-y divide-border border border-border">
{staleIssues.map((issue) => (
<div
key={issue.id}
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
onClick={() => navigate(`/issues/${issue.id}`)}
>
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="text-sm truncate flex-1">{issue.title}</span>
<span className="flex-1 truncate text-sm">{issue.title}</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <Identity name={name} size="sm" />
: <span className="text-xs text-muted-foreground font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>;
: <span className="font-mono text-xs text-muted-foreground">{issue.assigneeAgentId.slice(0, 8)}</span>;
})()}
<span className="text-xs text-muted-foreground shrink-0">
<span className="shrink-0 text-xs text-muted-foreground">
updated {timeAgo(issue.updatedAt)}
</span>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
@@ -9,7 +9,7 @@ import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { relativeTime, cn } from "../lib/utils";
import { relativeTime, cn, formatTokens } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor";
import { CommentThread } from "../components/CommentThread";
import { IssueProperties } from "../components/IssueProperties";
@@ -21,9 +21,9 @@ import { Identity } from "../components/Identity";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { ChevronRight, MoreHorizontal, EyeOff, Hexagon } from "lucide-react";
import { ChevronRight, MoreHorizontal, EyeOff, Hexagon, Paperclip, Trash2 } from "lucide-react";
import type { ActivityEvent } from "@paperclip/shared";
import type { Agent } from "@paperclip/shared";
import type { Agent, IssueAttachment } from "@paperclip/shared";
const ACTION_LABELS: Record<string, string> = {
"issue.created": "created the issue",
@@ -49,6 +49,20 @@ function humanizeValue(value: unknown): string {
return value.replace(/_/g, " ");
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
if (!usage) return 0;
for (const key of keys) {
const value = usage[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return 0;
}
function formatAction(action: string, details?: Record<string, unknown> | null): string {
if (action === "issue.updated" && details) {
const previous = (details._previous ?? {}) as Record<string, unknown>;
@@ -101,6 +115,8 @@ export function IssueDetail() {
const [moreOpen, setMoreOpen] = useState(false);
const [projectOpen, setProjectOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState("");
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!),
@@ -133,6 +149,12 @@ export function IssueDetail() {
enabled: !!issueId,
});
const { data: attachments } = useQuery({
queryKey: queryKeys.issues.attachments(issueId!),
queryFn: () => issuesApi.listAttachments(issueId!),
enabled: !!issueId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
@@ -173,11 +195,53 @@ export function IssueDetail() {
});
}, [activity, comments, linkedRuns]);
const issueCostSummary = useMemo(() => {
let input = 0;
let output = 0;
let cached = 0;
let cost = 0;
let hasCost = false;
let hasTokens = false;
for (const run of linkedRuns ?? []) {
const usage = asRecord(run.usageJson);
const result = asRecord(run.resultJson);
const runInput = usageNumber(usage, "inputTokens", "input_tokens");
const runOutput = usageNumber(usage, "outputTokens", "output_tokens");
const runCached = usageNumber(
usage,
"cachedInputTokens",
"cached_input_tokens",
"cache_read_input_tokens",
);
const runCost =
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
if (runCost > 0) hasCost = true;
if (runInput + runOutput + runCached > 0) hasTokens = true;
input += runInput;
output += runOutput;
cached += runCached;
cost += runCost;
}
return {
input,
output,
cached,
cost,
totalTokens: input + output,
hasCost,
hasTokens,
};
}, [linkedRuns]);
const invalidateIssue = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
if (selectedCompanyId) {
@@ -199,6 +263,33 @@ export function IssueDetail() {
},
});
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
return issuesApi.uploadAttachment(selectedCompanyId, issueId!, file);
},
onSuccess: () => {
setAttachmentError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
invalidateIssue();
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Upload failed");
},
});
const deleteAttachment = useMutation({
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
onSuccess: () => {
setAttachmentError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
invalidateIssue();
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Delete failed");
},
});
useEffect(() => {
setBreadcrumbs([
{ label: "Issues", href: "/issues" },
@@ -222,6 +313,17 @@ export function IssueDetail() {
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
const ancestors = issue.ancestors ?? [];
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const file = evt.target.files?.[0];
if (!file) return;
await uploadAttachment.mutateAsync(file);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
return (
<div className="max-w-2xl space-y-6">
{/* Parent chain breadcrumb */}
@@ -357,6 +459,80 @@ export function IssueDetail() {
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleFilePicked}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAttachment.isPending}
>
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
{uploadAttachment.isPending ? "Uploading..." : "Upload image"}
</Button>
</div>
</div>
{attachmentError && (
<p className="text-xs text-destructive">{attachmentError}</p>
)}
{(!attachments || attachments.length === 0) ? (
<p className="text-xs text-muted-foreground">No attachments yet.</p>
) : (
<div className="space-y-2">
{attachments.map((attachment) => (
<div key={attachment.id} className="border border-border rounded-md p-2">
<div className="flex items-center justify-between gap-2">
<a
href={attachment.contentPath}
target="_blank"
rel="noreferrer"
className="text-xs hover:underline truncate"
title={attachment.originalFilename ?? attachment.id}
>
{attachment.originalFilename ?? attachment.id}
</a>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteAttachment.mutate(attachment.id)}
disabled={deleteAttachment.isPending}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-muted-foreground">
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
</p>
{isImageAttachment(attachment) && (
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
<img
src={attachment.contentPath}
alt={attachment.originalFilename ?? "attachment"}
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
loading="lazy"
/>
</a>
)}
</div>
))}
</div>
)}
</div>
<Separator />
<CommentThread
comments={commentsWithRunMeta}
issueStatus={issue.status}
@@ -437,6 +613,34 @@ export function IssueDetail() {
</div>
</>
)}
{(linkedRuns && linkedRuns.length > 0) && (
<>
<Separator />
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Cost</h3>
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}
</span>
)}
{issueCostSummary.hasTokens && (
<span>
Tokens {formatTokens(issueCostSummary.totalTokens)}
{issueCostSummary.cached > 0
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
)}
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -44,7 +44,7 @@ function OrgTreeNode({
<div>
<div
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer hover:bg-accent/50"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
onClick={() => onSelect(node.id)}
>
{hasChildren ? (

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { usePanel } from "../context/PanelContext";
@@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ProjectProperties } from "../components/ProjectProperties";
import { InlineEditor } from "../components/InlineEditor";
import { StatusBadge } from "../components/StatusBadge";
import { EntityRow } from "../components/EntityRow";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -18,6 +19,8 @@ export function ProjectDetail() {
const { selectedCompanyId } = useCompany();
const { openPanel, closePanel } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: project, isLoading, error } = useQuery({
queryKey: queryKeys.projects.detail(projectId!),
@@ -33,6 +36,18 @@ export function ProjectDetail() {
const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId);
const invalidateProject = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId!) });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId) });
}
};
const updateProject = useMutation({
mutationFn: (data: Record<string, unknown>) => projectsApi.update(projectId!, data),
onSuccess: invalidateProject,
});
useEffect(() => {
setBreadcrumbs([
{ label: "Projects", href: "/projects" },
@@ -42,7 +57,7 @@ export function ProjectDetail() {
useEffect(() => {
if (project) {
openPanel(<ProjectProperties project={project} />);
openPanel(<ProjectProperties project={project} onUpdate={(data) => updateProject.mutate(data)} />);
}
return () => closePanel();
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -53,11 +68,22 @@ export function ProjectDetail() {
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold">{project.name}</h2>
{project.description && (
<p className="text-sm text-muted-foreground mt-1">{project.description}</p>
)}
<div className="space-y-3">
<InlineEditor
value={project.name}
onSave={(name) => updateProject.mutate({ name })}
as="h2"
className="text-xl font-bold"
/>
<InlineEditor
value={project.description ?? ""}
onSave={(description) => updateProject.mutate({ description })}
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
/>
</div>
<Tabs defaultValue="overview">
@@ -67,7 +93,7 @@ export function ProjectDetail() {
</TabsList>
<TabsContent value="overview" className="mt-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Status</span>
<div className="mt-1">
@@ -94,6 +120,7 @@ export function ProjectDetail() {
identifier={issue.identifier ?? issue.id.slice(0, 8)}
title={issue.title}
trailing={<StatusBadge status={issue.status} />}
onClick={() => navigate(`/issues/${issue.id}`)}
/>
))}
</div>