From 176d2794036cfd8b8b272a7d07cbbf2961513c5d Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 19 Feb 2026 13:03:08 -0600 Subject: [PATCH] UI: approval detail page, agent hiring UX, costs breakdown, sidebar badges, and dashboard improvements Add ApprovalDetail page with comment thread, revision request/resubmit flow, and ApprovalPayload component for structured payload display. Extend AgentDetail with permissions management, config revision history, and duplicate action. Add agent hire dialog with permission-gated access. Rework Costs page with per-agent breakdown table and period filtering. Add sidebar badge counts for pending approvals and inbox items. Enhance Dashboard with live metrics and sparkline trends. Extend Agents list with pending_approval status and bulk actions. Update IssueDetail with approval linking. Various component improvements to MetricCard, InlineEditor, CommentThread, and StatusBadge. Co-Authored-By: Claude Opus 4.6 --- ui/package.json | 2 + ui/src/App.tsx | 2 + ui/src/api/agents.ts | 28 +- ui/src/api/approvals.ts | 11 +- ui/src/api/companies.ts | 7 +- ui/src/api/costs.ts | 21 +- ui/src/api/index.ts | 1 + ui/src/api/issues.ts | 7 +- ui/src/api/sidebarBadges.ts | 6 + ui/src/components/AgentConfigForm.tsx | 132 +++++++--- ui/src/components/ApprovalPayload.tsx | 74 ++++++ ui/src/components/CommentThread.tsx | 5 +- ui/src/components/InlineEditor.tsx | 9 +- ui/src/components/MetricCard.tsx | 34 ++- ui/src/components/NewAgentDialog.tsx | 7 +- ui/src/components/NewIssueDialog.tsx | 1 + ui/src/components/Sidebar.tsx | 24 +- ui/src/components/StatusBadge.tsx | 3 + ui/src/context/LiveUpdatesProvider.tsx | 1 + ui/src/index.css | 43 +++ ui/src/lib/queryKeys.ts | 9 +- ui/src/pages/AgentDetail.tsx | 132 ++++++++-- ui/src/pages/Agents.tsx | 66 ++++- ui/src/pages/ApprovalDetail.tsx | 352 +++++++++++++++++++++++++ ui/src/pages/Approvals.tsx | 99 ++----- ui/src/pages/Companies.tsx | 42 +++ ui/src/pages/Costs.tsx | 132 ++++++++-- ui/src/pages/Dashboard.tsx | 135 +++++++++- ui/src/pages/Inbox.tsx | 26 +- ui/src/pages/IssueDetail.tsx | 72 ++++- ui/src/pages/Org.tsx | 2 + 31 files changed, 1271 insertions(+), 214 deletions(-) create mode 100644 ui/src/api/sidebarBadges.ts create mode 100644 ui/src/components/ApprovalPayload.tsx create mode 100644 ui/src/pages/ApprovalDetail.tsx diff --git a/ui/package.json b/ui/package.json index fc243aec..a4d37838 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,6 +15,7 @@ "@paperclip/adapter-utils": "workspace:*", "@paperclip/shared": "workspace:*", "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -23,6 +24,7 @@ "radix-ui": "^1.4.3", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.1.5", "tailwind-merge": "^3.4.1" }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 01b26557..5f57ab2c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,7 @@ import { IssueDetail } from "./pages/IssueDetail"; import { Goals } from "./pages/Goals"; import { GoalDetail } from "./pages/GoalDetail"; import { Approvals } from "./pages/Approvals"; +import { ApprovalDetail } from "./pages/ApprovalDetail"; import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; @@ -35,6 +36,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 725346ff..796ae631 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,4 +1,11 @@ -import type { Agent, AgentKeyCreated, AgentRuntimeState, HeartbeatRun } from "@paperclip/shared"; +import type { + Agent, + AgentKeyCreated, + AgentRuntimeState, + HeartbeatRun, + Approval, + AgentConfigRevision, +} from "@paperclip/shared"; import { api } from "./client"; export interface AgentKey { @@ -21,16 +28,35 @@ export interface OrgNode { reports: OrgNode[]; } +export interface AgentHireResponse { + agent: Agent; + approval: Approval | null; +} + export const agentsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/agents`), org: (companyId: string) => api.get(`/companies/${companyId}/org`), + listConfigurations: (companyId: string) => + api.get[]>(`/companies/${companyId}/agent-configurations`), get: (id: string) => api.get(`/agents/${id}`), + getConfiguration: (id: string) => api.get>(`/agents/${id}/configuration`), + listConfigRevisions: (id: string) => + api.get(`/agents/${id}/config-revisions`), + getConfigRevision: (id: string, revisionId: string) => + api.get(`/agents/${id}/config-revisions/${revisionId}`), + rollbackConfigRevision: (id: string, revisionId: string) => + api.post(`/agents/${id}/config-revisions/${revisionId}/rollback`, {}), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/agents`, data), + hire: (companyId: string, data: Record) => + api.post(`/companies/${companyId}/agent-hires`, data), update: (id: string, data: Record) => api.patch(`/agents/${id}`, data), + updatePermissions: (id: string, data: { canCreateAgents: boolean }) => + api.patch(`/agents/${id}/permissions`, data), pause: (id: string) => api.post(`/agents/${id}/pause`, {}), resume: (id: string) => api.post(`/agents/${id}/resume`, {}), terminate: (id: string) => api.post(`/agents/${id}/terminate`, {}), + remove: (id: string) => api.delete<{ ok: true }>(`/agents/${id}`), listKeys: (id: string) => api.get(`/agents/${id}/keys`), createKey: (id: string, name: string) => api.post(`/agents/${id}/keys`, { name }), revokeKey: (agentId: string, keyId: string) => api.delete<{ ok: true }>(`/agents/${agentId}/keys/${keyId}`), diff --git a/ui/src/api/approvals.ts b/ui/src/api/approvals.ts index 2df9cb34..61fae055 100644 --- a/ui/src/api/approvals.ts +++ b/ui/src/api/approvals.ts @@ -1,4 +1,4 @@ -import type { Approval } from "@paperclip/shared"; +import type { Approval, ApprovalComment, Issue } from "@paperclip/shared"; import { api } from "./client"; export const approvalsApi = { @@ -8,8 +8,17 @@ export const approvalsApi = { ), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/approvals`, data), + get: (id: string) => api.get(`/approvals/${id}`), approve: (id: string, decisionNote?: string) => api.post(`/approvals/${id}/approve`, { decisionNote }), reject: (id: string, decisionNote?: string) => api.post(`/approvals/${id}/reject`, { decisionNote }), + requestRevision: (id: string, decisionNote?: string) => + api.post(`/approvals/${id}/request-revision`, { decisionNote }), + resubmit: (id: string, payload?: Record) => + api.post(`/approvals/${id}/resubmit`, { payload }), + listComments: (id: string) => api.get(`/approvals/${id}/comments`), + addComment: (id: string, body: string) => + api.post(`/approvals/${id}/comments`, { body }), + listIssues: (id: string) => api.get(`/approvals/${id}/issues`), }; diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index e14c828a..a27a1c54 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -11,7 +11,12 @@ export const companiesApi = { api.post("/companies", data), update: ( companyId: string, - data: Partial>, + data: Partial< + Pick< + Company, + "name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" + > + >, ) => api.patch(`/companies/${companyId}`, data), archive: (companyId: string) => api.post(`/companies/${companyId}/archive`, {}), remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`), diff --git a/ui/src/api/costs.ts b/ui/src/api/costs.ts index 977b2347..bb90fd49 100644 --- a/ui/src/api/costs.ts +++ b/ui/src/api/costs.ts @@ -1,4 +1,4 @@ -import type { CostSummary } from "@paperclip/shared"; +import type { CostSummary, CostByAgent } from "@paperclip/shared"; import { api } from "./client"; export interface CostByEntity { @@ -9,10 +9,19 @@ export interface CostByEntity { outputTokens: number; } +function dateParams(from?: string, to?: string): string { + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (to) params.set("to", to); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + export const costsApi = { - summary: (companyId: string) => api.get(`/companies/${companyId}/costs/summary`), - byAgent: (companyId: string) => - api.get(`/companies/${companyId}/costs/by-agent`), - byProject: (companyId: string) => - api.get(`/companies/${companyId}/costs/by-project`), + summary: (companyId: string, from?: string, to?: string) => + api.get(`/companies/${companyId}/costs/summary${dateParams(from, to)}`), + byAgent: (companyId: string, from?: string, to?: string) => + api.get(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`), + byProject: (companyId: string, from?: string, to?: string) => + api.get(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`), }; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 1f28465d..43be4f5b 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -9,3 +9,4 @@ export { costsApi } from "./costs"; export { activityApi } from "./activity"; export { dashboardApi } from "./dashboard"; export { heartbeatsApi } from "./heartbeats"; +export { sidebarBadgesApi } from "./sidebarBadges"; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 29ee925e..7030d6d5 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -1,4 +1,4 @@ -import type { Issue, IssueComment } from "@paperclip/shared"; +import type { Approval, Issue, IssueComment } from "@paperclip/shared"; import { api } from "./client"; export const issuesApi = { @@ -17,4 +17,9 @@ export const issuesApi = { listComments: (id: string) => api.get(`/issues/${id}/comments`), addComment: (id: string, body: string, reopen?: boolean) => api.post(`/issues/${id}/comments`, reopen === undefined ? { body } : { body, reopen }), + listApprovals: (id: string) => api.get(`/issues/${id}/approvals`), + linkApproval: (id: string, approvalId: string) => + api.post(`/issues/${id}/approvals`, { approvalId }), + unlinkApproval: (id: string, approvalId: string) => + api.delete<{ ok: true }>(`/issues/${id}/approvals/${approvalId}`), }; diff --git a/ui/src/api/sidebarBadges.ts b/ui/src/api/sidebarBadges.ts new file mode 100644 index 00000000..0883673c --- /dev/null +++ b/ui/src/api/sidebarBadges.ts @@ -0,0 +1,6 @@ +import type { SidebarBadges } from "@paperclip/shared"; +import { api } from "./client"; + +export const sidebarBadgesApi = { + get: (companyId: string) => api.get(`/companies/${companyId}/sidebar-badges`), +}; diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index a4c4d58b..c54ccb34 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -10,7 +10,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; -import { FolderOpen, Heart, ChevronDown } from "lucide-react"; +import { FolderOpen, Heart, ChevronDown, X } from "lucide-react"; import { cn } from "../lib/utils"; import { Field, @@ -122,28 +122,6 @@ function formatArgList(value: unknown): string { return typeof value === "string" ? value : ""; } -function parseEnvVars(text: string): Record { - const env: Record = {}; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq <= 0) continue; - const key = trimmed.slice(0, eq).trim(); - const value = trimmed.slice(eq + 1); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; - env[key] = value; - } - return env; -} - -function formatEnvVars(value: unknown): string { - if (typeof value !== "object" || value === null || Array.isArray(value)) return ""; - return Object.entries(value as Record) - .filter(([, v]) => typeof v === "string") - .map(([k, v]) => `${k}=${String(v)}`) - .join("\n"); -} function extractPickedDirectoryPath(handle: unknown): string | null { if (typeof handle !== "object" || handle === null) return null; @@ -540,19 +518,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { minRows={3} /> ) : ( - { - const parsed = parseEnvVars(v); - mark( - "adapterConfig", - "env", - Object.keys(parsed).length > 0 ? parsed : undefined, - ); - }} - immediate - placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"} - minRows={3} + )} + onChange={(env) => mark("adapterConfig", "env", env)} /> )} @@ -727,6 +695,98 @@ function AdapterTypeDropdown({ ); } +function EnvVarEditor({ + value, + onChange, +}: { + value: Record; + onChange: (env: Record | undefined) => void; +}) { + type Row = { key: string; value: string }; + + function toRows(rec: Record | null | undefined): Row[] { + if (!rec || typeof rec !== "object") return [{ key: "", value: "" }]; + const entries = Object.entries(rec).map(([k, v]) => ({ key: k, value: String(v) })); + return [...entries, { key: "", value: "" }]; + } + + const [rows, setRows] = useState(() => toRows(value)); + const valueRef = useRef(value); + + // Sync when value identity changes (overlay reset after save) + useEffect(() => { + if (value !== valueRef.current) { + valueRef.current = value; + setRows(toRows(value)); + } + }, [value]); + + function emit(nextRows: Row[]) { + const rec: Record = {}; + for (const row of nextRows) { + const k = row.key.trim(); + if (k) rec[k] = row.value; + } + onChange(Object.keys(rec).length > 0 ? rec : undefined); + } + + function updateRow(i: number, field: "key" | "value", v: string) { + const next = rows.map((r, idx) => (idx === i ? { ...r, [field]: v } : r)); + if (next[next.length - 1].key || next[next.length - 1].value) { + next.push({ key: "", value: "" }); + } + setRows(next); + emit(next); + } + + function removeRow(i: number) { + const next = rows.filter((_, idx) => idx !== i); + if (next.length === 0 || next[next.length - 1].key || next[next.length - 1].value) { + next.push({ key: "", value: "" }); + } + setRows(next); + emit(next); + } + + return ( +
+ {rows.map((row, i) => { + const isTrailing = i === rows.length - 1 && !row.key && !row.value; + return ( +
+ updateRow(i, "key", e.target.value)} + /> + updateRow(i, "value", e.target.value)} + /> + {!isTrailing ? ( + + ) : ( +
+ )} +
+ ); + })} +

+ PAPERCLIP_* variables are injected automatically at runtime. +

+
+ ); +} + function ModelDropdown({ models, value, diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx new file mode 100644 index 00000000..7283cd1e --- /dev/null +++ b/ui/src/components/ApprovalPayload.tsx @@ -0,0 +1,74 @@ +import { UserPlus, Lightbulb, ShieldCheck } from "lucide-react"; + +export const typeLabel: Record = { + hire_agent: "Hire Agent", + approve_ceo_strategy: "CEO Strategy", +}; + +export const typeIcon: Record = { + hire_agent: UserPlus, + approve_ceo_strategy: Lightbulb, +}; + +export const defaultTypeIcon = ShieldCheck; + +function PayloadField({ label, value }: { label: string; value: unknown }) { + if (!value) return null; + return ( +
+ {label} + {String(value)} +
+ ); +} + +export function HireAgentPayload({ payload }: { payload: Record }) { + return ( +
+
+ Name + {String(payload.name ?? "—")} +
+ + + {!!payload.capabilities && ( +
+ Capabilities + {String(payload.capabilities)} +
+ )} + {!!payload.adapterType && ( +
+ Adapter + + {String(payload.adapterType)} + +
+ )} +
+ ); +} + +export function CeoStrategyPayload({ payload }: { payload: Record }) { + const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text; + return ( +
+ + {!!plan && ( +
+ {String(plan)} +
+ )} + {!plan && ( +
+          {JSON.stringify(payload, null, 2)}
+        
+ )} +
+ ); +} + +export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record }) { + if (type === "hire_agent") return ; + return ; +} diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index f8302b30..992db42f 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; +import Markdown from "react-markdown"; import type { IssueComment, Agent } from "@paperclip/shared"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -72,7 +73,9 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen {formatDate(comment.createdAt)}
-

{comment.body}

+
+ {comment.body} +
{comment.runId && comment.runAgentId && (
setEditing(true)} > - {value || placeholder} + {value && multiline ? ( +
+ {value} +
+ ) : ( + value || placeholder + )} ); } diff --git a/ui/src/components/MetricCard.tsx b/ui/src/components/MetricCard.tsx index 6c1d3e7c..399a2033 100644 --- a/ui/src/components/MetricCard.tsx +++ b/ui/src/components/MetricCard.tsx @@ -1,29 +1,41 @@ import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; import { Card, CardContent } from "@/components/ui/card"; interface MetricCardProps { icon: LucideIcon; value: string | number; label: string; - description?: string; + description?: ReactNode; + onClick?: () => void; } -export function MetricCard({ icon: Icon, value, label, description }: MetricCardProps) { +export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) { return ( -
-
+
+
+

+ {value} +

+

+ {label} +

+ {description && ( +
{description}
+ )} +
+
-
-

{value}

-

{label}

-
- {description && ( -

{description}

- )} ); diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 94e12a93..e1f1157a 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -76,12 +76,13 @@ export function NewAgentDialog() { const createAgent = useMutation({ mutationFn: (data: Record) => - agentsApi.create(selectedCompanyId!, data), - onSuccess: (agent) => { + agentsApi.hire(selectedCompanyId!, data), + onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); reset(); closeNewAgent(); - navigate(`/agents/${agent.id}`); + navigate(`/agents/${result.agent.id}`); }, }); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index dbef7ab4..87059789 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -115,6 +115,7 @@ export function NewIssueDialog() { issuesApi.create(selectedCompanyId!, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); + if (draftTimer.current) clearTimeout(draftTimer.current); clearDraft(); reset(); closeNewIssue(); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 56e15f96..2ba146aa 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -15,15 +15,25 @@ import { BookOpen, Paperclip, } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; import { CompanySwitcher } from "./CompanySwitcher"; import { SidebarSection } from "./SidebarSection"; import { SidebarNavItem } from "./SidebarNavItem"; import { useDialog } from "../context/DialogContext"; +import { useCompany } from "../context/CompanyContext"; +import { sidebarBadgesApi } from "../api/sidebarBadges"; +import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; export function Sidebar() { const { openNewIssue } = useDialog(); + const { selectedCompanyId } = useCompany(); + const { data: sidebarBadges } = useQuery({ + queryKey: queryKeys.sidebarBadges(selectedCompanyId!), + queryFn: () => sidebarBadgesApi.get(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); function openSearch() { document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })); @@ -63,7 +73,12 @@ export function Sidebar() {
@@ -585,6 +629,20 @@ function ConfigurationTab({ mutationFn: (data: Record) => agentsApi.update(agent.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); + }, + }); + + const { data: configRevisions } = useQuery({ + queryKey: queryKeys.agents.configRevisions(agent.id), + queryFn: () => agentsApi.listConfigRevisions(agent.id), + }); + + const rollbackConfig = useMutation({ + mutationFn: (revisionId: string) => agentsApi.rollbackConfigRevision(agent.id, revisionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.configRevisions(agent.id) }); }, }); @@ -593,18 +651,58 @@ function ConfigurationTab({ }, [onSavingChange, updateAgent.isPending]); return ( -
- updateAgent.mutate(patch)} - isSaving={updateAgent.isPending} - adapterModels={adapterModels} - onDirtyChange={onDirtyChange} - onSaveActionChange={onSaveActionChange} - onCancelActionChange={onCancelActionChange} - hideInlineSave - /> +
+
+ updateAgent.mutate(patch)} + isSaving={updateAgent.isPending} + adapterModels={adapterModels} + onDirtyChange={onDirtyChange} + onSaveActionChange={onSaveActionChange} + onCancelActionChange={onCancelActionChange} + hideInlineSave + /> +
+
+
+

Configuration Revisions

+ {configRevisions?.length ?? 0} +
+ {(configRevisions ?? []).length === 0 ? ( +

No configuration revisions yet.

+ ) : ( +
+ {(configRevisions ?? []).slice(0, 10).map((revision) => ( +
+
+
+ {revision.id.slice(0, 8)} + · + {formatDate(revision.createdAt)} + · + {revision.source} +
+ +
+

+ Changed:{" "} + {revision.changedKeys.length > 0 ? revision.changedKeys.join(", ") : "no tracked changes"} +

+
+ ))} +
+ )} +
); } diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 2d689dc3..05cfdf47 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -12,7 +12,7 @@ import { EmptyState } from "../components/EmptyState"; import { formatCents, relativeTime, cn } from "../lib/utils"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { Bot, Plus, List, GitBranch } from "lucide-react"; +import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react"; import type { Agent } from "@paperclip/shared"; const adapterLabels: Record = { @@ -30,23 +30,23 @@ const roleLabels: Record = { type FilterTab = "all" | "active" | "paused" | "error"; -function matchesFilter(status: string, tab: FilterTab): boolean { +function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean): boolean { + if (status === "terminated") return showTerminated; if (tab === "all") return true; if (tab === "active") return status === "active" || status === "running" || status === "idle"; if (tab === "paused") return status === "paused"; - if (tab === "error") return status === "error" || status === "terminated"; + if (tab === "error") return status === "error"; return true; } -function filterAgents(agents: Agent[], tab: FilterTab): Agent[] { - return agents.filter((a) => matchesFilter(a.status, tab)); +function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] { + return agents.filter((a) => matchesFilter(a.status, tab, showTerminated)); } -function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] { - if (tab === "all") return nodes; +function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] { return nodes.reduce((acc, node) => { - const filteredReports = filterOrgTree(node.reports, tab); - if (matchesFilter(node.status, tab) || filteredReports.length > 0) { + const filteredReports = filterOrgTree(node.reports, tab, showTerminated); + if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) { acc.push({ ...node, reports: filteredReports }); } return acc; @@ -60,6 +60,8 @@ export function Agents() { const navigate = useNavigate(); const [tab, setTab] = useState("all"); const [view, setView] = useState<"list" | "org">("org"); + const [showTerminated, setShowTerminated] = useState(false); + const [filtersOpen, setFiltersOpen] = useState(false); const { data: agents, isLoading, error } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -87,21 +89,51 @@ export function Agents() { return ; } - const filtered = filterAgents(agents ?? [], tab); - const filteredOrg = filterOrgTree(orgTree ?? [], tab); + const filtered = filterAgents(agents ?? [], tab, showTerminated); + const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated); return (
setTab(v as FilterTab)}> - All{agents ? ` (${agents.length})` : ""} + All Active Paused Error
+ {/* Filters */} +
+ + {filtersOpen && ( +
+ +
+ )} +
{/* View toggle */}
+ {filtered.length > 0 && ( +

{filtered.length} agent{filtered.length !== 1 ? "s" : ""}

+ )} + {isLoading &&

Loading...

} {error &&

{error.message}

} @@ -165,8 +201,10 @@ export function Agents() { ? "bg-cyan-400 animate-pulse" : agent.status === "active" ? "bg-green-400" - : agent.status === "paused" + : agent.status === "paused" ? "bg-yellow-400" + : agent.status === "pending_approval" + ? "bg-amber-400" : agent.status === "error" ? "bg-red-400" : "bg-neutral-400" @@ -260,6 +298,8 @@ function OrgTreeNode({ ? "bg-green-400" : node.status === "paused" ? "bg-yellow-400" + : node.status === "pending_approval" + ? "bg-amber-400" : node.status === "error" ? "bg-red-400" : "bg-neutral-400"; diff --git a/ui/src/pages/ApprovalDetail.tsx b/ui/src/pages/ApprovalDetail.tsx new file mode 100644 index 00000000..fb09bd8a --- /dev/null +++ b/ui/src/pages/ApprovalDetail.tsx @@ -0,0 +1,352 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useMutation, useQuery, 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 { StatusBadge } from "../components/StatusBadge"; +import { Identity } from "../components/Identity"; +import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { CheckCircle2, ChevronRight, Sparkles } from "lucide-react"; +import type { ApprovalComment } from "@paperclip/shared"; + +export function ApprovalDetail() { + const { approvalId } = useParams<{ approvalId: string }>(); + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const [commentBody, setCommentBody] = useState(""); + const [error, setError] = useState(null); + const [showRawPayload, setShowRawPayload] = useState(false); + + const { data: approval, isLoading } = useQuery({ + queryKey: queryKeys.approvals.detail(approvalId!), + queryFn: () => approvalsApi.get(approvalId!), + enabled: !!approvalId, + }); + + const { data: comments } = useQuery({ + queryKey: queryKeys.approvals.comments(approvalId!), + queryFn: () => approvalsApi.listComments(approvalId!), + enabled: !!approvalId, + }); + + const { data: linkedIssues } = useQuery({ + queryKey: queryKeys.approvals.issues(approvalId!), + queryFn: () => approvalsApi.listIssues(approvalId!), + enabled: !!approvalId, + }); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(approval?.companyId ?? selectedCompanyId ?? ""), + queryFn: () => agentsApi.list(approval?.companyId ?? selectedCompanyId ?? ""), + enabled: !!(approval?.companyId ?? selectedCompanyId), + }); + + const agentNameById = useMemo(() => { + const map = new Map(); + for (const agent of agents ?? []) map.set(agent.id, agent.name); + return map; + }, [agents]); + + useEffect(() => { + setBreadcrumbs([ + { label: "Approvals", href: "/approvals" }, + { label: approval?.id?.slice(0, 8) ?? approvalId ?? "Approval" }, + ]); + }, [setBreadcrumbs, approval, approvalId]); + + const refresh = () => { + if (!approvalId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(approvalId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.comments(approvalId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.issues(approvalId) }); + if (approval?.companyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(approval.companyId) }); + queryClient.invalidateQueries({ + queryKey: queryKeys.approvals.list(approval.companyId, "pending"), + }); + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(approval.companyId) }); + } + }; + + const approveMutation = useMutation({ + mutationFn: () => approvalsApi.approve(approvalId!), + onSuccess: () => { + setError(null); + refresh(); + navigate(`/approvals/${approvalId}?resolved=approved`, { replace: true }); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Approve failed"), + }); + + const rejectMutation = useMutation({ + mutationFn: () => approvalsApi.reject(approvalId!), + onSuccess: () => { + setError(null); + refresh(); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Reject failed"), + }); + + const revisionMutation = useMutation({ + mutationFn: () => approvalsApi.requestRevision(approvalId!), + onSuccess: () => { + setError(null); + refresh(); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Revision request failed"), + }); + + const resubmitMutation = useMutation({ + mutationFn: () => approvalsApi.resubmit(approvalId!), + onSuccess: () => { + setError(null); + refresh(); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Resubmit failed"), + }); + + const addCommentMutation = useMutation({ + mutationFn: () => approvalsApi.addComment(approvalId!, commentBody.trim()), + onSuccess: () => { + setCommentBody(""); + setError(null); + refresh(); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Comment failed"), + }); + + const deleteAgentMutation = useMutation({ + mutationFn: (agentId: string) => agentsApi.remove(agentId), + onSuccess: () => { + setError(null); + refresh(); + navigate("/approvals"); + }, + onError: (err) => setError(err instanceof Error ? err.message : "Delete failed"), + }); + + if (isLoading) return

Loading...

; + if (!approval) return

Approval not found.

; + + const payload = approval.payload as Record; + const linkedAgentId = typeof payload.agentId === "string" ? payload.agentId : null; + const isActionable = approval.status === "pending" || approval.status === "revision_requested"; + const TypeIcon = typeIcon[approval.type] ?? defaultTypeIcon; + const showApprovedBanner = searchParams.get("resolved") === "approved" && approval.status === "approved"; + const primaryLinkedIssue = linkedIssues?.[0] ?? null; + const resolvedCta = + primaryLinkedIssue + ? { + label: + (linkedIssues?.length ?? 0) > 1 + ? "Review linked issues" + : "Review linked issue", + to: `/issues/${primaryLinkedIssue.id}`, + } + : linkedAgentId + ? { + label: "Open hired agent", + to: `/agents/${linkedAgentId}`, + } + : { + label: "Back to approvals", + to: "/approvals", + }; + + return ( +
+ {showApprovedBanner && ( +
+
+
+
+ + +
+
+

Approval confirmed

+

+ Requesting agent was notified to review this approval and linked issues. +

+
+
+ +
+
+ )} +
+
+
+ +
+

{typeLabel[approval.type] ?? approval.type.replace(/_/g, " ")}

+

{approval.id}

+
+
+ +
+
+ {approval.requestedByAgentId && ( +
+ Requested by + +
+ )} + + + {showRawPayload && ( +
+              {JSON.stringify(payload, null, 2)}
+            
+ )} + {approval.decisionNote && ( +

Decision note: {approval.decisionNote}

+ )} +
+ {error &&

{error}

} + {linkedIssues && linkedIssues.length > 0 && ( +
+

Linked Issues

+
+ {linkedIssues.map((issue) => ( + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} + + ))} +
+

+ Linked issues remain open until the requesting agent follows up and closes them. +

+
+ )} +
+ {isActionable && ( + <> + + + + )} + {approval.status === "pending" && ( + + )} + {approval.status === "revision_requested" && ( + + )} + {approval.status === "rejected" && approval.type === "hire_agent" && linkedAgentId && ( + + )} +
+
+ +
+

Comments ({comments?.length ?? 0})

+
+ {(comments ?? []).map((comment: ApprovalComment) => ( +
+
+ + + {new Date(comment.createdAt).toLocaleString()} + +
+

{comment.body}

+
+ ))} +
+