import { useEffect, useMemo, useState } from "react"; import { useParams, Link, useNavigate } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { activityApi } from "../api/activity"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { usePanel } from "../context/PanelContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { relativeTime, cn } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; import { CommentThread } from "../components/CommentThread"; import { IssueProperties } from "../components/IssueProperties"; import { LiveRunWidget } from "../components/LiveRunWidget"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { StatusBadge } from "../components/StatusBadge"; import { Identity } from "../components/Identity"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { ChevronRight, MoreHorizontal, EyeOff, Hexagon } from "lucide-react"; import type { ActivityEvent } from "@paperclip/shared"; import type { Agent } from "@paperclip/shared"; const ACTION_LABELS: Record = { "issue.created": "created the issue", "issue.updated": "updated the issue", "issue.checked_out": "checked out the issue", "issue.released": "released the issue", "issue.comment_added": "added a comment", "issue.deleted": "deleted the issue", "agent.created": "created an agent", "agent.updated": "updated the agent", "agent.paused": "paused the agent", "agent.resumed": "resumed the agent", "agent.terminated": "terminated the agent", "heartbeat.invoked": "invoked a heartbeat", "heartbeat.cancelled": "cancelled a heartbeat", "approval.created": "requested approval", "approval.approved": "approved", "approval.rejected": "rejected", }; function humanizeValue(value: unknown): string { if (typeof value !== "string") return String(value ?? "none"); return value.replace(/_/g, " "); } function formatAction(action: string, details?: Record | null): string { if (action === "issue.updated" && details) { const previous = (details._previous ?? {}) as Record; const parts: string[] = []; if (details.status !== undefined) { const from = previous.status; parts.push( from ? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}` : `changed the status to ${humanizeValue(details.status)}` ); } if (details.priority !== undefined) { const from = previous.priority; parts.push( from ? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}` : `changed the priority to ${humanizeValue(details.priority)}` ); } if (details.assigneeAgentId !== undefined) { parts.push(details.assigneeAgentId ? "assigned the issue" : "unassigned the issue"); } if (details.title !== undefined) parts.push("updated the title"); if (details.description !== undefined) parts.push("updated the description"); if (parts.length > 0) return parts.join(", "); } return ACTION_LABELS[action] ?? action.replace(/[._]/g, " "); } function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map }) { const id = evt.actorId; if (evt.actorType === "agent") { const agent = agentMap.get(id); return ; } if (evt.actorType === "system") return ; return ; } export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); const { selectedCompanyId } = useCompany(); const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const [moreOpen, setMoreOpen] = useState(false); const [projectOpen, setProjectOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); const { data: issue, isLoading, error } = useQuery({ queryKey: queryKeys.issues.detail(issueId!), queryFn: () => issuesApi.get(issueId!), enabled: !!issueId, }); const { data: comments } = useQuery({ queryKey: queryKeys.issues.comments(issueId!), queryFn: () => issuesApi.listComments(issueId!), enabled: !!issueId, }); const { data: activity } = useQuery({ queryKey: queryKeys.issues.activity(issueId!), queryFn: () => activityApi.forIssue(issueId!), enabled: !!issueId, }); const { data: linkedRuns } = useQuery({ queryKey: queryKeys.issues.runs(issueId!), queryFn: () => activityApi.runsForIssue(issueId!), enabled: !!issueId, refetchInterval: 5000, }); const { data: linkedApprovals } = useQuery({ queryKey: queryKeys.issues.approvals(issueId!), queryFn: () => issuesApi.listApprovals(issueId!), enabled: !!issueId, }); 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, }); const agentMap = useMemo(() => { const map = new Map(); for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); const commentsWithRunMeta = useMemo(() => { const runMetaByCommentId = new Map(); const agentIdByRunId = new Map(); for (const run of linkedRuns ?? []) { agentIdByRunId.set(run.runId, run.agentId); } for (const evt of activity ?? []) { if (evt.action !== "issue.comment_added" || !evt.runId) continue; const details = evt.details ?? {}; const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; if (!commentId || runMetaByCommentId.has(commentId)) continue; runMetaByCommentId.set(commentId, { runId: evt.runId, runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, }); } return (comments ?? []).map((comment) => { const meta = runMetaByCommentId.get(comment.id); return meta ? { ...comment, ...meta } : comment; }); }, [activity, comments, linkedRuns]); const invalidateIssue = () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); } }; const updateIssue = useMutation({ mutationFn: (data: Record) => issuesApi.update(issueId!, data), onSuccess: invalidateIssue, }); const addComment = useMutation({ mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => issuesApi.addComment(issueId!, body, reopen), onSuccess: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); }, }); useEffect(() => { setBreadcrumbs([ { label: "Issues", href: "/issues" }, { label: issue?.title ?? issueId ?? "Issue" }, ]); }, [setBreadcrumbs, issue, issueId]); useEffect(() => { if (issue) { openPanel( updateIssue.mutate(data)} /> ); } return () => closePanel(); }, [issue]); // eslint-disable-line react-hooks/exhaustive-deps if (isLoading) return

Loading...

; 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 && ( )} {issue.hiddenAt && (
This issue is hidden
)}
updateIssue.mutate({ status })} /> updateIssue.mutate({ priority })} /> {issue.identifier ?? issue.id.slice(0, 8)} { setProjectOpen(open); if (!open) setProjectSearch(""); }}> setProjectSearch(e.target.value)} autoFocus /> {(projects ?? []) .filter((p) => { if (!projectSearch.trim()) return true; return p.name.toLowerCase().includes(projectSearch.toLowerCase()); }) .map((p) => ( )) }
updateIssue.mutate({ title })} as="h2" className="text-xl font-bold" /> updateIssue.mutate({ description })} as="p" className="text-sm text-muted-foreground" placeholder="Add a description..." multiline />
{ await addComment.mutateAsync({ body, reopen }); }} /> {linkedApprovals && linkedApprovals.length > 0 && ( <>

Linked Approvals

{linkedApprovals.map((approval) => (
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} {approval.id.slice(0, 8)}
{relativeTime(approval.createdAt)} ))}
)} {/* Linked Runs */} {linkedRuns && linkedRuns.length > 0 && ( <>

Linked Runs

{linkedRuns.map((run) => (
{run.runId.slice(0, 8)}
{relativeTime(run.createdAt)} ))}
)} {/* Activity Log */} {activity && activity.length > 0 && ( <>

Activity

{activity.slice(0, 20).map((evt) => (
{formatAction(evt.action, evt.details)} {relativeTime(evt.createdAt)}
))}
)}
); }