mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
Show task sessions list in AgentDetail with per-session reset. Extract ApprovalCard into standalone component from Approvals and Inbox pages, reducing duplication. Add CompanySettings page with issuePrefix configuration. Fix Sidebar active state for settings route. Display sessionDisplayId in agent properties. Various cleanups to Approvals and Inbox pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
243 lines
9.1 KiB
TypeScript
243 lines
9.1 KiB
TypeScript
import { useEffect, 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 { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { StatusIcon } from "../components/StatusIcon";
|
|
import { PriorityIcon } from "../components/PriorityIcon";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { ApprovalCard } from "../components/ApprovalCard";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import {
|
|
Inbox as InboxIcon,
|
|
AlertTriangle,
|
|
Clock,
|
|
ExternalLink,
|
|
} from "lucide-react";
|
|
import { Identity } from "../components/Identity";
|
|
import type { Issue } from "@paperclip/shared";
|
|
|
|
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
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()
|
|
);
|
|
}
|
|
|
|
export function Inbox() {
|
|
const { selectedCompanyId } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "Inbox" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
const { data: approvals, isLoading, error } = useQuery({
|
|
queryKey: queryKeys.approvals.list(selectedCompanyId!),
|
|
queryFn: () => approvalsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: dashboard } = useQuery({
|
|
queryKey: queryKeys.dashboard(selectedCompanyId!),
|
|
queryFn: () => dashboardApi.summary(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: issues } = useQuery({
|
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const staleIssues = issues ? getStaleIssues(issues) : [];
|
|
|
|
const agentName = (id: string | null) => {
|
|
if (!id || !agents) return null;
|
|
const agent = agents.find((a) => a.id === id);
|
|
return agent?.name ?? null;
|
|
};
|
|
|
|
const approveMutation = useMutation({
|
|
mutationFn: (id: string) => approvalsApi.approve(id),
|
|
onSuccess: (_approval, id) => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
|
navigate(`/approvals/${id}?resolved=approved`);
|
|
},
|
|
onError: (err) => {
|
|
setActionError(err instanceof Error ? err.message : "Failed to approve");
|
|
},
|
|
});
|
|
|
|
const rejectMutation = useMutation({
|
|
mutationFn: (id: string) => approvalsApi.reject(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
|
},
|
|
onError: (err) => {
|
|
setActionError(err instanceof Error ? err.message : "Failed to reject");
|
|
},
|
|
});
|
|
|
|
if (!selectedCompanyId) {
|
|
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
|
}
|
|
|
|
const actionableApprovals = (approvals ?? []).filter(
|
|
(approval) => approval.status === "pending" || approval.status === "revision_requested",
|
|
);
|
|
const hasActionableApprovals = actionableApprovals.length > 0;
|
|
const hasAlerts =
|
|
dashboard &&
|
|
(dashboard.agents.error > 0 ||
|
|
dashboard.costs.monthUtilizationPercent >= 80);
|
|
const hasStale = staleIssues.length > 0;
|
|
const hasContent = hasActionableApprovals || hasAlerts || hasStale;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{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 && !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">
|
|
Approvals
|
|
</h3>
|
|
<button
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => navigate("/approvals")}
|
|
>
|
|
See all approvals <ExternalLink className="inline h-3 w-3 ml-0.5" />
|
|
</button>
|
|
</div>
|
|
<div className="grid gap-3">
|
|
{actionableApprovals.map((approval) => (
|
|
<ApprovalCard
|
|
key={approval.id}
|
|
approval={approval}
|
|
requesterAgent={approval.requestedByAgentId ? (agents ?? []).find((a) => a.id === approval.requestedByAgentId) ?? null : null}
|
|
onApprove={() => approveMutation.mutate(approval.id)}
|
|
onReject={() => rejectMutation.mutate(approval.id)}
|
|
onOpen={() => navigate(`/approvals/${approval.id}`)}
|
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Alerts */}
|
|
{hasAlerts && (
|
|
<>
|
|
{hasActionableApprovals && <Separator />}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
Alerts
|
|
</h3>
|
|
<div className="border border-border divide-y divide-border">
|
|
{dashboard!.agents.error > 0 && (
|
|
<div
|
|
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
|
onClick={() => navigate("/agents")}
|
|
>
|
|
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" />
|
|
<span className="text-sm">
|
|
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
|
|
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
|
|
</span>
|
|
</div>
|
|
)}
|
|
{dashboard!.costs.monthUtilizationPercent >= 80 && (
|
|
<div
|
|
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
|
|
onClick={() => navigate("/costs")}
|
|
>
|
|
<AlertTriangle className="h-4 w-4 text-yellow-400 shrink-0" />
|
|
<span className="text-sm">
|
|
Budget at{" "}
|
|
<span className="font-medium">
|
|
{dashboard!.costs.monthUtilizationPercent}%
|
|
</span>{" "}
|
|
utilization this month
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Stale Work */}
|
|
{hasStale && (
|
|
<>
|
|
{(hasActionableApprovals || hasAlerts) && <Separator />}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
Stale Work
|
|
</h3>
|
|
<div className="border border-border divide-y divide-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"
|
|
onClick={() => navigate(`/issues/${issue.id}`)}
|
|
>
|
|
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<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>
|
|
{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="text-xs text-muted-foreground shrink-0">
|
|
updated {timeAgo(issue.updatedAt)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|