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 <noreply@anthropic.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
@@ -65,16 +84,45 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Assignee">
|
||||
<span className="text-sm">
|
||||
{issue.assigneeAgentId ? agentName(issue.assigneeAgentId) : "Unassigned"}
|
||||
</span>
|
||||
{assignee ? (
|
||||
<Link
|
||||
to={`/agents/${assignee.id}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{assignee.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Project">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{issue.projectId ? issue.projectId.slice(0, 8) : "None"}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
{issue.projectId && (
|
||||
<PropertyRow label="Project">
|
||||
<Link
|
||||
to={`/projects/${issue.projectId}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{projectName(issue.projectId)}
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.parentId && (
|
||||
<PropertyRow label="Parent">
|
||||
<Link
|
||||
to={`/issues/${issue.parentId}`}
|
||||
className="text-sm hover:underline"
|
||||
>
|
||||
{issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)}
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
{issue.requestDepth > 0 && (
|
||||
<PropertyRow label="Depth">
|
||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -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<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 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>
|
||||
{payload.role && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Role</span>
|
||||
<span>{String(payload.role)}</span>
|
||||
</div>
|
||||
)}
|
||||
{payload.title && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Title</span>
|
||||
<span>{String(payload.title)}</span>
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
{payload.title && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Title</span>
|
||||
<span className="font-medium">{String(payload.title)}</span>
|
||||
</div>
|
||||
)}
|
||||
{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) => {
|
||||
@@ -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 <p className="text-muted-foreground">Select a company first.</p>;
|
||||
return <p className="text-sm text-muted-foreground">Select a company first.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Approvals</h2>
|
||||
{isLoading && <p className="text-muted-foreground">Loading...</p>}
|
||||
{error && <p className="text-destructive">{error.message}</p>}
|
||||
{actionError && <p className="text-destructive">{actionError}</p>}
|
||||
|
||||
{data && data.length === 0 && <p className="text-muted-foreground">No approvals.</p>}
|
||||
|
||||
{data && data.length > 0 && (
|
||||
<div className="grid gap-3">
|
||||
{data.map((approval) => (
|
||||
<Card key={approval.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{approval.type}</p>
|
||||
<p className="text-xs text-muted-foreground">{approval.id}</p>
|
||||
</div>
|
||||
<StatusBadge status={approval.status} />
|
||||
</div>
|
||||
{approval.status === "pending" && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-green-700 text-green-400 hover:bg-green-900/50"
|
||||
onClick={() => approveMutation.mutate(approval.id)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => rejectMutation.mutate(approval.id)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-semibold">Approvals</h2>
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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 <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!issue) return null;
|
||||
|
||||
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
|
||||
const ancestors = issue.ancestors ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Parent chain breadcrumb */}
|
||||
{ancestors.length > 0 && (
|
||||
<nav className="flex items-center gap-1 text-xs text-muted-foreground flex-wrap">
|
||||
{[...ancestors].reverse().map((ancestor, i) => (
|
||||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Link
|
||||
to={`/issues/${ancestor.id}`}
|
||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||
title={ancestor.title}
|
||||
>
|
||||
{ancestor.title}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
<span className="text-foreground/60 truncate max-w-[200px]">{issue.title}</span>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon
|
||||
|
||||
Reference in New Issue
Block a user