Major AgentConfigForm rework with improved adapter configuration fields and layout. Refine sidebar, breadcrumbs, and card/tab components for visual consistency. Clean up page layouts across Activity, Agents, Approvals, Costs, Dashboard, Goals, Inbox, Issues, Org, and Projects pages. Minor heartbeat-run CLI fix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
278 lines
9.4 KiB
TypeScript
278 lines
9.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { approvalsApi } from "../api/approvals";
|
|
import { agentsApi } from "../api/agents";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { cn } from "../lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { ShieldCheck, UserPlus, Lightbulb, CheckCircle2, XCircle, Clock } from "lucide-react";
|
|
import type { Approval } from "@paperclip/shared";
|
|
|
|
type StatusFilter = "pending" | "all";
|
|
|
|
const typeLabel: Record<string, string> = {
|
|
hire_agent: "Hire Agent",
|
|
approve_ceo_strategy: "CEO Strategy",
|
|
};
|
|
|
|
const typeIcon: Record<string, typeof UserPlus> = {
|
|
hire_agent: UserPlus,
|
|
approve_ceo_strategy: Lightbulb,
|
|
};
|
|
|
|
function statusIcon(status: string) {
|
|
if (status === "approved") return <CheckCircle2 className="h-3.5 w-3.5 text-green-400" />;
|
|
if (status === "rejected") return <XCircle className="h-3.5 w-3.5 text-red-400" />;
|
|
if (status === "pending") return <Clock className="h-3.5 w-3.5 text-yellow-400" />;
|
|
return null;
|
|
}
|
|
|
|
function PayloadField({ label, value }: { label: string; value: unknown }) {
|
|
if (!value) return null;
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground w-24 shrink-0 text-xs">{label}</span>
|
|
<span>{String(value)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HireAgentPayload({ payload }: { payload: Record<string, unknown> }) {
|
|
return (
|
|
<div className="mt-3 space-y-1.5 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground w-24 shrink-0 text-xs">Name</span>
|
|
<span className="font-medium">{String(payload.name ?? "—")}</span>
|
|
</div>
|
|
<PayloadField label="Role" value={payload.role} />
|
|
<PayloadField label="Title" value={payload.title} />
|
|
{!!payload.capabilities && (
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-muted-foreground w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
|
|
<span className="text-muted-foreground">{String(payload.capabilities)}</span>
|
|
</div>
|
|
)}
|
|
{!!payload.adapterType && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground w-24 shrink-0 text-xs">Adapter</span>
|
|
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
|
{String(payload.adapterType)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CeoStrategyPayload({ payload }: { payload: Record<string, unknown> }) {
|
|
const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text;
|
|
return (
|
|
<div className="mt-3 space-y-1.5 text-sm">
|
|
<PayloadField label="Title" value={payload.title} />
|
|
{!!plan && (
|
|
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground whitespace-pre-wrap font-mono text-xs max-h-48 overflow-y-auto">
|
|
{String(plan)}
|
|
</div>
|
|
)}
|
|
{!plan && (
|
|
<pre className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground overflow-x-auto max-h-48">
|
|
{JSON.stringify(payload, null, 2)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ApprovalCard({
|
|
approval,
|
|
requesterName,
|
|
onApprove,
|
|
onReject,
|
|
isPending,
|
|
}: {
|
|
approval: Approval;
|
|
requesterName: string | null;
|
|
onApprove: () => void;
|
|
onReject: () => void;
|
|
isPending: boolean;
|
|
}) {
|
|
const Icon = typeIcon[approval.type] ?? ShieldCheck;
|
|
const label = typeLabel[approval.type] ?? approval.type;
|
|
|
|
return (
|
|
<div className="border border-border rounded-lg p-4 space-y-0">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<div>
|
|
<span className="font-medium text-sm">{label}</span>
|
|
{requesterName && (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
requested by {requesterName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{statusIcon(approval.status)}
|
|
<span className="text-xs text-muted-foreground capitalize">{approval.status}</span>
|
|
<span className="text-xs text-muted-foreground">· {timeAgo(approval.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Payload */}
|
|
{approval.type === "hire_agent" ? (
|
|
<HireAgentPayload payload={approval.payload} />
|
|
) : (
|
|
<CeoStrategyPayload payload={approval.payload} />
|
|
)}
|
|
|
|
{/* Decision note */}
|
|
{approval.decisionNote && (
|
|
<div className="mt-3 text-xs text-muted-foreground italic border-t border-border pt-2">
|
|
Note: {approval.decisionNote}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
{approval.status === "pending" && (
|
|
<div className="flex gap-2 mt-4 pt-3 border-t border-border">
|
|
<Button
|
|
size="sm"
|
|
className="bg-green-700 hover:bg-green-600 text-white"
|
|
onClick={onApprove}
|
|
disabled={isPending}
|
|
>
|
|
Approve
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={onReject}
|
|
disabled={isPending}
|
|
>
|
|
Reject
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Approvals() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const queryClient = useQueryClient();
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("pending");
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "Approvals" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: queryKeys.approvals.list(selectedCompanyId!),
|
|
queryFn: () => approvalsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const approveMutation = useMutation({
|
|
mutationFn: (id: string) => approvalsApi.approve(id),
|
|
onSuccess: () => {
|
|
setActionError(null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
|
},
|
|
onError: (err) => {
|
|
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
|
},
|
|
});
|
|
|
|
const rejectMutation = useMutation({
|
|
mutationFn: (id: string) => approvalsApi.reject(id),
|
|
onSuccess: () => {
|
|
setActionError(null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
|
},
|
|
onError: (err) => {
|
|
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
|
},
|
|
});
|
|
|
|
const agentName = (id: string | null) => {
|
|
if (!id || !agents) return null;
|
|
return agents.find((a) => a.id === id)?.name ?? null;
|
|
};
|
|
|
|
const filtered = (data ?? []).filter(
|
|
(a) => statusFilter === "all" || a.status === "pending",
|
|
);
|
|
|
|
const pendingCount = (data ?? []).filter((a) => a.status === "pending").length;
|
|
|
|
if (!selectedCompanyId) {
|
|
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
|
}
|
|
|
|
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>
|
|
</div>
|
|
|
|
{isLoading && <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 && filtered.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<ShieldCheck className="h-8 w-8 text-muted-foreground/30 mb-3" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{statusFilter === "pending" ? "No pending approvals." : "No approvals yet."}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{filtered.length > 0 && (
|
|
<div className="grid gap-3">
|
|
{filtered.map((approval) => (
|
|
<ApprovalCard
|
|
key={approval.id}
|
|
approval={approval}
|
|
requesterName={agentName(approval.requestedByAgentId)}
|
|
onApprove={() => approveMutation.mutate(approval.id)}
|
|
onReject={() => rejectMutation.mutate(approval.id)}
|
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|