import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; import { heartbeatsApi } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; import { extractIssueTimelineEvents } from "../lib/issue-timeline-events"; import { queryKeys } from "../lib/queryKeys"; import { hasLegacyIssueDetailQuery, createIssueDetailPath, readIssueDetailLocationState, readIssueDetailBreadcrumb, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, resolveInboxQuickArchiveKeyAction, } from "../lib/keyboardShortcuts"; import { applyOptimisticIssueFieldUpdate, applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueCommentUpdate, createOptimisticIssueComment, isQueuedIssueComment, matchesIssueRef, mergeIssueComments, upsertIssueComment, type IssueCommentReassignment, type OptimisticIssueComment, } from "../lib/optimistic-issue-comments"; import { removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { ApprovalCard } from "../components/ApprovalCard"; import { InlineEditor } from "../components/InlineEditor"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueProperties } from "../components/IssueProperties"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import { PageSkeleton } from "../components/PageSkeleton"; import type { MentionOption } from "../components/MarkdownEditor"; import { ImageGalleryModal } from "../components/ImageGalleryModal"; import { ScrollToBottom } from "../components/ScrollToBottom"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { Identity } from "../components/Identity"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Activity as ActivityIcon, Check, ChevronRight, Copy, EyeOff, Hexagon, ListTree, MessageSquare, MoreHorizontal, Paperclip, Repeat, SlidersHorizontal, Trash2, } from "lucide-react"; import { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, type ActivityEvent, type Agent, type FeedbackVote, type FeedbackVoteValue, type Issue, type IssueAttachment, type IssueComment, } from "@paperclipai/shared"; type CommentReassignment = IssueCommentReassignment; type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { runId?: string | null; runAgentId?: string | null; interruptedRunId?: string | null; queueState?: "queued"; queueTargetRunId?: string | null; }; const ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000; const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000; const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000; const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000; 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.feedback_vote_saved": "saved feedback on an AI output", "issue.attachment_added": "added an attachment", "issue.attachment_removed": "removed an attachment", "issue.document_created": "created a document", "issue.document_updated": "updated a document", "issue.document_deleted": "deleted a document", "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", }; const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; 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 isMarkdownFile(file: File) { const name = file.name.toLowerCase(); return ( name.endsWith(".md") || name.endsWith(".markdown") || file.type === "text/markdown" ); } function fileBaseName(filename: string) { return filename.replace(/\.[^.]+$/, ""); } function slugifyDocumentKey(input: string) { const slug = input .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return slug || "document"; } function titleizeFilename(input: string) { return input .split(/[-_ ]+/g) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } 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(", "); } if ( (action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") && details ) { const key = typeof details.key === "string" ? details.key : "document"; const title = typeof details.title === "string" && details.title ? ` (${details.title})` : ""; return `${ACTION_LABELS[action] ?? action} ${key}${title}`; } return ACTION_LABELS[action] ?? action.replace(/[._]/g, " "); } function mergeOptimisticFeedbackVote( previousVotes: FeedbackVote[] | undefined, nextVote: { issueId: string; targetType: "issue_comment" | "issue_document_revision"; targetId: string; vote: "up" | "down"; reason?: string; }, currentUserId: string | null, ): FeedbackVote[] { const now = new Date(); const existingVotes = previousVotes ?? []; const existingIndex = existingVotes.findIndex( (feedbackVote) => feedbackVote.targetType === nextVote.targetType && feedbackVote.targetId === nextVote.targetId && (!currentUserId || feedbackVote.authorUserId === currentUserId), ); if (existingIndex >= 0) { const existingVote = existingVotes[existingIndex]!; const updatedVote: FeedbackVote = { ...existingVote, vote: nextVote.vote, reason: nextVote.reason !== undefined ? nextVote.reason.trim() || null : existingVote.reason, updatedAt: now, }; const nextVotes = [...existingVotes]; nextVotes[existingIndex] = updatedVote; return nextVotes; } return [ ...existingVotes, { id: `optimistic:${nextVote.targetType}:${nextVote.targetId}`, companyId: "", issueId: nextVote.issueId, targetType: nextVote.targetType, targetId: nextVote.targetId, authorUserId: currentUserId ?? "current-user", vote: nextVote.vote, reason: nextVote.reason?.trim() || null, sharedWithLabs: false, sharedAt: null, consentVersion: null, redactionSummary: null, createdAt: now, updatedAt: now, }, ]; } 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 ; } function IssueSectionSkeleton({ titleWidth = "w-28", rows = 3, }: { titleWidth?: string; rows?: number; }) { return (
{Array.from({ length: rows }).map((_, index) => ( ))}
); } function IssueChatSkeleton() { return (
); } export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); const { pushToast } = useToast(); const [moreOpen, setMoreOpen] = useState(false); const [copied, setCopied] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); const [detailTab, setDetailTab] = useState("chat"); const [pendingApprovalAction, setPendingApprovalAction] = useState<{ approvalId: string; action: "approve" | "reject"; } | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [attachmentError, setAttachmentError] = useState(null); const [attachmentDragActive, setAttachmentDragActive] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false); const [galleryIndex, setGalleryIndex] = useState(0); const [optimisticComments, setOptimisticComments] = useState([]); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); const commentComposerRef = useRef(null); const { data: issue, isLoading, error } = useQuery({ queryKey: queryKeys.issues.detail(issueId!), queryFn: () => issuesApi.get(issueId!), enabled: !!issueId, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; const commentComposerDisabledReason = useMemo(() => { if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) { return null; } return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace); }, [issue?.currentExecutionWorkspace]); const { data: comments, isLoading: commentsLoading } = useQuery({ queryKey: queryKeys.issues.comments(issueId!), queryFn: () => issuesApi.listComments(issueId!), enabled: !!issueId, }); const { data: activity, isLoading: activityLoading } = useQuery({ queryKey: queryKeys.issues.activity(issueId!), queryFn: () => activityApi.forIssue(issueId!), enabled: !!issueId, }); const { data: linkedApprovals } = useQuery({ queryKey: queryKeys.issues.approvals(issueId!), queryFn: () => issuesApi.listApprovals(issueId!), enabled: !!issueId, }); const { data: attachments, isLoading: attachmentsLoading } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!), enabled: !!issueId, }); const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), enabled: !!issueId, refetchInterval: (query) => { const data = query.state.data as Array | undefined; return data && data.length > 0 ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS : IDLE_ISSUE_RUN_POLL_INTERVAL_MS; }, }); const { data: activeRun, isLoading: activeRunLoading } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), enabled: !!issueId, refetchInterval: (query) => query.state.data ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS : IDLE_ISSUE_RUN_POLL_INTERVAL_MS, }); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; const { data: linkedRuns } = useQuery({ queryKey: queryKeys.issues.runs(issueId!), queryFn: () => activityApi.runsForIssue(issueId!), enabled: !!issueId, refetchInterval: hasLiveRuns ? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS : IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS, }); const runningIssueRun = useMemo( () => ( activeRun?.status === "running" ? activeRun : (liveRuns ?? []).find((run) => run.status === "running") ?? null ), [activeRun, liveRuns], ); const resolvedIssueDetailState = useMemo( () => readIssueDetailLocationState(issueId, location.state, location.search), [issueId, location.state, location.search], ); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], ); // 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: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ queryKey: issue?.id && resolvedCompanyId ? queryKeys.issues.listByParent(resolvedCompanyId, issue.id) : ["issues", "parent", "pending"], queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }), enabled: !!resolvedCompanyId && !!issue?.id, }); 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 { data: feedbackVotes } = useQuery({ queryKey: queryKeys.issues.feedbackVotes(issueId!), queryFn: () => issuesApi.listFeedbackVotes(issueId!), enabled: !!issueId && !!currentUserId, }); const { data: instanceGeneralSettings } = useQuery({ queryKey: queryKeys.instance.generalSettings, queryFn: () => instanceSettingsApi.getGeneral(), enabled: !!issueId, retry: false, }); const keyboardShortcutsEnabled = instanceGeneralSettings?.keyboardShortcuts === true; const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt"; const { orderedProjects } = useProjectOrder({ projects: projects ?? [], companyId: selectedCompanyId, userId: currentUserId, }); const { slots: issuePluginDetailSlots } = usePluginSlots({ slotTypes: ["detailTab"], entityType: "issue", companyId: resolvedCompanyId, enabled: !!resolvedCompanyId, }); const issuePluginTabItems = useMemo( () => issuePluginDetailSlots.map((slot) => ({ value: `plugin:${slot.pluginKey}:${slot.id}`, label: slot.displayName, slot, })), [issuePluginDetailSlots], ); const activePluginTab = issuePluginTabItems.find((item) => item.value === detailTab) ?? null; 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", agentId: agent.id, agentIcon: agent.icon, }); } 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 resolvedProject = useMemo( () => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null), [issue?.project, issue?.projectId, orderedProjects], ); const childIssues = useMemo( () => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()), [rawChildIssues], ); const childIssuesPanelKey = useMemo( () => childIssues.map((child) => `${child.id}:${String(child.updatedAt)}`).join("|"), [childIssues], ); const issuePanelKey = issue ? `${issue.id}:${String(issue.updatedAt)}:${childIssuesPanelKey}` : ""; const openNewSubIssue = useCallback(() => { if (!issue) return; openNewIssue({ parentId: issue.id, parentIdentifier: issue.identifier ?? undefined, parentTitle: issue.title, projectId: issue.projectId ?? undefined, projectWorkspaceId: issue.projectWorkspaceId ?? undefined, goalId: issue.goalId ?? undefined, executionWorkspaceId: issue.executionWorkspaceId ?? undefined, executionWorkspaceMode: issue.executionWorkspaceId ? "reuse_existing" : issue.executionWorkspacePreference ?? undefined, parentExecutionWorkspaceLabel: issue.currentExecutionWorkspace?.name ?? issue.currentExecutionWorkspace?.branchName ?? issue.currentExecutionWorkspace?.cwd ?? issue.executionWorkspaceId ?? undefined, }); }, [ issue?.currentExecutionWorkspace?.branchName, issue?.currentExecutionWorkspace?.cwd, issue?.currentExecutionWorkspace?.name, issue?.executionWorkspaceId, issue?.executionWorkspacePreference, issue?.goalId, issue?.id, issue?.identifier, issue?.projectId, issue?.projectWorkspaceId, issue?.title, openNewIssue, ]); 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) { options.push({ id: `user:${currentUserId}`, label: "Me" }); } return options; }, [agents, currentUserId]); const actualAssigneeValue = useMemo( () => assigneeValueFromSelection(issue ?? {}), [issue], ); const suggestedAssigneeValue = useMemo( () => suggestedCommentAssigneeValue( issue ?? {}, mergeIssueComments(comments ?? [], optimisticComments), currentUserId, ), [issue, comments, optimisticComments, currentUserId], ); const threadComments = useMemo( () => mergeIssueComments(comments ?? [], optimisticComments), [comments, optimisticComments], ); const commentsWithRunMeta = useMemo(() => { const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; 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; const interruptedRunId = typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null; runMetaByCommentId.set(commentId, { runId: evt.runId, runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, interruptedRunId, }); } return threadComments.map((comment) => { const meta = runMetaByCommentId.get(comment.id); const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment }; if ( isQueuedIssueComment({ comment: nextComment, activeRunStartedAt, activeRunAgentId: runningIssueRun?.agentId ?? null, runId: meta?.runId ?? nextComment.runId ?? null, interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null, }) ) { return { ...nextComment, queueState: "queued" as const, queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null, }; } return nextComment; }); }, [activity, threadComments, linkedRuns, runningIssueRun]); const timelineEvents = useMemo( () => extractIssueTimelineEvents(activity), [activity], ); 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 = visibleRunCostUsd(usage, result); 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.feedbackVotes(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(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.listMineByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); } }; const invalidateIssueCollections = () => { if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) }); } }; const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable, data: Record) => { queryClient.setQueriesData( { queryKey: ["issues", "detail"] }, (cached) => (cached && matchesIssueRef(cached, refs) ? applyOptimisticIssueFieldUpdate(cached, data) : cached), ); if (!selectedCompanyId) return; queryClient.setQueryData( queryKeys.issues.list(selectedCompanyId), (cached) => applyOptimisticIssueFieldUpdateToCollection(cached, refs, data), ); }, [queryClient, selectedCompanyId]); const mergeIssueResponseIntoCaches = useCallback((refs: Iterable, nextIssue: Issue) => { queryClient.setQueriesData( { queryKey: ["issues", "detail"] }, (cached) => (cached && matchesIssueRef(cached, refs) ? { ...cached, ...nextIssue } : cached), ); if (!selectedCompanyId) return; queryClient.setQueryData( queryKeys.issues.list(selectedCompanyId), (cached) => cached?.map((item) => (matchesIssueRef(item, refs) ? { ...item, ...nextIssue } : item)), ); }, [queryClient, selectedCompanyId]); const markIssueRead = useMutation({ mutationFn: (id: string) => issuesApi.markRead(id), onSuccess: () => { if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(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), onMutate: async (data) => { await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); if (selectedCompanyId) { await queryClient.cancelQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); } const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); const issueRefs = new Set([issueId!]); if (previousIssue?.id) issueRefs.add(previousIssue.id); if (previousIssue?.identifier) issueRefs.add(previousIssue.identifier); const previousDetailQueries = queryClient .getQueriesData({ queryKey: ["issues", "detail"] }) .filter(([, cachedIssue]) => cachedIssue && matchesIssueRef(cachedIssue, issueRefs)); const previousList = selectedCompanyId ? queryClient.getQueryData(queryKeys.issues.list(selectedCompanyId)) : undefined; applyOptimisticIssueCacheUpdate(issueRefs, data); return { previousDetailQueries, previousList, selectedCompanyId }; }, onSuccess: ({ comment: _comment, ...nextIssue }) => { const issueRefs = new Set([issueId!, nextIssue.id]); if (nextIssue.identifier) issueRefs.add(nextIssue.identifier); mergeIssueResponseIntoCaches(issueRefs, nextIssue); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); invalidateIssueCollections(); }, onError: (err, _variables, context) => { for (const [queryKey, previousIssue] of context?.previousDetailQueries ?? []) { queryClient.setQueryData(queryKey, previousIssue); } if (context?.selectedCompanyId) { queryClient.setQueryData(queryKeys.issues.list(context.selectedCompanyId), context.previousList); } pushToast({ title: "Issue update failed", body: err instanceof Error ? err.message : "Unable to save issue changes", tone: "error", }); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); } }, }); const handleIssuePropertiesUpdate = useCallback((data: Record) => { updateIssue.mutate(data); }, [updateIssue.mutate]); const approvalDecision = useMutation({ mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => { if (action === "approve") { return approvalsApi.approve(approvalId); } return approvalsApi.reject(approvalId); }, onMutate: ({ approvalId, action }) => { setPendingApprovalAction({ approvalId, action }); }, onSuccess: (_approval, variables) => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) }); if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) }); } pushToast({ title: variables.action === "approve" ? "Approval approved" : "Approval rejected", tone: "success", }); }, onError: (err, variables) => { pushToast({ title: variables.action === "approve" ? "Approval failed" : "Rejection failed", body: err instanceof Error ? err.message : "Unable to update approval", tone: "error", }); }, onSettled: () => { setPendingApprovalAction(null); }, }); const addComment = useMutation({ mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) => issuesApi.addComment(issueId!, body, reopen, interrupt), onMutate: async ({ body, reopen, interrupt }) => { await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); const queuedComment = !interrupt && runningIssueRun; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, issueId: issue.id, body, authorUserId: currentUserId, clientStatus: queuedComment ? "queued" : "pending", queueTargetRunId: queuedComment ? runningIssueRun.id : null, }) : null; if (optimisticComment) { setOptimisticComments((current) => [...current, optimisticComment]); } if (previousIssue) { queryClient.setQueryData( queryKeys.issues.detail(issueId!), applyOptimisticIssueCommentUpdate(previousIssue, { reopen }), ); } return { optimisticCommentId: optimisticComment?.clientId ?? null, previousIssue, }; }, onSuccess: (comment, _variables, context) => { if (context?.optimisticCommentId) { setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } queryClient.setQueryData( queryKeys.issues.comments(issueId!), (current) => upsertIssueComment(current, comment), ); }, onError: (err, _variables, context) => { if (context?.optimisticCommentId) { setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } if (context?.previousIssue) { queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue); } pushToast({ title: "Comment failed", body: err instanceof Error ? err.message : "Unable to post comment", tone: "error", }); }, onSettled: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); }, }); const addCommentAndReassign = useMutation({ mutationFn: ({ body, reopen, interrupt, reassignment, }: { body: string; reopen?: boolean; interrupt?: boolean; reassignment: CommentReassignment; }) => issuesApi.update(issueId!, { comment: body, assigneeAgentId: reassignment.assigneeAgentId, assigneeUserId: reassignment.assigneeUserId, ...(reopen ? { status: "todo" } : {}), ...(interrupt ? { interrupt } : {}), }), onMutate: async ({ body, reopen, reassignment, interrupt }) => { await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) }); await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); const queuedComment = !interrupt && runningIssueRun; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, issueId: issue.id, body, authorUserId: currentUserId, clientStatus: queuedComment ? "queued" : "pending", queueTargetRunId: queuedComment ? runningIssueRun.id : null, }) : null; if (optimisticComment) { setOptimisticComments((current) => [...current, optimisticComment]); } if (previousIssue) { queryClient.setQueryData( queryKeys.issues.detail(issueId!), applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }), ); } return { optimisticCommentId: optimisticComment?.clientId ?? null, previousIssue, }; }, onSuccess: (result, _variables, context) => { if (context?.optimisticCommentId) { setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } const { comment, ...nextIssue } = result; queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue); if (comment) { queryClient.setQueryData( queryKeys.issues.comments(issueId!), (current) => upsertIssueComment(current, comment), ); } }, onError: (err, _variables, context) => { if (context?.optimisticCommentId) { setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } if (context?.previousIssue) { queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue); } pushToast({ title: "Comment failed", body: err instanceof Error ? err.message : "Unable to post comment", tone: "error", }); }, onSettled: () => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); }, }); const interruptQueuedComment = useMutation({ mutationFn: (runId: string) => heartbeatsApi.cancel(runId), onMutate: async (runId) => { await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) }); await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }); await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }); const previousRuns = queryClient.getQueryData(queryKeys.issues.runs(issueId!)); const previousLiveRuns = queryClient.getQueryData(queryKeys.issues.liveRuns(issueId!)); const previousActiveRun = queryClient.getQueryData(queryKeys.issues.activeRun(issueId!)); const liveRunList = previousLiveRuns ?? liveRuns ?? []; const cachedActiveRun = previousActiveRun ?? activeRun; const targetRun = cachedActiveRun?.id === runId ? cachedActiveRun : liveRunList?.find((run) => run.id === runId) ?? runningIssueRun ?? null; if (targetRun) { const interruptedAt = new Date().toISOString(); queryClient.setQueryData( queryKeys.issues.runs(issueId!), (current) => upsertInterruptedRun(current, targetRun, interruptedAt), ); } queryClient.setQueryData( queryKeys.issues.liveRuns(issueId!), (current: typeof liveRuns) => removeLiveRunById(current, runId), ); queryClient.setQueryData( queryKeys.issues.activeRun(issueId!), (current: typeof activeRun) => (current?.id === runId ? null : current), ); return { previousRuns, previousLiveRuns, previousActiveRun, }; }, onSuccess: () => { invalidateIssue(); pushToast({ title: "Interrupt requested", body: "The active run is stopping so queued comments can continue next.", tone: "success", }); }, onError: (err, _runId, context) => { queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns); queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns); queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun); pushToast({ title: "Interrupt failed", body: err instanceof Error ? err.message : "Unable to interrupt the active run", tone: "error", }); }, }); const feedbackVoteMutation = useMutation({ mutationFn: (variables: { targetType: "issue_comment" | "issue_document_revision"; targetId: string; vote: "up" | "down"; reason?: string; allowSharing?: boolean; sharingPreferenceAtSubmit: "allowed" | "not_allowed" | "prompt"; }) => issuesApi.upsertFeedbackVote(issueId!, { targetType: variables.targetType, targetId: variables.targetId, vote: variables.vote, ...(variables.reason ? { reason: variables.reason } : {}), ...(variables.allowSharing ? { allowSharing: true } : {}), }), onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) }); const previousVotes = queryClient.getQueryData( queryKeys.issues.feedbackVotes(issueId!), ); queryClient.setQueryData( queryKeys.issues.feedbackVotes(issueId!), mergeOptimisticFeedbackVote( previousVotes, { issueId: issueId!, targetType: variables.targetType, targetId: variables.targetId, vote: variables.vote, reason: variables.reason, }, currentUserId, ), ); return { previousVotes }; }, onSuccess: (_savedVote, variables) => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings }); pushToast({ title: variables.sharingPreferenceAtSubmit === "prompt" ? variables.allowSharing ? "Feedback saved. Future votes will share" : "Feedback saved. Future votes will stay local" : variables.allowSharing ? "Feedback saved and sharing enabled" : "Feedback saved", tone: "success", }); }, onError: (err, _variables, context) => { if (context?.previousVotes) { queryClient.setQueryData(queryKeys.issues.feedbackVotes(issueId!), context.previousVotes); } pushToast({ title: "Failed to save feedback", body: err instanceof Error ? err.message : "Unknown error", tone: "error", }); }, }); 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 importMarkdownDocument = useMutation({ mutationFn: async (file: File) => { const baseName = fileBaseName(file.name); const key = slugifyDocumentKey(baseName); const existing = (issue?.documentSummaries ?? []).find((doc) => doc.key === key) ?? null; const body = await file.text(); const inferredTitle = titleizeFilename(baseName); const nextTitle = existing?.title ?? inferredTitle ?? null; return issuesApi.upsertDocument(issueId!, key, { title: key === "plan" ? null : nextTitle, format: "markdown", body, baseRevisionId: existing?.latestRevisionId ?? null, }); }, onSuccess: () => { setAttachmentError(null); invalidateIssue(); }, onError: (err) => { setAttachmentError(err instanceof Error ? err.message : "Document import 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"); }, }); const archiveFromInbox = useMutation({ mutationFn: (id: string) => issuesApi.archiveFromInbox(id), onSuccess: () => { invalidateIssue(); navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true }); pushToast({ title: "Issue archived from inbox", tone: "success" }); }, onError: (err) => { pushToast({ title: "Archive failed", body: err instanceof Error ? err.message : "Unable to archive this issue from the inbox", tone: "error", }); }, }); const handleInterruptQueued = useCallback( async (runId: string) => { await interruptQueuedComment.mutateAsync(runId); }, [interruptQueuedComment.mutateAsync], ); const handleCommentImageUpload = useCallback( async (file: File) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; }, [uploadAttachment.mutateAsync], ); const handleCommentAttachImage = useCallback( async (file: File) => { await uploadAttachment.mutateAsync(file); }, [uploadAttachment.mutateAsync], ); const handleCommentAdd = useCallback( async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => { if (reassignment) { await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); return; } await addComment.mutateAsync({ body, reopen }); }, [addComment.mutateAsync, addCommentAndReassign.mutateAsync], ); const handleCommentVote = useCallback( async (commentId: string, vote: FeedbackVoteValue, options?: { reason?: string; allowSharing?: boolean }) => { await feedbackVoteMutation.mutateAsync({ targetType: "issue_comment", targetId: commentId, vote, reason: options?.reason, allowSharing: options?.allowSharing, sharingPreferenceAtSubmit: feedbackDataSharingPreference, }); }, [feedbackVoteMutation.mutateAsync, feedbackDataSharingPreference], ); 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(() => { const nextState = resolvedIssueDetailState ?? location.state; if (issue?.identifier && issueId !== issue.identifier) { rememberIssueDetailLocationState(issue.identifier, nextState, location.search); navigate(createIssueDetailPath(issue.identifier), { replace: true, state: nextState, }); return; } if (issueId && hasLegacyIssueDetailQuery(location.search)) { rememberIssueDetailLocationState(issueId, nextState, location.search); navigate(createIssueDetailPath(issueId), { replace: true, state: nextState, }); } }, [issue, issueId, navigate, location.state, location.search, resolvedIssueDetailState]); 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) { closePanel(); return; } openPanel( ); return () => closePanel(); }, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]); const goToInboxShortcutArmedRef = useRef(false); const goToInboxShortcutTimeoutRef = useRef(null); const canQuickArchiveFromInbox = keyboardShortcutsEnabled && !issue?.hiddenAt; useEffect(() => { if (!issue?.id || !canQuickArchiveFromInbox) return; const handleKeyDown = (event: KeyboardEvent) => { const action = resolveInboxQuickArchiveKeyAction({ armed: canQuickArchiveFromInbox, defaultPrevented: event.defaultPrevented, key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey, altKey: event.altKey, target: event.target, hasOpenDialog: hasBlockingShortcutDialog(document), }); if (action !== "archive") return; event.preventDefault(); if (!archiveFromInbox.isPending) { archiveFromInbox.mutate(issue.id); } }; document.addEventListener("keydown", handleKeyDown, true); return () => { document.removeEventListener("keydown", handleKeyDown, true); }; }, [archiveFromInbox, canQuickArchiveFromInbox, issue?.id]); useEffect(() => { if (!keyboardShortcutsEnabled) { goToInboxShortcutArmedRef.current = false; if (goToInboxShortcutTimeoutRef.current !== null) { window.clearTimeout(goToInboxShortcutTimeoutRef.current); goToInboxShortcutTimeoutRef.current = null; } return; } const clearArmTimeout = () => { if (goToInboxShortcutTimeoutRef.current !== null) { window.clearTimeout(goToInboxShortcutTimeoutRef.current); goToInboxShortcutTimeoutRef.current = null; } }; const disarm = () => { goToInboxShortcutArmedRef.current = false; clearArmTimeout(); }; const arm = () => { goToInboxShortcutArmedRef.current = true; clearArmTimeout(); goToInboxShortcutTimeoutRef.current = window.setTimeout(() => { goToInboxShortcutArmedRef.current = false; goToInboxShortcutTimeoutRef.current = null; }, 1200); }; const handlePointerDown = () => { disarm(); }; const handleFocusIn = (event: FocusEvent) => { if (event.target instanceof HTMLElement && event.target !== document.body) { disarm(); } }; const handleKeyDown = (event: KeyboardEvent) => { const action = resolveIssueDetailGoKeyAction({ armed: goToInboxShortcutArmedRef.current, defaultPrevented: event.defaultPrevented, key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey, altKey: event.altKey, target: event.target, hasOpenDialog: hasBlockingShortcutDialog(document), }); if (action === "ignore") return; if (action === "arm") { arm(); return; } disarm(); if (action === "navigate_inbox") { event.preventDefault(); event.stopPropagation(); navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox"); return; } if (action === "focus_comment") { event.preventDefault(); event.stopPropagation(); setDetailTab("chat"); setPendingCommentComposerFocusKey((current) => current + 1); } }; document.addEventListener("pointerdown", handlePointerDown, true); document.addEventListener("focusin", handleFocusIn, true); document.addEventListener("keydown", handleKeyDown, true); return () => { disarm(); document.removeEventListener("pointerdown", handlePointerDown, true); document.removeEventListener("focusin", handleFocusIn, true); document.removeEventListener("keydown", handleKeyDown, true); }; }, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]); useEffect(() => { if (pendingCommentComposerFocusKey === 0) return; if (detailTab !== "chat") return; commentComposerRef.current?.focus(); }, [detailTab, pendingCommentComposerFocusKey]); const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/"); const attachmentList = attachments ?? []; const imageAttachments = attachmentList.filter(isImageAttachment); const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a)); const handleChatImageClick = useCallback( (src: string) => { // Try exact contentPath match first let idx = imageAttachments.findIndex((a) => a.contentPath === src); if (idx < 0) { // Try matching by asset ID extracted from /api/assets/{assetId}/content URLs const assetMatch = src.match(/\/api\/assets\/([^/]+)\/content/); if (assetMatch) { idx = imageAttachments.findIndex((a) => a.assetId === assetMatch[1]); } } if (idx >= 0) { setGalleryIndex(idx); setGalleryOpen(true); } else { // Image not in attachment list — open in new tab window.open(src, "_blank"); } }, [imageAttachments], ); const copyIssueToClipboard = async () => { if (!issue) return; const decodeEntities = (text: string) => { const el = document.createElement("textarea"); el.innerHTML = text; return el.value; }; const title = decodeEntities(issue.title); const body = decodeEntities(issue.description ?? ""); const md = `# ${issue.identifier}: ${title}\n\n${body}`.trimEnd(); await navigator.clipboard.writeText(md); setCopied(true); pushToast({ title: "Copied to clipboard", tone: "success" }); setTimeout(() => setCopied(false), 2000); }; const issueChatInitialLoading = (commentsLoading && comments === undefined) || (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined) || (liveRunsLoading && liveRuns === undefined) || (activeRunLoading && activeRun === undefined); const activityInitialLoading = (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined); const attachmentsInitialLoading = attachmentsLoading && attachments === undefined; if (isLoading) return ; 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 files = evt.target.files; if (!files || files.length === 0) return; for (const file of Array.from(files)) { if (isMarkdownFile(file)) { await importMarkdownDocument.mutateAsync(file); } else { await uploadAttachment.mutateAsync(file); } } if (fileInputRef.current) { fileInputRef.current.value = ""; } }; const handleAttachmentDrop = async (evt: DragEvent) => { evt.preventDefault(); setAttachmentDragActive(false); const files = evt.dataTransfer.files; if (!files || files.length === 0) return; for (const file of Array.from(files)) { if (isMarkdownFile(file)) { await importMarkdownDocument.mutateAsync(file); } else { await uploadAttachment.mutateAsync(file); } } }; const hasAttachments = attachmentList.length > 0; const attachmentUploadButton = ( <> ); 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.originKind === "routine_execution" && issue.originId && ( Routine )} {issue.projectId ? ( {resolvedProject?.name ?? issue.project?.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.mutateAsync({ title })} as="h2" className="text-xl font-bold" /> updateIssue.mutateAsync({ 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; }} onDropFile={async (file) => { await uploadAttachment.mutateAsync(file); }} />
{(childIssuesLoading || childIssues.length > 0) && (

Sub-issues

{childIssuesLoading ? ( ) : (
{childIssues.map((child) => ( rememberIssueDetailLocationState( child.identifier ?? child.id, resolvedIssueDetailState ?? location.state, location.search, )} className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors" >
{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)}; })()} ))}
)}
)} { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; }} onVote={async (revisionId, vote, options) => { await feedbackVoteMutation.mutateAsync({ targetType: "issue_document_revision", targetId: revisionId, vote, reason: options?.reason, allowSharing: options?.allowSharing, sharingPreferenceAtSubmit: feedbackDataSharingPreference, }); }} extraActions={ <> {!hasAttachments && attachmentUploadButton} {childIssues.length === 0 && ( )} } /> {attachmentsInitialLoading ? ( ) : hasAttachments ? (
{ evt.preventDefault(); setAttachmentDragActive(true); }} onDragOver={(evt) => { evt.preventDefault(); setAttachmentDragActive(true); }} onDragLeave={(evt) => { if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return; setAttachmentDragActive(false); }} onDrop={(evt) => void handleAttachmentDrop(evt)} >

Attachments

{attachmentUploadButton}
{attachmentError && (

{attachmentError}

)} {imageAttachments.length > 0 && (
{imageAttachments.map((attachment) => (
{ const idx = imageAttachments.findIndex((a) => a.id === attachment.id); setGalleryIndex(idx >= 0 ? idx : 0); setGalleryOpen(true); }} > {attachment.originalFilename
{confirmDeleteId === attachment.id ? (
e.stopPropagation()} >

Delete?

) : ( )}
))}
)} {nonImageAttachments.length > 0 && (
{nonImageAttachments.map((attachment) => (
{attachment.originalFilename ?? attachment.id}

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

))}
)}
) : null} updateIssue.mutate(data)} /> Chat Activity {issuePluginTabItems.map((item) => ( {item.label} ))} {issueChatInitialLoading ? ( ) : ( { await interruptQueuedComment.mutateAsync(runningIssueRun.id); } : undefined} onImageClick={handleChatImageClick} /> )} {activityInitialLoading ? ( ) : ( <> {linkedApprovals && linkedApprovals.length > 0 && (
{linkedApprovals.map((approval) => ( approvalDecision.mutate({ approvalId: approval.id, action: "approve" })} onReject={() => approvalDecision.mutate({ approvalId: approval.id, action: "reject" })} detailLink={`/approvals/${approval.id}`} isPending={pendingApprovalAction?.approvalId === approval.id} pendingAction={ pendingApprovalAction?.approvalId === approval.id ? pendingApprovalAction.action : null } /> ))}
)} {linkedRuns && linkedRuns.length > 0 && (
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)})`} )}
)}
)} {!activity || activity.length === 0 ? (

No activity yet.

) : (
{activity.slice(0, 20).map((evt) => (
{formatAction(evt.action, evt.details)} {relativeTime(evt.createdAt)}
))}
)} )}
{activePluginTab && ( )}
{/* Mobile properties drawer */} Properties
updateIssue.mutate(data)} inline />
); }