Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
Dotta 2026-03-16 17:02:39 -05:00
commit cca086b863
125 changed files with 38085 additions and 683 deletions

View file

@ -3,6 +3,7 @@ import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/r
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
import { companySkillsApi } from "../api/companySkills";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
@ -25,8 +26,9 @@ import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
@ -60,7 +62,16 @@ import {
import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { isUuidLike, type Agent, type AgentRuntimeState, type AgentSkillSnapshot, type HeartbeatRun, type HeartbeatRunEvent, type LiveEvent } from "@paperclipai/shared";
import {
isUuidLike,
type Agent,
type AgentSkillSnapshot,
type BudgetPolicySummary,
type HeartbeatRun,
type HeartbeatRunEvent,
type AgentRuntimeState,
type LiveEvent,
} from "@paperclipai/shared";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
@ -183,11 +194,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
container.scrollTo({ top: container.scrollHeight, behavior });
}
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs";
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget";
function parseAgentDetailView(value: string | null): AgentDetailView {
if (value === "configure" || value === "configuration") return "configuration";
if (value === "skills") return "skills";
if (value === "budget") return "budget";
if (value === "runs") return value;
return "dashboard";
}
@ -213,8 +225,7 @@ function runMetrics(run: HeartbeatRun) {
"cache_read_input_tokens",
);
const cost =
usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") ||
usageNumber(result, "total_cost_usd", "cost_usd", "costUsd");
visibleRunCostUsd(usage, result);
return {
input,
output,
@ -306,11 +317,50 @@ export function AgentDetail() {
enabled: !!resolvedCompanyId && needsDashboardData,
});
const { data: budgetOverview } = useQuery({
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
enabled: !!resolvedCompanyId,
refetchInterval: 30_000,
staleTime: 5_000,
});
const assignedIssues = (allIssues ?? [])
.filter((i) => i.assigneeAgentId === agent?.id)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo);
const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated");
const agentBudgetSummary = useMemo(() => {
const matched = budgetOverview?.policies.find(
(policy) => policy.scopeType === "agent" && policy.scopeId === (agent?.id ?? routeAgentRef),
);
if (matched) return matched;
const budgetMonthlyCents = agent?.budgetMonthlyCents ?? 0;
const spentMonthlyCents = agent?.spentMonthlyCents ?? 0;
return {
policyId: "",
companyId: resolvedCompanyId ?? "",
scopeType: "agent",
scopeId: agent?.id ?? routeAgentRef,
scopeName: agent?.name ?? "Agent",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: budgetMonthlyCents,
observedAmount: spentMonthlyCents,
remainingAmount: Math.max(0, budgetMonthlyCents - spentMonthlyCents),
utilizationPercent:
budgetMonthlyCents > 0 ? Number(((spentMonthlyCents / budgetMonthlyCents) * 100).toFixed(2)) : 0,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: budgetMonthlyCents > 0,
status: budgetMonthlyCents > 0 && spentMonthlyCents >= budgetMonthlyCents ? "hard_stop" : "ok",
paused: agent?.status === "paused",
pauseReason: agent?.pauseReason ?? null,
windowStart: new Date(),
windowEnd: new Date(),
} satisfies BudgetPolicySummary;
}, [agent, budgetOverview?.policies, resolvedCompanyId, routeAgentRef]);
const mobileLiveRun = useMemo(
() => (heartbeats ?? []).find((r) => r.status === "running" || r.status === "queued") ?? null,
[heartbeats],
@ -331,7 +381,9 @@ export function AgentDetail() {
? "skills"
: activeView === "runs"
? "runs"
: "dashboard";
: activeView === "budget"
? "budget"
: "dashboard";
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
return;
@ -374,6 +426,24 @@ export function AgentDetail() {
},
});
const budgetMutation = useMutation({
mutationFn: (amount: number) =>
budgetsApi.upsertPolicy(resolvedCompanyId!, {
scopeType: "agent",
scopeId: agent?.id ?? routeAgentRef,
amount,
windowKind: "calendar_month_utc",
}),
onSuccess: () => {
if (!resolvedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
},
});
const updateIcon = useMutation({
mutationFn: (icon: string) => agentsApi.update(agentLookupRef, { icon }, resolvedCompanyId ?? undefined),
onSuccess: () => {
@ -432,6 +502,8 @@ export function AgentDetail() {
crumbs.push({ label: "Skills" });
} else if (activeView === "runs") {
crumbs.push({ label: "Runs" });
} else if (activeView === "budget") {
crumbs.push({ label: "Budget" });
} else {
crumbs.push({ label: "Dashboard" });
}
@ -589,6 +661,7 @@ export function AgentDetail() {
{ value: "configuration", label: "Configuration" },
{ value: "skills", label: "Skills" },
{ value: "runs", label: "Runs" },
{ value: "budget", label: "Budget" },
]}
value={activeView}
onValueChange={(value) => navigate(`/agents/${canonicalAgentRef}/${value}`)}
@ -701,6 +774,17 @@ export function AgentDetail() {
adapterType={agent.adapterType}
/>
)}
{activeView === "budget" && resolvedCompanyId ? (
<div className="max-w-3xl">
<BudgetPolicyCard
summary={agentBudgetSummary}
isSaving={budgetMutation.isPending}
onSave={(amount) => budgetMutation.mutate(amount)}
variant="plain"
/>
</div>
) : null}
</div>
);
}
@ -873,8 +957,8 @@ function CostsSection({
}) {
const runsWithCost = runs
.filter((r) => {
const u = r.usageJson as Record<string, unknown> | null;
return u && (u.cost_usd || u.total_cost_usd || u.input_tokens);
const metrics = runMetrics(r);
return metrics.cost > 0 || metrics.input > 0 || metrics.output > 0 || metrics.cached > 0;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@ -916,16 +1000,16 @@ function CostsSection({
</thead>
<tbody>
{runsWithCost.slice(0, 10).map((run) => {
const u = run.usageJson as Record<string, unknown>;
const metrics = runMetrics(run);
return (
<tr key={run.id} className="border-b border-border last:border-b-0">
<td className="px-3 py-2">{formatDate(run.createdAt)}</td>
<td className="px-3 py-2 font-mono">{run.id.slice(0, 8)}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.input_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(Number(u.output_tokens ?? 0))}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.input)}</td>
<td className="px-3 py-2 text-right tabular-nums">{formatTokens(metrics.output)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{(u.cost_usd || u.total_cost_usd)
? `$${Number(u.cost_usd ?? u.total_cost_usd ?? 0).toFixed(4)}`
{metrics.cost > 0
? `$${metrics.cost.toFixed(4)}`
: "-"
}
</td>