From 0b9bea667cc3b4061e0743529617c4b244f7ef52 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Tue, 17 Feb 2026 20:16:57 -0600 Subject: [PATCH] Improve issue and approvals UI: parent chain, project names, approval details shared: - Add IssueAncestor type and ancestors?: IssueAncestor[] to Issue interface IssueDetail: - Show clickable parent chain breadcrumb above issue title when issue has parents - Ancestors rendered root-first (reversed from API order) with chevron separators IssueProperties: - Project field now shows project name with a link instead of raw UUID - Assignee field now links to agent detail page - Parent field shows immediate parent title (from ancestors[0]) with link - Show request depth when > 0 Approvals: - Pending/All filter tabs with pending count badge - HireAgentPayload renders name, role, title, capabilities, adapter type - CeoStrategyPayload renders plan/description text in a monospace block - Decision note shown when present - Requester agent name shown in header - Approve button uses green styling; empty state with icon Co-Authored-By: Claude Sonnet 4.6 --- packages/shared/src/types/index.ts | 2 +- packages/shared/src/types/issue.ts | 12 ++ ui/src/components/IssueProperties.tsx | 64 +++++- ui/src/pages/Approvals.tsx | 281 ++++++++++++++++++++++---- ui/src/pages/IssueDetail.tsx | 26 ++- 5 files changed, 331 insertions(+), 54 deletions(-) diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 69ffe96b..f9dc25e5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,7 +1,7 @@ export type { Company } from "./company.js"; export type { Agent, AgentKeyCreated } from "./agent.js"; export type { Project } from "./project.js"; -export type { Issue, IssueComment } from "./issue.js"; +export type { Issue, IssueComment, IssueAncestor } from "./issue.js"; export type { Goal } from "./goal.js"; export type { Approval } from "./approval.js"; export type { CostEvent, CostSummary } from "./cost.js"; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 24cc7318..705fec22 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1,11 +1,23 @@ import type { IssuePriority, IssueStatus } from "../constants.js"; +export interface IssueAncestor { + id: string; + title: string; + description: string | null; + status: string; + priority: string; + assigneeAgentId: string | null; + projectId: string | null; + goalId: string | null; +} + export interface Issue { id: string; companyId: string; projectId: string | null; goalId: string | null; parentId: string | null; + ancestors?: IssueAncestor[]; title: string; description: string | null; status: IssueStatus; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 60fd54f6..da535c8a 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,6 +1,8 @@ +import { Link } from "react-router-dom"; import type { Issue } from "@paperclip/shared"; import { useQuery } from "@tanstack/react-query"; import { agentsApi } from "../api/agents"; +import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "./StatusIcon"; @@ -33,18 +35,35 @@ function priorityLabel(priority: string): string { export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); + const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && !!issue.projectId, + }); + const agentName = (id: string | null) => { if (!id || !agents) return null; const agent = agents.find((a) => a.id === id); return agent?.name ?? id.slice(0, 8); }; + const projectName = (id: string | null) => { + if (!id || !projects) return id?.slice(0, 8) ?? "None"; + const project = projects.find((p) => p.id === id); + return project?.name ?? id.slice(0, 8); + }; + + const assignee = issue.assigneeAgentId + ? agents?.find((a) => a.id === issue.assigneeAgentId) + : null; + return (
@@ -65,16 +84,45 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { - - {issue.assigneeAgentId ? agentName(issue.assigneeAgentId) : "Unassigned"} - + {assignee ? ( + + {assignee.name} + + ) : ( + Unassigned + )} - - - {issue.projectId ? issue.projectId.slice(0, 8) : "None"} - - + {issue.projectId && ( + + + {projectName(issue.projectId)} + + + )} + + {issue.parentId && ( + + + {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} + + + )} + + {issue.requestDepth > 0 && ( + + {issue.requestDepth} + + )}
diff --git a/ui/src/pages/Approvals.tsx b/ui/src/pages/Approvals.tsx index 03b8e91f..73f70ee0 100644 --- a/ui/src/pages/Approvals.tsx +++ b/ui/src/pages/Approvals.tsx @@ -1,26 +1,201 @@ -import { useState } from "react"; +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 { StatusBadge } from "../components/StatusBadge"; -import { Card, CardContent } from "@/components/ui/card"; +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 = { + hire_agent: "Hire Agent", + approve_ceo_strategy: "CEO Strategy", +}; + +const typeIcon: Record = { + hire_agent: UserPlus, + approve_ceo_strategy: Lightbulb, +}; + +function statusIcon(status: string) { + if (status === "approved") return ; + if (status === "rejected") return ; + if (status === "pending") return ; + return null; +} + +function HireAgentPayload({ payload }: { payload: Record }) { + return ( +
+
+ Name + {String(payload.name ?? "—")} +
+ {payload.role && ( +
+ Role + {String(payload.role)} +
+ )} + {payload.title && ( +
+ Title + {String(payload.title)} +
+ )} + {payload.capabilities && ( +
+ Capabilities + {String(payload.capabilities)} +
+ )} + {payload.adapterType && ( +
+ Adapter + + {String(payload.adapterType)} + +
+ )} +
+ ); +} + +function CeoStrategyPayload({ payload }: { payload: Record }) { + const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text; + return ( +
+ {payload.title && ( +
+ Title + {String(payload.title)} +
+ )} + {plan && ( +
+ {String(plan)} +
+ )} + {!plan && ( +
+          {JSON.stringify(payload, null, 2)}
+        
+ )} +
+ ); +} + +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 ( +
+ {/* Header */} +
+
+ +
+ {label} + {requesterName && ( + + requested by {requesterName} + + )} +
+
+
+ {statusIcon(approval.status)} + {approval.status} + · {timeAgo(approval.createdAt)} +
+
+ + {/* Payload */} + {approval.type === "hire_agent" ? ( + + ) : ( + + )} + + {/* Decision note */} + {approval.decisionNote && ( +
+ Note: {approval.decisionNote} +
+ )} + + {/* Actions */} + {approval.status === "pending" && ( +
+ + +
+ )} +
+ ); +} export function Approvals() { const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); + const [statusFilter, setStatusFilter] = useState("pending"); const [actionError, setActionError] = useState(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) => { @@ -31,6 +206,7 @@ export function Approvals() { const rejectMutation = useMutation({ mutationFn: (id: string) => approvalsApi.reject(id), onSuccess: () => { + setActionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); }, onError: (err) => { @@ -38,52 +214,69 @@ export function Approvals() { }, }); + 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

Select a company first.

; + return

Select a company first.

; } return ( -
-

Approvals

- {isLoading &&

Loading...

} - {error &&

{error.message}

} - {actionError &&

{actionError}

} - - {data && data.length === 0 &&

No approvals.

} - - {data && data.length > 0 && ( -
- {data.map((approval) => ( - - -
-
-

{approval.type}

-

{approval.id}

-
- -
- {approval.status === "pending" && ( -
- - -
+
+
+
+

Approvals

+ setStatusFilter(v as StatusFilter)}> + + + Pending + {pendingCount > 0 && ( + + {pendingCount} + )} - - + + All + + +
+
+ + {isLoading &&

Loading...

} + {error &&

{error.message}

} + {actionError &&

{actionError}

} + + {!isLoading && filtered.length === 0 && ( +
+ +

+ {statusFilter === "pending" ? "No pending approvals." : "No approvals yet."} +

+
+ )} + + {filtered.length > 0 && ( +
+ {filtered.map((approval) => ( + approveMutation.mutate(approval.id)} + onReject={() => rejectMutation.mutate(approval.id)} + isPending={approveMutation.isPending || rejectMutation.isPending} + /> ))}
)} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index ce4be2dd..1d1764f8 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { useCompany } from "../context/CompanyContext"; @@ -12,6 +12,7 @@ import { IssueProperties } from "../components/IssueProperties"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { Separator } from "@/components/ui/separator"; +import { ChevronRight } from "lucide-react"; export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); @@ -69,8 +70,31 @@ export function IssueDetail() { if (error) return

{error.message}

; if (!issue) return null; + // Ancestors are returned oldest-first from the server (root at end, immediate parent at start) + const ancestors = issue.ancestors ?? []; + return (
+ {/* Parent chain breadcrumb */} + {ancestors.length > 0 && ( + + )} +