mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 12:10:37 +09:00
Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
commit
cca086b863
125 changed files with 38085 additions and 683 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export function ApprovalDetail() {
|
|||
const payload = approval.payload as Record<string, unknown>;
|
||||
const linkedAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
const isActionable = approval.status === "pending" || approval.status === "revision_requested";
|
||||
const isBudgetApproval = approval.type === "budget_override_required";
|
||||
const TypeIcon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const showApprovedBanner = searchParams.get("resolved") === "approved" && approval.status === "approved";
|
||||
const primaryLinkedIssue = linkedIssues?.[0] ?? null;
|
||||
|
|
@ -260,7 +261,7 @@ export function ApprovalDetail() {
|
|||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isActionable && (
|
||||
{isActionable && !isBudgetApproval && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -280,6 +281,11 @@ export function ApprovalDetail() {
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isBudgetApproval && approval.status === "pending" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Resolve this budget stop from the budget controls on <Link to="/costs" className="underline underline-offset-2">/costs</Link>.
|
||||
</p>
|
||||
)}
|
||||
{approval.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { accessApi } from "../api/access";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, Check, Download, Upload } from "lucide-react";
|
||||
|
|
@ -35,6 +36,8 @@ export function CompanySettings() {
|
|||
const [companyName, setCompanyName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [brandColor, setBrandColor] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||
|
||||
// Sync local state from selected company
|
||||
useEffect(() => {
|
||||
|
|
@ -42,6 +45,7 @@ export function CompanySettings() {
|
|||
setCompanyName(selectedCompany.name);
|
||||
setDescription(selectedCompany.description ?? "");
|
||||
setBrandColor(selectedCompany.brandColor ?? "");
|
||||
setLogoUrl(selectedCompany.logoUrl ?? "");
|
||||
}, [selectedCompany]);
|
||||
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
|
|
@ -129,6 +133,42 @@ export function CompanySettings() {
|
|||
}
|
||||
});
|
||||
|
||||
const syncLogoState = (nextLogoUrl: string | null) => {
|
||||
setLogoUrl(nextLogoUrl ?? "");
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
};
|
||||
|
||||
const logoUploadMutation = useMutation({
|
||||
mutationFn: (file: File) =>
|
||||
assetsApi
|
||||
.uploadCompanyLogo(selectedCompanyId!, file)
|
||||
.then((asset) => companiesApi.update(selectedCompanyId!, { logoAssetId: asset.assetId })),
|
||||
onSuccess: (company) => {
|
||||
syncLogoState(company.logoUrl);
|
||||
setLogoUploadError(null);
|
||||
}
|
||||
});
|
||||
|
||||
const clearLogoMutation = useMutation({
|
||||
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoAssetId: null }),
|
||||
onSuccess: (company) => {
|
||||
setLogoUploadError(null);
|
||||
syncLogoState(company.logoUrl);
|
||||
}
|
||||
});
|
||||
|
||||
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.currentTarget.value = "";
|
||||
if (!file) return;
|
||||
setLogoUploadError(null);
|
||||
logoUploadMutation.mutate(file);
|
||||
}
|
||||
|
||||
function handleClearLogo() {
|
||||
clearLogoMutation.mutate();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setInviteError(null);
|
||||
setInviteSnippet(null);
|
||||
|
|
@ -226,11 +266,53 @@ export function CompanySettings() {
|
|||
<div className="shrink-0">
|
||||
<CompanyPatternIcon
|
||||
companyName={companyName || selectedCompany.name}
|
||||
logoUrl={logoUrl || null}
|
||||
brandColor={brandColor || null}
|
||||
className="rounded-[14px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex-1 space-y-3">
|
||||
<Field
|
||||
label="Logo"
|
||||
hint="Upload a PNG, JPEG, WEBP, GIF, or SVG logo image."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||||
onChange={handleLogoFileChange}
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none file:mr-4 file:rounded-md file:border-0 file:bg-muted file:px-2.5 file:py-1 file:text-xs"
|
||||
/>
|
||||
{logoUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleClearLogo}
|
||||
disabled={clearLogoMutation.isPending}
|
||||
>
|
||||
{clearLogoMutation.isPending ? "Removing..." : "Remove logo"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(logoUploadMutation.isError || logoUploadError) && (
|
||||
<span className="text-xs text-destructive">
|
||||
{logoUploadError ??
|
||||
(logoUploadMutation.error instanceof Error
|
||||
? logoUploadMutation.error.message
|
||||
: "Logo upload failed")}
|
||||
</span>
|
||||
)}
|
||||
{clearLogoMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{clearLogoMutation.error.message}
|
||||
</span>
|
||||
)}
|
||||
{logoUploadMutation.isPending && (
|
||||
<span className="text-xs text-muted-foreground">Uploading logo...</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="Brand color"
|
||||
hint="Sets the hue for the company icon. Leave empty for auto-generated color."
|
||||
|
|
@ -287,8 +369,8 @@ export function CompanySettings() {
|
|||
{generalMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{generalMutation.error instanceof Error
|
||||
? generalMutation.error.message
|
||||
: "Failed to save"}
|
||||
? generalMutation.error.message
|
||||
: "Failed to save"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -19,7 +19,7 @@ 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 } from "lucide-react";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle } from "lucide-react";
|
||||
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
|
|
@ -210,6 +210,25 @@ export function Dashboard() {
|
|||
|
||||
{data && (
|
||||
<>
|
||||
{data.budgets.activeIncidents > 0 ? (
|
||||
<div className="flex items-start justify-between gap-3 rounded-xl border border-red-500/20 bg-[linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))] px-4 py-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-300" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-50">
|
||||
{data.budgets.activeIncidents} active budget incident{data.budgets.activeIncidents === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="text-xs text-red-100/70">
|
||||
{data.budgets.pausedAgents} agents paused · {data.budgets.pausedProjects} projects paused · {data.budgets.pendingApprovals} pending budget approvals
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/costs" className="text-sm underline underline-offset-2 text-red-100">
|
||||
Open budgets
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-1 sm:gap-2">
|
||||
<MetricCard
|
||||
icon={Bot}
|
||||
|
|
@ -251,12 +270,14 @@ export function Dashboard() {
|
|||
/>
|
||||
<MetricCard
|
||||
icon={ShieldCheck}
|
||||
value={data.pendingApprovals}
|
||||
value={data.pendingApprovals + data.budgets.pendingApprovals}
|
||||
label="Pending Approvals"
|
||||
to="/approvals"
|
||||
description={
|
||||
<span>
|
||||
Awaiting board review
|
||||
{data.budgets.pendingApprovals > 0
|
||||
? `${data.budgets.pendingApprovals} budget overrides awaiting board review`
|
||||
: "Awaiting board review"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
|
|
@ -417,9 +417,7 @@ export function IssueDetail() {
|
|||
"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");
|
||||
const runCost = visibleRunCostUsd(usage, result);
|
||||
if (runCost > 0) hasCost = true;
|
||||
if (runInput + runOutput + runCached > 0) hasTokens = true;
|
||||
input += runInput;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike } from "@paperclipai/shared";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { agentsApi } from "../api/agents";
|
||||
|
|
@ -14,6 +15,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
|
|
@ -24,7 +26,7 @@ import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slo
|
|||
|
||||
/* ── Top-level tab types ── */
|
||||
|
||||
type ProjectBaseTab = "overview" | "list" | "configuration";
|
||||
type ProjectBaseTab = "overview" | "list" | "configuration" | "budget";
|
||||
type ProjectPluginTab = `plugin:${string}`;
|
||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||
|
||||
|
|
@ -39,6 +41,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
|||
const tab = segments[projectsIdx + 2];
|
||||
if (tab === "overview") return "overview";
|
||||
if (tab === "configuration") return "configuration";
|
||||
if (tab === "budget") return "budget";
|
||||
if (tab === "issues") return "list";
|
||||
return null;
|
||||
}
|
||||
|
|
@ -296,6 +299,14 @@ export function ProjectDetail() {
|
|||
},
|
||||
});
|
||||
|
||||
const { data: budgetOverview } = useQuery({
|
||||
queryKey: queryKeys.budgets.overview(resolvedCompanyId ?? "__none__"),
|
||||
queryFn: () => budgetsApi.overview(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
refetchInterval: 30_000,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Projects", href: "/projects" },
|
||||
|
|
@ -318,6 +329,10 @@ export function ProjectDetail() {
|
|||
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "budget") {
|
||||
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "list") {
|
||||
if (filter) {
|
||||
navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true });
|
||||
|
|
@ -377,6 +392,53 @@ export function ProjectDetail() {
|
|||
}
|
||||
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
|
||||
|
||||
const projectBudgetSummary = useMemo(() => {
|
||||
const matched = budgetOverview?.policies.find(
|
||||
(policy) => policy.scopeType === "project" && policy.scopeId === (project?.id ?? routeProjectRef),
|
||||
);
|
||||
if (matched) return matched;
|
||||
return {
|
||||
policyId: "",
|
||||
companyId: resolvedCompanyId ?? "",
|
||||
scopeType: "project",
|
||||
scopeId: project?.id ?? routeProjectRef,
|
||||
scopeName: project?.name ?? "Project",
|
||||
metric: "billed_cents",
|
||||
windowKind: "lifetime",
|
||||
amount: 0,
|
||||
observedAmount: 0,
|
||||
remainingAmount: 0,
|
||||
utilizationPercent: 0,
|
||||
warnPercent: 80,
|
||||
hardStopEnabled: true,
|
||||
notifyEnabled: true,
|
||||
isActive: false,
|
||||
status: "ok",
|
||||
paused: Boolean(project?.pausedAt),
|
||||
pauseReason: project?.pauseReason ?? null,
|
||||
windowStart: new Date(),
|
||||
windowEnd: new Date(),
|
||||
} satisfies BudgetPolicySummary;
|
||||
}, [budgetOverview?.policies, project, resolvedCompanyId, routeProjectRef]);
|
||||
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||||
scopeType: "project",
|
||||
scopeId: project?.id ?? routeProjectRef,
|
||||
amount,
|
||||
windowKind: "lifetime",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
if (!resolvedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(routeProjectRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(projectLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(resolvedCompanyId) });
|
||||
},
|
||||
});
|
||||
|
||||
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
}
|
||||
|
|
@ -397,6 +459,8 @@ export function ProjectDetail() {
|
|||
}
|
||||
if (tab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||
} else if (tab === "budget") {
|
||||
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||||
} else if (tab === "configuration") {
|
||||
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
||||
} else {
|
||||
|
|
@ -413,12 +477,20 @@ export function ProjectDetail() {
|
|||
onSelect={(color) => updateProject.mutate({ color })}
|
||||
/>
|
||||
</div>
|
||||
<InlineEditor
|
||||
value={project.name}
|
||||
onSave={(name) => updateProject.mutate({ name })}
|
||||
as="h2"
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<InlineEditor
|
||||
value={project.name}
|
||||
onSave={(name) => updateProject.mutate({ name })}
|
||||
as="h2"
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
{project.pauseReason === "budget" ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-red-500/30 bg-red-500/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-red-200">
|
||||
<span className="h-2 w-2 rounded-full bg-red-400" />
|
||||
Paused by budget hard stop
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginSlotOutlet
|
||||
|
|
@ -458,6 +530,7 @@ export function ProjectDetail() {
|
|||
{ value: "overview", label: "Overview" },
|
||||
{ value: "list", label: "List" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
...pluginTabItems.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
|
|
@ -497,6 +570,17 @@ export function ProjectDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "budget" && resolvedCompanyId ? (
|
||||
<div className="max-w-3xl">
|
||||
<BudgetPolicyCard
|
||||
summary={projectBudgetSummary}
|
||||
variant="plain"
|
||||
isSaving={budgetMutation.isPending}
|
||||
onSave={(amount) => budgetMutation.mutate(amount)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activePluginTab && (
|
||||
<PluginSlotMount
|
||||
slot={activePluginTab.slot}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue