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" && (
+
+
+ Approve
+
+
+ Reject
+
+
+ )}
+
+ );
+}
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" && (
-
- approveMutation.mutate(approval.id)}
- >
- Approve
-
- rejectMutation.mutate(approval.id)}
- >
- Reject
-
-
+
+
+
+
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 && (
+
+ {[...ancestors].reverse().map((ancestor, i) => (
+
+ {i > 0 && }
+
+ {ancestor.title}
+
+
+ ))}
+
+ {issue.title}
+
+ )}
+