import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { activityApi } from "../api/activity"; import { heartbeatsApi } from "../api/heartbeats"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; 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 { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens } from "../lib/utils"; import { InlineEditor } from "../components/InlineEditor"; import { CommentThread } from "../components/CommentThread"; import { IssueProperties } from "../components/IssueProperties"; import { LiveRunWidget } from "../components/LiveRunWidget"; import type { MentionOption } from "../components/MarkdownEditor"; import { ScrollToBottom } from "../components/ScrollToBottom"; 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Activity as ActivityIcon, ChevronDown, ChevronRight, EyeOff, Hexagon, ListTree, MessageSquare, MoreHorizontal, Paperclip, SlidersHorizontal, Trash2, } from "lucide-react"; import type { ActivityEvent } from "@paperclipai/shared"; import type { Agent, IssueAttachment } from "@paperclipai/shared"; type CommentReassignment = { assigneeAgentId: string | null; assigneeUserId: string | null; }; 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.attachment_added": "added an attachment", "issue.attachment_removed": "removed an attachment", "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 asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } function usageNumber(usage: Record | null, ...keys: string[]) { if (!usage) return 0; for (const key of keys) { const value = usage[key]; if (typeof value === "number" && Number.isFinite(value)) return value; } return 0; } function truncate(text: string, max: number): string { if (text.length <= max) return text; return text.slice(0, max - 1) + "\u2026"; } 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 || details.assigneeUserId !== undefined) { parts.push( details.assigneeAgentId || details.assigneeUserId ? "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 ; if (evt.actorType === "user") return ; return ; } export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); const { selectedCompanyId } = useCompany(); const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const [moreOpen, setMoreOpen] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); const [detailTab, setDetailTab] = useState("comments"); const [secondaryOpen, setSecondaryOpen] = useState({ approvals: false, cost: false, }); const [attachmentError, setAttachmentError] = useState(null); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); 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: attachments } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!), enabled: !!issueId, }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), enabled: !!issueId, refetchInterval: 3000, }); const { data: activeRun } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), enabled: !!issueId, refetchInterval: 3000, }); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" }, [location.state], ); // Filter out runs already shown by the live widget to avoid duplication const timelineRuns = useMemo(() => { const liveIds = new Set(); for (const r of liveRuns ?? []) liveIds.add(r.id); if (activeRun) liveIds.add(activeRun.id); if (liveIds.size === 0) return linkedRuns ?? []; return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); }, [linkedRuns, liveRuns, activeRun]); const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const { data: projects } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const { orderedProjects } = useProjectOrder({ projects: projects ?? [], companyId: selectedCompanyId, userId: currentUserId, }); const agentMap = useMemo(() => { const map = new Map(); for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); const mentionOptions = useMemo(() => { const options: MentionOption[] = []; const activeAgents = [...(agents ?? [])] .filter((agent) => agent.status !== "terminated") .sort((a, b) => a.name.localeCompare(b.name)); for (const agent of activeAgents) { options.push({ id: `agent:${agent.id}`, name: agent.name, kind: "agent", }); } for (const project of orderedProjects) { options.push({ id: `project:${project.id}`, name: project.name, kind: "project", projectId: project.id, projectColor: project.color, }); } return options; }, [agents, orderedProjects]); const childIssues = useMemo(() => { if (!allIssues || !issue) return []; return allIssues .filter((i) => i.parentId === issue.id) .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [allIssues, issue]); const commentReassignOptions = useMemo(() => { const options: Array<{ id: string; label: string; searchText?: string }> = []; const activeAgents = [...(agents ?? [])] .filter((agent) => agent.status !== "terminated") .sort((a, b) => a.name.localeCompare(b.name)); for (const agent of activeAgents) { options.push({ id: `agent:${agent.id}`, label: agent.name }); } if (currentUserId) { const label = currentUserId === "local-board" ? "Board" : "Me (Board)"; options.push({ id: `user:${currentUserId}`, label }); } return options; }, [agents, currentUserId]); const currentAssigneeValue = useMemo(() => { if (issue?.assigneeAgentId) return `agent:${issue.assigneeAgentId}`; if (issue?.assigneeUserId) return `user:${issue.assigneeUserId}`; return ""; }, [issue?.assigneeAgentId, issue?.assigneeUserId]); 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 issueCostSummary = useMemo(() => { let input = 0; let output = 0; let cached = 0; let cost = 0; let hasCost = false; let hasTokens = false; for (const run of linkedRuns ?? []) { const usage = asRecord(run.usageJson); const result = asRecord(run.resultJson); const runInput = usageNumber(usage, "inputTokens", "input_tokens"); const runOutput = usageNumber(usage, "outputTokens", "output_tokens"); const runCached = usageNumber( usage, "cachedInputTokens", "cached_input_tokens", "cache_read_input_tokens", ); const runCost = usageNumber(usage, "costUsd", "cost_usd", "total_cost_usd") || usageNumber(result, "total_cost_usd", "cost_usd", "costUsd"); if (runCost > 0) hasCost = true; if (runInput + runOutput + runCached > 0) hasTokens = true; input += runInput; output += runOutput; cached += runCached; cost += runCost; } return { input, output, cached, cost, totalTokens: input + output, hasCost, hasTokens, }; }, [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.attachments(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); } }; const markIssueRead = useMutation({ mutationFn: (id: string) => issuesApi.markRead(id), onSuccess: () => { if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(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!) }); }, }); const addCommentAndReassign = useMutation({ mutationFn: ({ body, reopen, reassignment, }: { body: string; reopen?: boolean; reassignment: CommentReassignment; }) => issuesApi.update(issueId!, { comment: body, assigneeAgentId: reassignment.assigneeAgentId, assigneeUserId: reassignment.assigneeUserId, ...(reopen ? { status: "todo" } : {}), }), onSuccess: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); }, }); const uploadAttachment = useMutation({ mutationFn: async (file: File) => { if (!selectedCompanyId) throw new Error("No company selected"); return issuesApi.uploadAttachment(selectedCompanyId, issueId!, file); }, onSuccess: () => { setAttachmentError(null); queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) }); invalidateIssue(); }, onError: (err) => { setAttachmentError(err instanceof Error ? err.message : "Upload failed"); }, }); const deleteAttachment = useMutation({ mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId), onSuccess: () => { setAttachmentError(null); queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) }); invalidateIssue(); }, onError: (err) => { setAttachmentError(err instanceof Error ? err.message : "Delete failed"); }, }); useEffect(() => { const titleLabel = issue?.title ?? issueId ?? "Issue"; setBreadcrumbs([ sourceBreadcrumb, { label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel }, ]); }, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]); // Redirect to identifier-based URL if navigated via UUID useEffect(() => { if (issue?.identifier && issueId !== issue.identifier) { navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state }); } }, [issue, issueId, navigate, location.state]); useEffect(() => { if (!issue?.id) return; if (lastMarkedReadIssueIdRef.current === issue.id) return; lastMarkedReadIssueIdRef.current = issue.id; markIssueRead.mutate(issue.id); }, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps 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 ?? []; const handleFilePicked = async (evt: ChangeEvent) => { const file = evt.target.files?.[0]; if (!file) return; await uploadAttachment.mutateAsync(file); if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/"); 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)} {hasLiveRuns && ( Live )} {issue.projectId ? ( {(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)} ) : ( No project )} {(issue.labels ?? []).length > 0 && (
{(issue.labels ?? []).slice(0, 4).map((label) => ( {label.name} ))} {(issue.labels ?? []).length > 4 && ( +{(issue.labels ?? []).length - 4} )}
)}
updateIssue.mutate({ title })} as="h2" className="text-xl font-bold" /> updateIssue.mutate({ description })} as="p" className="text-[15px] leading-7 text-foreground" placeholder="Add a description..." multiline mentions={mentionOptions} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; }} />

Attachments

{attachmentError && (

{attachmentError}

)} {(!attachments || attachments.length === 0) ? (

No attachments yet.

) : (
{attachments.map((attachment) => (
{attachment.originalFilename ?? attachment.id}

{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB

{isImageAttachment(attachment) && ( {attachment.originalFilename )}
))}
)}
Comments Sub-issues Activity { if (reassignment) { await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); return; } await addComment.mutateAsync({ body, reopen }); }} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; }} onAttachImage={async (file) => { await uploadAttachment.mutateAsync(file); }} liveRunSlot={} /> {childIssues.length === 0 ? (

No sub-issues.

) : (
{childIssues.map((child) => (
{child.identifier ?? child.id.slice(0, 8)} {child.title}
{child.assigneeAgentId && (() => { const name = agentMap.get(child.assigneeAgentId)?.name; return name ? : {child.assigneeAgentId.slice(0, 8)}; })()} ))}
)}
{!activity || activity.length === 0 ? (

No activity yet.

) : (
{activity.slice(0, 20).map((evt) => (
{formatAction(evt.action, evt.details)} {relativeTime(evt.createdAt)}
))}
)}
{linkedApprovals && linkedApprovals.length > 0 && ( setSecondaryOpen((prev) => ({ ...prev, approvals: open }))} className="rounded-lg border border-border" > Linked Approvals ({linkedApprovals.length})
{linkedApprovals.map((approval) => (
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} {approval.id.slice(0, 8)}
{relativeTime(approval.createdAt)} ))}
)} {linkedRuns && linkedRuns.length > 0 && ( setSecondaryOpen((prev) => ({ ...prev, cost: open }))} className="rounded-lg border border-border" > Cost Summary
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
No cost data yet.
) : (
{issueCostSummary.hasCost && ( ${issueCostSummary.cost.toFixed(4)} )} {issueCostSummary.hasTokens && ( Tokens {formatTokens(issueCostSummary.totalTokens)} {issueCostSummary.cached > 0 ? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})` : ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`} )}
)}
)} {/* Mobile properties drawer */} Properties
updateIssue.mutate(data)} inline />
); }