import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode, type Ref } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { approvalsApi } from "../api/approvals"; import { activityApi, type RunForIssue } from "../api/activity"; import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats"; import { instanceSettingsApi } from "../api/instanceSettings"; import { accessApi, type CurrentBoardAccess } from "../api/access"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { useDialogActions } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useSidebar } from "../context/SidebarContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members"; import { extractIssueTimelineEvents } from "../lib/issue-timeline-events"; import { queryKeys } from "../lib/queryKeys"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { hasLegacyIssueDetailQuery, createIssueDetailPath, readIssueDetailLocationState, readIssueDetailBreadcrumb, readIssueDetailHeaderSeed, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; import { getIssueDetailQueryOptions } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, resolveInboxQuickArchiveKeyAction, } from "../lib/keyboardShortcuts"; import { applyOptimisticIssueFieldUpdate, applyOptimisticIssueFieldUpdateToCollection, applyOptimisticIssueCommentUpdate, applyLocalQueuedIssueCommentState, createOptimisticIssueComment, flattenIssueCommentPages, getNextIssueCommentPageParam, isQueuedIssueComment, loadRemainingIssueCommentPages, matchesIssueRef, mergeIssueComments, removeIssueCommentFromPages, shouldAutoloadOlderIssueComments, takeOptimisticIssueComment, upsertIssueCommentInPages, type IssueCommentReassignment, type OptimisticIssueComment, } from "../lib/optimistic-issue-comments"; import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { ApprovalCard } from "../components/ApprovalCard"; import { InlineEditor } from "../components/InlineEditor"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation"; import { IssuesList } from "../components/IssuesList"; import { AgentIcon } from "../components/AgentIconPicker"; import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary"; import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel"; import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard"; import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard"; import { IssueProperties } from "../components/IssueProperties"; import { IssueRunLedger } from "../components/IssueRunLedger"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; 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 { ProductivityReviewBadge } from "../components/ProductivityReviewBadge"; 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; import { formatIssueActivityAction } from "@/lib/activity-format"; import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"; import { buildIssueSiblingNavigation, shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues"; import { filterIssueDescendants } from "../lib/issue-tree"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION, SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION, successfulRunHandoffActivityTone, } from "../lib/successful-run-handoff"; import { hasAssignedBacklogBlocker } from "../lib/issue-blockers"; import { Activity as ActivityIcon, AlertTriangle, Archive, ArrowLeft, Check, ChevronRight, Copy, Eye, EyeOff, Flag, Hexagon, ListTree, MessageSquare, MoreHorizontal, MoreVertical, PauseCircle, Paperclip, PlayCircle, Plus, Repeat, SlidersHorizontal, Trash2, XCircle, } from "lucide-react"; import { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, type AskUserQuestionsAnswer, type AskUserQuestionsInteraction, type ActivityEvent, type Agent, type FeedbackVote, type Issue, type IssueAttachment, type IssueComment, type IssueWorkMode, type IssueThreadInteraction, type RequestConfirmationInteraction, type SuggestTasksInteraction, type IssueTreeControlMode, } from "@paperclipai/shared"; type CommentReassignment = IssueCommentReassignment; type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction; type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled"; type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { runId?: string | null; runAgentId?: string | null; interruptedRunId?: string | null; queueState?: "queued"; queueTargetRunId?: string | null; queueReason?: "hold" | "active_run" | "other"; }; const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; const ISSUE_COMMENT_PAGE_SIZE = 50; const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3; const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10; const TREE_CONTROL_MODE_LABEL: Record = { pause: "Pause subtree", resume: "Resume subtree", cancel: "Cancel subtree", restore: "Restore subtree", }; const LEAF_WORK_CONTROL_MODE_LABEL: Partial> = { pause: "Pause work", resume: "Resume work", }; const TREE_CONTROL_MODE_HELP_TEXT: Record = { pause: "Pause active execution in this issue subtree until an explicit resume.", resume: "Release the active subtree pause hold so held work can continue.", cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.", restore: "Restore issues cancelled by this subtree operation so work can resume.", }; const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial> = { pause: "Pause active execution on this issue until an explicit resume.", resume: "Release the active pause hold so this issue can continue.", }; function issueTreeControlLabel(mode: IssueTreeControlMode, scope: "leaf" | "subtree") { return scope === "leaf" ? LEAF_WORK_CONTROL_MODE_LABEL[mode] ?? TREE_CONTROL_MODE_LABEL[mode] : TREE_CONTROL_MODE_LABEL[mode]; } function issueTreeControlHelpText(mode: IssueTreeControlMode, scope: "leaf" | "subtree") { return scope === "leaf" ? LEAF_WORK_CONTROL_MODE_HELP_TEXT[mode] ?? TREE_CONTROL_MODE_HELP_TEXT[mode] : TREE_CONTROL_MODE_HELP_TEXT[mode]; } function treeControlPreviewErrorCopy(error: unknown): string { if (error instanceof ApiError) { if (error.status === 403) return "Only board users can preview subtree controls."; if (error.status === 409) return "Preview is stale because subtree hold state changed. Retry to refresh."; if (error.status === 422) return "This subtree action is currently invalid for the selected issues."; } return error instanceof Error ? error.message : "Unable to load preview."; } export function canBoardResolveRecoveryAction( companyId: string | null | undefined, boardAccess: CurrentBoardAccess | undefined, ) { if (!companyId || !boardAccess) return false; if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true; if (!boardAccess.memberships || boardAccess.memberships.length === 0) { return boardAccess.companyIds.includes(companyId); } const membership = boardAccess.memberships.find( (item) => item.companyId === companyId && item.status === "active", ); if (!membership) return false; return membership.membershipRole !== "viewer" && membership.membershipRole !== null; } function resolveRunningIssueRun( activeRun: ActiveRunForIssue | null | undefined, liveRuns: readonly LiveRunForIssue[] | undefined, ) { return activeRun?.status === "running" ? activeRun : (liveRuns ?? []).find((run) => run.status === "running") ?? null; } function dedupeLiveRunsById(liveRuns: readonly LiveRunForIssue[]) { const seen = new Set(); return liveRuns.filter((run) => { if (seen.has(run.id)) return false; seen.add(run.id); return true; }); } function readIssueRunStateFromCache(queryClient: QueryClient, issueId: string) { const liveRuns = queryClient.getQueryData( queryKeys.issues.liveRuns(issueId), ); const activeRun = queryClient.getQueryData( queryKeys.issues.activeRun(issueId), ); return { liveRuns, activeRun, runningIssueRun: resolveRunningIssueRun(activeRun, liveRuns), }; } 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 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, userProfileMap }: { evt: ActivityEvent; agentMap: Map; userProfileMap?: Map }) { const id = evt.actorId; if (evt.actorType === "agent") { const agent = agentMap.get(id); return ; } if (evt.actorType === "system") return ; if (evt.actorType === "user") { const profile = userProfileMap?.get(id); return ; } return ; } function IssueSectionSkeleton({ titleWidth = "w-28", rows = 3, }: { titleWidth?: string; rows?: number; }) { return (
{Array.from({ length: rows }).map((_, index) => ( ))}
); } function IssueChatSkeleton() { return (
); } function IssueDetailLoadingState({ headerSeed, }: { headerSeed: ReturnType; }) { const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null; return (
{headerSeed ? ( <> {identifier ? ( {identifier} ) : null} {headerSeed.originKind === "routine_execution" && headerSeed.originId ? ( Routine ) : null} {headerSeed.projectId ? ( {headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)} ) : ( No project )} ) : ( <> )}
{headerSeed ? ( <>

{headerSeed.title}

) : ( <> )}
); } interface InboxMobileToolbarProps { backHref: string; issueId: string | undefined; issueHidden: boolean; onArchive: () => void; archivePending: boolean; onCopy: () => void; onProperties: () => void; onHide: () => void; } function InboxMobileToolbar({ backHref, issueId: issueIdProp, issueHidden, onArchive, archivePending, onCopy, onProperties, onHide, }: InboxMobileToolbarProps) { const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(false); return (
{issueIdProp && !issueHidden && ( )} {issueIdProp && ( )}
); } type IssueDetailChatTabProps = { issueId: string; companyId: string; projectId: string | null; issueStatus: Issue["status"]; issueWorkMode: IssueWorkMode; executionRunId: string | null; blockedBy: Issue["blockedBy"]; blockerAttention: Issue["blockerAttention"] | null; successfulRunHandoff: Issue["successfulRunHandoff"] | null; recoveryAction: Issue["activeRecoveryAction"]; onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void; canFalsePositiveRecoveryAction?: boolean; legacyRecoverySourceIssue?: { identifier: string | null; href: string; title?: string | null; } | null; comments: IssueDetailComment[]; locallyQueuedCommentRunIds: ReadonlyMap; interactions: IssueThreadInteraction[]; hasOlderComments: boolean; commentsLoadingOlder: boolean; onLoadOlderComments: () => void; onRefreshLatestComments: () => Promise | void; onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; composerRef: Ref; footer?: ReactNode; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt"; feedbackTermsUrl: string | null; agentMap: Map; currentUserId: string | null; userLabelMap: ReadonlyMap | null; userProfileMap: ReadonlyMap | null; draftKey: string; reassignOptions: Array<{ id: string; label: string; searchText?: string }>; currentAssigneeValue: string; suggestedAssigneeValue: string; mentions: MentionOption[]; composerDisabledReason: string | null; composerHint: string | null; queuedCommentReason: "hold" | "active_run" | "other"; onVote: ( commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; onImageUpload: (file: File) => Promise; onAttachImage: (file: File) => Promise; onInterruptQueued: (runId: string) => Promise; onPauseWorkRun?: (runId: string) => Promise; onCancelQueued: (commentId: string) => void; interruptingQueuedRunId: string | null; pausingWorkRunId: string | null; onImageClick: (src: string) => void; onAcceptInteraction: ( interaction: ActionableIssueThreadInteraction, selectedClientKeys?: string[], ) => Promise; onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise; onSubmitInteractionAnswers: ( interaction: IssueThreadInteraction, answers: AskUserQuestionsAnswer[], ) => Promise; onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise; assigneeUserId: string | null; onResumeFromBacklog?: () => Promise | void; resumeFromBacklogPending?: boolean; }; const IssueDetailChatTab = memo(function IssueDetailChatTab({ issueId, companyId, projectId, issueWorkMode, issueStatus, executionRunId, blockedBy, blockerAttention, successfulRunHandoff, recoveryAction, onResolveRecoveryAction, canFalsePositiveRecoveryAction, legacyRecoverySourceIssue, comments, locallyQueuedCommentRunIds, interactions, hasOlderComments, commentsLoadingOlder, onLoadOlderComments, onRefreshLatestComments, onWorkModeChange, composerRef, footer, feedbackVotes, feedbackDataSharingPreference, feedbackTermsUrl, agentMap, currentUserId, userLabelMap, userProfileMap, draftKey, reassignOptions, currentAssigneeValue, suggestedAssigneeValue, mentions, composerDisabledReason, composerHint, queuedCommentReason, onVote, onAdd, onImageUpload, onAttachImage, onInterruptQueued, onPauseWorkRun, onCancelQueued, interruptingQueuedRunId, pausingWorkRunId, onImageClick, onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, onCancelInteraction, assigneeUserId, onResumeFromBacklog, resumeFromBacklogPending, }: IssueDetailChatTabProps) { const { data: activity } = useQuery({ queryKey: queryKeys.issues.activity(issueId), queryFn: () => activityApi.forIssue(issueId), placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), refetchInterval: 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const resolvedLiveRuns = liveRuns ?? []; const liveRunCount = resolvedLiveRuns.length; const { data: activeRun = null } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId), queryFn: () => heartbeatsApi.activeRunForIssue(issueId), enabled: !!executionRunId || issueStatus === "in_progress", refetchInterval: liveRunCount > 0 ? false : 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const resolvedActiveRun = useMemo( () => resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun), [activeRun, executionRunId, issueStatus], ); const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun; const { data: linkedRuns } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), refetchInterval: hasLiveRuns ? 5000 : false, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const resolvedActivity = activity ?? []; const resolvedLinkedRuns = linkedRuns ?? []; const runningIssueRun = useMemo( () => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns), [resolvedActiveRun, resolvedLiveRuns], ); const liveRunIds = useMemo(() => { const ids = new Set(); for (const run of resolvedLiveRuns) ids.add(run.id); if (resolvedActiveRun) ids.add(resolvedActiveRun.id); return ids; }, [resolvedActiveRun, resolvedLiveRuns]); const timelineRuns = useMemo(() => { const historicalRuns = liveRunIds.size === 0 ? resolvedLinkedRuns : resolvedLinkedRuns.filter((run) => !liveRunIds.has(run.runId)); return historicalRuns.map((run) => ({ ...run, adapterType: run.adapterType, hasStoredOutput: (run.logBytes ?? 0) > 0, })); }, [liveRunIds, resolvedLinkedRuns]); const commentsWithRunMeta = useMemo(() => { const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; const runMetaByCommentId = new Map(); const followUpCommentIds = new Set(); const agentIdByRunId = new Map(); for (const run of resolvedLinkedRuns) { agentIdByRunId.set(run.runId, run.agentId); } for (const evt of resolvedActivity) { 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, }); } for (const evt of resolvedActivity) { if (evt.action !== "issue.comment_added") continue; const details = evt.details ?? {}; const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; if (!commentId) continue; if (details["followUpRequested"] === true || details["resumeIntent"] === true) { followUpCommentIds.add(commentId); } } return comments.map((comment) => { const meta = runMetaByCommentId.get(comment.id); const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment }; if (followUpCommentIds.has(comment.id)) { nextComment.followUpRequested = true; } const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null; const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, { queuedTargetRunId, targetRunIsLive: queuedTargetRunId ? liveRunIds.has(queuedTargetRunId) : false, runningRunId: runningIssueRun?.id ?? null, }); if (locallyQueuedComment !== nextComment) { return locallyQueuedComment; } if ( isQueuedIssueComment({ comment: nextComment, activeRunStartedAt, activeRunAgentId: runningIssueRun?.agentId ?? null, activeRunCommentId: runningIssueRun?.contextCommentId ?? null, activeRunWakeCommentId: runningIssueRun?.contextWakeCommentId ?? 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, queueReason: queuedCommentReason, }; } return nextComment; }); }, [ comments, liveRunIds, locallyQueuedCommentRunIds, queuedCommentReason, resolvedActivity, resolvedLinkedRuns, runningIssueRun, ]); const timelineEvents = useMemo( () => extractIssueTimelineEvents(resolvedActivity), [resolvedActivity], ); return (
{hasOlderComments ? (
) : null} onSubmitInteractionAnswers(interaction, answers) } onCancelInteraction={onCancelInteraction} issueWorkMode={issueWorkMode} onWorkModeChange={onWorkModeChange} onCancelRun={runningIssueRun && onPauseWorkRun ? async () => { await onPauseWorkRun(runningIssueRun.id); } : undefined} onImageClick={onImageClick} onRefreshLatestComments={onRefreshLatestComments} assigneeUserId={assigneeUserId} onResumeFromBacklog={onResumeFromBacklog} resumeFromBacklogPending={resumeFromBacklogPending} footer={footer} />
); }); type IssueDetailActivityTabProps = { issue: Issue; issueId: string; companyId: string; issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: Map; hasLiveRuns: boolean; currentUserId: string | null; userProfileMap: Map; pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null; onApprovalAction: (approvalId: string, action: "approve" | "reject") => void; onCheckMonitorNow: () => void; checkingMonitorNow: boolean; handoffFocusSignal?: number; }; function IssueDetailActivityTab({ issue, issueId, companyId, issueStatus, childIssues, agentMap, hasLiveRuns, currentUserId, userProfileMap, pendingApprovalAction, onApprovalAction, onCheckMonitorNow, checkingMonitorNow, handoffFocusSignal = 0, }: IssueDetailActivityTabProps) { const { data: activity, isLoading: activityLoading } = useQuery({ queryKey: queryKeys.issues.activity(issueId), queryFn: () => activityApi.forIssue(issueId), placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), placeholderData: keepPreviousDataForSameQueryTail(issueId), }); const { data: linkedApprovals } = useQuery({ queryKey: queryKeys.issues.approvals(issueId), queryFn: () => issuesApi.listApprovals(issueId), placeholderData: keepPreviousDataForSameQueryTail>>(issueId), }); const { data: continuationHandoff } = useQuery({ queryKey: queryKeys.issues.document(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), queryFn: async () => { try { return await issuesApi.getDocument(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY); } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } }, retry: false, placeholderData: keepPreviousDataForSameQueryTail> | null>( issueId, ), }); const { data: issueTreeCostSummary } = useQuery({ queryKey: queryKeys.issues.costSummary(issueId), queryFn: () => issuesApi.getCostSummary(issueId), placeholderData: keepPreviousDataForSameQueryTail>>(issueId), }); const initialLoading = (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined); const issueCostSummary = useMemo(() => { let input = 0; let output = 0; let cached = 0; let cost = 0; let runtimeMs = 0; let runCount = 0; let hasCost = false; let hasTokens = false; const nowMs = Date.now(); 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; if (run.startedAt) { const startMs = new Date(run.startedAt).getTime(); const endMs = run.finishedAt ? new Date(run.finishedAt).getTime() : nowMs; if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs) { runtimeMs += endMs - startMs; runCount += 1; } } } return { input, output, cached, cost, totalTokens: input + output, hasCost, hasTokens, runtimeMs, runCount, hasRuntime: runtimeMs > 0, }; }, [linkedRuns]); const issueTreeCostTokens = (issueTreeCostSummary?.inputTokens ?? 0) + (issueTreeCostSummary?.outputTokens ?? 0); const hasIssueTreeCost = !!issueTreeCostSummary && (issueTreeCostSummary.costCents > 0 || issueTreeCostTokens > 0 || issueTreeCostSummary.cachedInputTokens > 0 || issueTreeCostSummary.runtimeMs > 0 || issueTreeCostSummary.issueCount > 1); const shouldShowCostSummary = (linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost; if (initialLoading) { return ; } return ( <> {shouldShowCostSummary && (
Cost Summary
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !hasIssueTreeCost ? (
No cost data yet.
) : (
This issue {issueCostSummary.hasCost ? ( ${issueCostSummary.cost.toFixed(4)} ) : null} {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)})`} ) : null} {issueCostSummary.hasRuntime ? ( Runtime {formatDurationMs(issueCostSummary.runtimeMs)} {` (${issueCostSummary.runCount} run${issueCostSummary.runCount === 1 ? "" : "s"})`} ) : null} {!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !issueCostSummary.hasRuntime ? ( No direct cost data. ) : null}
{hasIssueTreeCost && issueTreeCostSummary ? (
Including sub-issues {(issueTreeCostSummary.costCents / 100).toLocaleString(undefined, { style: "currency", currency: "USD", minimumFractionDigits: 4, maximumFractionDigits: 4, })} Tokens {formatTokens(issueTreeCostTokens)} {issueTreeCostSummary.cachedInputTokens > 0 ? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})` : ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`} {issueTreeCostSummary.runCount > 0 ? ( Runtime {formatDurationMs(issueTreeCostSummary.runtimeMs)} {` (${issueTreeCostSummary.runCount} run${issueTreeCostSummary.runCount === 1 ? "" : "s"})`} ) : null} {issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}
) : null}
)}
)}
{ const tone = successfulRunHandoffActivityTone(evt.action); const isHandoffWarning = evt.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION || evt.action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION; return (
{isHandoffWarning ? ( ) : null} {formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })} {relativeTime(evt.createdAt)}
); }} />
{linkedApprovals && linkedApprovals.length > 0 && (
{linkedApprovals.map((approval) => ( onApprovalAction(approval.id, "approve")} onReject={() => onApprovalAction(approval.id, "reject")} detailLink={`/approvals/${approval.id}`} isPending={pendingApprovalAction?.approvalId === approval.id} pendingAction={ pendingApprovalAction?.approvalId === approval.id ? pendingApprovalAction.action : null } /> ))}
)} ); } export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialogActions(); const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel(); const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs(); const queryClient = useQueryClient(); const navigate = useNavigate(); const navigationType = useNavigationType(); const location = useLocation(); const { pushToast } = useToastActions(); const { isMobile } = useSidebar(); const [moreOpen, setMoreOpen] = useState(false); const [copied, setCopied] = useState(false); const [mobilePropsOpen, setMobilePropsOpen] = useState(false); const [detailTab, setDetailTab] = useState("chat"); const [handoffFocusSignal, setHandoffFocusSignal] = useState(0); 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 [treeControlOpen, setTreeControlOpen] = useState(false); const [treeControlMode, setTreeControlMode] = useState("pause"); const [treeControlReason, setTreeControlReason] = useState(""); const [treeControlWakeAgentsOnResume, setTreeControlWakeAgentsOnResume] = useState(false); const [treeControlCancelConfirmed, setTreeControlCancelConfirmed] = useState(false); const [optimisticComments, setOptimisticComments] = useState([]); const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState>(() => new Map()); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); const commentComposerRef = useRef(null); const cancelledQueuedOptimisticCommentIdsRef = useRef(new Set()); const resolvedIssueDetailState = useMemo( () => readIssueDetailLocationState(issueId, location.state, location.search), [issueId, location.state, location.search], ); const issueHeaderSeed = useMemo( () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), [location.state, resolvedIssueDetailState], ); const { data: issue, isLoading, error } = useQuery({ ...getIssueDetailQueryOptions(queryClient, issueId!, { placeholderIssue: issueHeaderSeed ? { id: issueHeaderSeed.id, identifier: issueHeaderSeed.identifier, } : null, }), 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: commentPages, isLoading: commentsLoading, isFetchingNextPage: commentsLoadingOlder, hasNextPage: hasOlderComments, fetchNextPage: fetchOlderComments, refetch: refetchComments, } = useInfiniteQuery({ queryKey: queryKeys.issues.comments(issueId!), queryFn: ({ pageParam }) => issuesApi.listComments(issueId!, { order: "desc", limit: ISSUE_COMMENT_PAGE_SIZE, ...(pageParam ? { after: pageParam } : {}), }), enabled: !!issueId, initialPageParam: null as string | null, getNextPageParam: (lastPage) => getNextIssueCommentPageParam(lastPage, ISSUE_COMMENT_PAGE_SIZE), placeholderData: keepPreviousDataForSameQueryTail>(issueId ?? "pending"), }); const comments = useMemo( () => flattenIssueCommentPages(commentPages?.pages), [commentPages?.pages], ); const shouldPrefetchOlderComments = useMemo( () => shouldAutoloadOlderIssueComments({ activeDetailTab: detailTab, hasOlderComments: hasOlderComments ?? false, loadedCommentCount: comments.length, initialPageLoading: commentsLoading, olderPageLoading: commentsLoadingOlder, autoLoadLimit: ISSUE_COMMENT_AUTOLOAD_LIMIT, }), [comments.length, commentsLoading, commentsLoadingOlder, detailTab, hasOlderComments], ); const { data: interactions = [] } = useQuery({ queryKey: queryKeys.issues.interactions(issueId!), queryFn: () => issuesApi.listInteractions(issueId!), enabled: !!issueId, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); const { data: attachments, isLoading: attachmentsLoading } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!), enabled: !!issueId, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); const { data: liveRunCount = 0 } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), enabled: !!issueId, refetchInterval: 3000, select: (runs) => runs.length, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); const { data: hasActiveRun = false } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), refetchInterval: liveRunCount > 0 ? false : 3000, select: (run) => !!run, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun; const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun; useEffect(() => { if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) { setLocallyQueuedCommentRunIds(new Map()); } }, [hasLiveRuns, locallyQueuedCommentRunIds.size]); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], ); const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ queryKey: issue?.id && resolvedCompanyId ? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id) : ["issues", "parent", "pending"], queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id, includeBlockedBy: true }), enabled: !!resolvedCompanyId && !!issue?.id, placeholderData: keepPreviousDataForSameQueryTail(issue?.id ?? "pending"), }); const { data: rawSiblingIssues = [], isLoading: siblingIssuesLoading, isError: siblingIssuesError, } = useQuery({ queryKey: issue?.parentId && resolvedCompanyId ? queryKeys.issues.listByParent(resolvedCompanyId, issue.parentId) : ["issues", "siblings", "pending"], queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.parentId!, includeBlockedBy: true }), enabled: !!resolvedCompanyId && !!issue?.parentId, }); const { data: companyLiveRuns } = useQuery({ queryKey: resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"], queryFn: () => heartbeatsApi.liveRunsForCompany(resolvedCompanyId!), enabled: !!resolvedCompanyId, refetchInterval: 5000, placeholderData: keepPreviousDataForSameQueryTail(resolvedCompanyId ?? "pending"), }); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const { data: companyMembers } = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), queryFn: () => accessApi.listUserDirectory(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: boardAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), enabled: !!session?.user?.id, retry: false, }); const canManageTreeControl = Boolean( selectedCompanyId && boardAccess?.companyIds?.includes(selectedCompanyId), ); const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction(selectedCompanyId, boardAccess); 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 { data: treeControlPreview, isFetching: treeControlPreviewLoading, error: treeControlPreviewError, refetch: refetchTreeControlPreview, } = useQuery({ queryKey: [ "issues", "tree-control-preview", issueId ?? "pending", treeControlMode, ], queryFn: () => issuesApi.previewTreeControl(issueId!, { mode: treeControlMode, releasePolicy: { strategy: "manual", }, }), enabled: treeControlOpen && !!issueId && canManageTreeControl, staleTime: 0, retry: false, }); const { data: treeControlState } = useQuery({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"], queryFn: () => issuesApi.getTreeControlState(issueId!), enabled: !!issueId && canManageTreeControl, retry: false, }); const { data: activeRootPauseHolds = [] } = useQuery({ queryKey: ["issues", "tree-holds", issueId ?? "pending", "active-pause-with-members"], queryFn: () => issuesApi.listTreeHolds(issueId!, { status: "active", mode: "pause", includeMembers: true, }), enabled: !!issueId && treeControlState?.activePauseHold?.isRoot === true, }); const { data: activeCancelHolds = [] } = useQuery({ queryKey: ["issues", "tree-holds", issueId ?? "pending", "active-cancel"], queryFn: () => issuesApi.listTreeHolds(issueId!, { status: "active", mode: "cancel", }), enabled: !!issueId && canManageTreeControl, }); const agentMap = useMemo(() => { const map = new Map(); for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); const userProfileMap = useMemo( () => buildCompanyUserProfileMap(companyMembers?.users), [companyMembers?.users], ); const userLabelMap = useMemo( () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], ); const mentionOptions = useMemo(() => { return buildMarkdownMentionOptions({ agents, projects: orderedProjects, members: companyMembers?.users, }); }, [agents, companyMembers?.users, 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( () => { const descendants = issue?.id ? filterIssueDescendants(issue.id, rawChildIssues) : rawChildIssues; return [...descendants].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [issue?.id, rawChildIssues], ); const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]); const issuePanelKey = useMemo( () => buildIssuePropertiesPanelKey(issue ?? null, childIssues), [childIssues, issue], ); const panelIssue = useMemo( () => issue ?? null, [issue?.id, issuePanelKey], ); const panelChildIssues = useMemo( () => childIssues, [issuePanelKey], ); const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length); const siblingNavigation = useMemo( () => issue && !childIssuesLoading && !siblingIssuesLoading && !siblingIssuesError ? buildIssueSiblingNavigation(issue, rawSiblingIssues, childIssues) : null, [childIssues, childIssuesLoading, issue, rawSiblingIssues, siblingIssuesError, siblingIssuesLoading], ); const openNewSubIssue = useCallback(() => { if (!issue) return; openNewIssue(buildSubIssueDefaultsForViewer(issue, currentUserId)); }, [ currentUserId, issue, openNewIssue, ]); const commentReassignOptions = useMemo(() => { const options: Array<{ id: string; label: string; searchText?: string }> = []; options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] })); 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, companyMembers?.users, 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 breadcrumbTitle = issue?.title ?? issueId ?? "Issue"; const issueCacheRefs = useMemo(() => { const refs = new Set(); if (issueId) refs.add(issueId); if (issue?.id) refs.add(issue.id); if (issue?.identifier) refs.add(issue.identifier); return [...refs]; }, [issue?.id, issue?.identifier, issueId]); const invalidateIssueDetail = useCallback(() => { for (const ref of issueCacheRefs) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) }); } }, [issueCacheRefs, queryClient]); const invalidateIssueThreadLazily = useCallback(() => { for (const ref of issueCacheRefs) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), refetchType: "inactive" }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), refetchType: "inactive" }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), refetchType: "inactive" }); } }, [issueCacheRefs, queryClient]); const invalidateIssueRunState = useCallback(() => { for (const ref of issueCacheRefs) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) }); } }, [issueCacheRefs, queryClient]); const removeCommentFromCache = useCallback((commentId: string) => { queryClient.setQueryData | undefined>( queryKeys.issues.comments(issueId!), (current) => { if (!current) return current; return { ...current, pages: removeIssueCommentFromPages(current.pages, commentId), }; }, ); }, [issueId, queryClient]); const restoreQueuedCommentDraft = useCallback((body: string) => { commentComposerRef.current?.restoreDraft(body); }, []); const invalidateIssueCollections = useCallback(() => { 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) }); } }, [queryClient, selectedCompanyId]); const upsertInteractionInCache = useCallback((interaction: IssueThreadInteraction) => { queryClient.setQueryData( queryKeys.issues.interactions(issueId!), (current) => { const existing = current ?? []; const next = existing.filter((entry) => entry.id !== interaction.id); next.push(interaction); next.sort((left, right) => { const createdAtDelta = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(); return createdAtDelta === 0 ? left.id.localeCompare(right.id) : createdAtDelta; }); return next; }, ); }, [issueId, queryClient]); 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 resolveRecoveryAction = useMutation({ mutationFn: (data: { actionId?: string; outcome: ResolveRecoveryActionOutcome; sourceIssueStatus: "done" | "in_review" | "blocked"; resolutionNote?: string | null; }) => issuesApi.resolveRecoveryAction(issueId!, data), onSuccess: ({ issue: 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) => { pushToast({ title: "Recovery resolution failed", body: err instanceof Error ? err.message : "Unable to resolve recovery action", tone: "error", }); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); } }, }); const executeTreeControl = useMutation({ mutationFn: async () => { if (treeControlMode === "resume") { const pauseHoldId = treeControlState?.activePauseHold?.holdId; if (!pauseHoldId) { throw new Error("No active subtree pause hold is available to resume."); } const releasedHold = await issuesApi.releaseTreeHold(issueId!, pauseHoldId, { reason: treeControlReason.trim() || null, metadata: { wakeAgents: treeControlWakeAgentsOnResume, }, }); return { kind: "release" as const, hold: releasedHold }; } const created = await issuesApi.createTreeHold(issueId!, { mode: treeControlMode, reason: treeControlReason.trim() || null, releasePolicy: { strategy: "manual", ...(treeControlMode === "pause" ? { note: treeControlScope === "leaf" ? "leaf_pause" : "full_pause" } : {}), }, ...(treeControlMode === "restore" ? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } } : {}), }); return { kind: "create" as const, hold: created.hold, preview: created.preview }; }, onSuccess: async (result) => { const modeLabel = issueTreeControlLabel(result.hold.mode, treeControlScope); const cancelCount = result.preview?.totals.activeRuns ?? 0; pushToast({ title: result.kind === "release" ? treeControlScope === "leaf" ? "Work resumed" : "Subtree resumed" : result.hold.mode === "pause" ? treeControlScope === "leaf" ? "Work paused" : "Subtree paused" : `${modeLabel} applied`, body: result.kind === "release" ? (result.hold.releaseReason?.trim() || (treeControlScope === "leaf" ? "Active issue pause released." : "Active subtree pause released.")) : result.hold.mode === "pause" ? treeControlScope === "leaf" ? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.` : `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.` : result.hold.reason?.trim() ? result.hold.reason : "Subtree control applied.", }); setTreeControlOpen(false); setTreeControlReason(""); setTreeControlWakeAgentsOnResume(false); setTreeControlCancelConfirmed(false); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }), queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"] }), queryClient.invalidateQueries({ queryKey: ["issues", "tree-holds", issueId ?? "pending"] }), queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-preview", issueId ?? "pending"] }), ]); if (selectedCompanyId) { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }), ...(issue?.id ? [ queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByDescendantRoot(selectedCompanyId, issue.id) }), ] : []), ]); } }, onError: (err) => { pushToast({ title: "Unable to apply subtree control", body: err instanceof Error ? err.message : "Please try again.", tone: "error", }); }, }); const pauseIssueWorkRun = useMutation({ mutationFn: async ({ runId, scope }: { runId: string; scope: "leaf" | "subtree" }) => { const created = await issuesApi.createTreeHold(issueId!, { mode: "pause", reason: "Paused from active run controls.", releasePolicy: { strategy: "manual", note: scope === "leaf" ? "leaf_pause" : "full_pause" }, metadata: { source: "issue_active_run_control", runId }, }); return created; }, onSuccess: async (result) => { const cancelCount = result.preview?.totals.activeRuns ?? 0; pushToast({ title: "Work paused", body: cancelCount > 0 ? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.` : "Work paused. This issue is held until resume.", tone: "success", }); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }), queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"] }), queryClient.invalidateQueries({ queryKey: ["issues", "tree-holds", issueId ?? "pending"] }), queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-preview", issueId ?? "pending"] }), ]); invalidateIssueCollections(); }, onError: (err) => { pushToast({ title: "Unable to pause work", body: err instanceof Error ? err.message : "Please try again.", tone: "error", }); }, }); const handleIssuePropertiesUpdate = useCallback((data: Record) => { updateIssue.mutate(data); }, [updateIssue.mutate]); const updateChildIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), onSuccess: () => { if (resolvedCompanyId) { queryClient.invalidateQueries({ queryKey: ["issues", resolvedCompanyId] }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(resolvedCompanyId) }); } }, onError: (err) => { pushToast({ title: "Issue update failed", body: err instanceof Error ? err.message : "Unable to save sub-issue changes", tone: "error", }); }, }); const handleChildIssueUpdate = useCallback((id: string, data: Record) => { updateChildIssue.mutate({ id, data }); }, [updateChildIssue]); const checkIssueMonitorNow = useMutation({ mutationFn: () => issuesApi.checkMonitorNow(issueId!), onSuccess: () => { invalidateIssueDetail(); invalidateIssueRunState(); invalidateIssueCollections(); pushToast({ title: "Monitor check queued", tone: "success", }); }, onError: (err) => { pushToast({ title: "Monitor check failed", body: err instanceof Error ? err.message : "Unable to trigger the monitor right now", tone: "error", }); }, }); 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) => { invalidateIssueDetail(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) }); invalidateIssueCollections(); 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 ? readIssueRunStateFromCache(queryClient, issueId!).runningIssueRun : null; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, issueId: issue.id, body, authorUserId: currentUserId, clientStatus: queuedComment ? "queued" : "pending", queueTargetRunId: queuedComment?.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, queuedCommentTargetRunId: queuedComment?.id ?? null, previousIssue, }; }, onSuccess: async (comment, _variables, context) => { if (context?.optimisticCommentId) { setOptimisticComments((current) => current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } if (context?.optimisticCommentId && cancelledQueuedOptimisticCommentIdsRef.current.has(context.optimisticCommentId)) { cancelledQueuedOptimisticCommentIdsRef.current.delete(context.optimisticCommentId); try { await issuesApi.cancelComment(issueId!, comment.id); invalidateIssueDetail(); invalidateIssueThreadLazily(); invalidateIssueCollections(); return; } catch (err) { pushToast({ title: "Cancel failed", body: err instanceof Error ? err.message : "Unable to cancel the queued comment", tone: "error", }); } } if (context?.queuedCommentTargetRunId) { setLocallyQueuedCommentRunIds((current) => { const next = new Map(current); next.set(comment.id, context.queuedCommentTargetRunId!); return next; }); } queryClient.setQueryData>( queryKeys.issues.comments(issueId!), (current) => current ? { ...current, pages: upsertIssueCommentInPages(current.pages, comment), } : { pageParams: [null], pages: upsertIssueCommentInPages(undefined, 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: (_result, _error, variables) => { invalidateIssueThreadLazily(); if (variables.interrupt) { invalidateIssueRunState(); } if (variables.reopen) { invalidateIssueCollections(); } }, }); const acceptInteraction = useMutation({ mutationFn: ({ interaction, selectedClientKeys, }: { interaction: ActionableIssueThreadInteraction; selectedClientKeys?: string[]; }) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }), onSuccess: (interaction) => { upsertInteractionInCache(interaction); if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(resolvedCompanyId, issue.id) }); } invalidateIssueDetail(); invalidateIssueCollections(); const createdCount = interaction.kind === "suggest_tasks" ? interaction.result?.createdTasks?.length ?? 0 : 0; const skippedCount = interaction.kind === "suggest_tasks" ? interaction.result?.skippedClientKeys?.length ?? 0 : 0; pushToast({ title: interaction.kind === "request_confirmation" ? "Request confirmed" : skippedCount > 0 ? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}` : "Suggested tasks accepted", tone: "success", }); }, onError: (err) => { pushToast({ title: "Accept failed", body: err instanceof Error ? err.message : "Unable to accept the suggested tasks", tone: "error", }); }, }); const rejectInteraction = useMutation({ mutationFn: ({ interaction, reason }: { interaction: ActionableIssueThreadInteraction; reason?: string }) => issuesApi.rejectInteraction(issueId!, interaction.id, reason), onSuccess: (interaction) => { upsertInteractionInCache(interaction); invalidateIssueDetail(); invalidateIssueCollections(); pushToast({ title: interaction.kind === "request_confirmation" ? "Request declined" : "Suggestion rejected", tone: "success", }); }, onError: (err) => { pushToast({ title: "Reject failed", body: err instanceof Error ? err.message : "Unable to reject the suggested tasks", tone: "error", }); }, }); const answerInteraction = useMutation({ mutationFn: ({ interaction, answers, }: { interaction: IssueThreadInteraction; answers: AskUserQuestionsAnswer[]; }) => issuesApi.respondToInteraction(issueId!, interaction.id, { answers }), onSuccess: (interaction) => { upsertInteractionInCache(interaction); invalidateIssueDetail(); invalidateIssueCollections(); pushToast({ title: "Answers submitted", tone: "success", }); }, onError: (err) => { pushToast({ title: "Submit failed", body: err instanceof Error ? err.message : "Unable to submit answers", tone: "error", }); }, }); const cancelInteraction = useMutation({ mutationFn: ({ interaction }: { interaction: AskUserQuestionsInteraction }) => issuesApi.cancelInteraction(issueId!, interaction.id), onSuccess: (interaction) => { upsertInteractionInCache(interaction); invalidateIssueDetail(); invalidateIssueCollections(); pushToast({ title: "Question cancelled", tone: "success", }); }, onError: (err) => { pushToast({ title: "Cancel failed", body: err instanceof Error ? err.message : "Unable to cancel the question", tone: "error", }); }, }); 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 ? readIssueRunStateFromCache(queryClient, issueId!).runningIssueRun : null; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, issueId: issue.id, body, authorUserId: currentUserId, clientStatus: queuedComment ? "queued" : "pending", queueTargetRunId: queuedComment?.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, queuedCommentTargetRunId: queuedComment?.id ?? null, previousIssue, }; }, onSuccess: async (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 && context?.optimisticCommentId && cancelledQueuedOptimisticCommentIdsRef.current.has(context.optimisticCommentId)) { cancelledQueuedOptimisticCommentIdsRef.current.delete(context.optimisticCommentId); try { await issuesApi.cancelComment(issueId!, comment.id); invalidateIssueDetail(); invalidateIssueThreadLazily(); invalidateIssueCollections(); return; } catch (err) { pushToast({ title: "Cancel failed", body: err instanceof Error ? err.message : "Unable to cancel the queued comment", tone: "error", }); } } if (comment && context?.queuedCommentTargetRunId) { setLocallyQueuedCommentRunIds((current) => { const next = new Map(current); next.set(comment.id, context.queuedCommentTargetRunId!); return next; }); } if (comment) { queryClient.setQueryData>( queryKeys.issues.comments(issueId!), (current) => current ? { ...current, pages: upsertIssueCommentInPages(current.pages, comment), } : { pageParams: [null], pages: upsertIssueCommentInPages(undefined, 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: (_result, _error, variables) => { invalidateIssueThreadLazily(); if (variables.interrupt) { invalidateIssueRunState(); } invalidateIssueCollections(); }, }); const interruptQueuedComment = useMutation({ mutationFn: (runId: string) => heartbeatsApi.cancel(runId), onMutate: async (runId) => { await Promise.all(issueCacheRefs.flatMap((ref) => [ queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(ref) }), queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(ref) }), queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(ref) }), queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(ref) }), ])); const previousRunState = issueCacheRefs.map((ref) => ({ ref, runs: queryClient.getQueryData(queryKeys.issues.runs(ref)), liveRuns: queryClient.getQueryData(queryKeys.issues.liveRuns(ref)), activeRun: queryClient.getQueryData(queryKeys.issues.activeRun(ref)), issue: queryClient.getQueryData(queryKeys.issues.detail(ref)), })); const previousLocalQueuedCommentRunIds = locallyQueuedCommentRunIds; const cachedActiveRun = previousRunState.find((state) => state.activeRun?.id === runId)?.activeRun ?? previousRunState.find((state) => state.activeRun)?.activeRun ?? null; const liveRunList = dedupeLiveRunsById(previousRunState.flatMap((state) => state.liveRuns ?? [])); const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList); const targetRun = cachedActiveRun?.id === runId ? cachedActiveRun : liveRunList?.find((run) => run.id === runId) ?? runningIssueRun ?? null; if (targetRun) { const interruptedAt = new Date().toISOString(); for (const ref of issueCacheRefs) { queryClient.setQueryData( queryKeys.issues.runs(ref), (current) => upsertInterruptedRun(current, targetRun, interruptedAt), ); } } for (const ref of issueCacheRefs) { queryClient.setQueryData( queryKeys.issues.liveRuns(ref), (current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId), ); queryClient.setQueryData( queryKeys.issues.activeRun(ref), (current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current), ); queryClient.setQueryData( queryKeys.issues.detail(ref), (current: Issue | undefined) => clearIssueExecutionRun(current, runId), ); } setLocallyQueuedCommentRunIds((current) => { const next = new Map([...current].filter(([, targetRunId]) => targetRunId !== runId)); return next.size === current.size ? current : next; }); return { previousRunState, previousLocalQueuedCommentRunIds, }; }, onSuccess: () => { invalidateIssueDetail(); invalidateIssueRunState(); pushToast({ title: "Interrupt requested", body: "The active run is stopping so queued comments can continue next.", tone: "success", }); }, onError: (err, _runId, context) => { for (const state of context?.previousRunState ?? []) { queryClient.setQueryData(queryKeys.issues.runs(state.ref), state.runs); queryClient.setQueryData(queryKeys.issues.liveRuns(state.ref), state.liveRuns); queryClient.setQueryData(queryKeys.issues.activeRun(state.ref), state.activeRun); queryClient.setQueryData(queryKeys.issues.detail(state.ref), state.issue); } if (context?.previousLocalQueuedCommentRunIds) { setLocallyQueuedCommentRunIds(context.previousLocalQueuedCommentRunIds); } pushToast({ title: "Interrupt failed", body: err instanceof Error ? err.message : "Unable to interrupt the active run", tone: "error", }); }, }); const cancelQueuedComment = useMutation({ mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.cancelComment(issueId!, commentId), onSuccess: (comment) => { setLocallyQueuedCommentRunIds((current) => { if (!current.has(comment.id)) return current; const next = new Map(current); next.delete(comment.id); return next; }); removeCommentFromCache(comment.id); restoreQueuedCommentDraft(comment.body); invalidateIssueDetail(); invalidateIssueThreadLazily(); invalidateIssueCollections(); pushToast({ title: "Queued comment canceled", body: "The queued message was restored to the composer.", tone: "success", }); }, onError: (err) => { pushToast({ title: "Cancel failed", body: err instanceof Error ? err.message : "Unable to cancel the queued comment", tone: "error", }); }, }); const handleCancelQueuedComment = useCallback((commentId: string) => { if (commentId.startsWith("optimistic-")) { cancelledQueuedOptimisticCommentIdsRef.current.add(commentId); let cancelledCommentBody: string | null = null; setOptimisticComments((current) => { const next = takeOptimisticIssueComment(current, commentId); cancelledCommentBody = next.comment?.body ?? null; return next.comments; }); if (cancelledCommentBody) { restoreQueuedCommentDraft(cancelledCommentBody); pushToast({ title: "Queued comment canceled", body: "The queued message was restored to the composer.", tone: "success", }); } return; } void cancelQueuedComment.mutateAsync({ commentId }); }, [cancelQueuedComment, restoreQueuedCommentDraft, pushToast]); 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!) }); invalidateIssueDetail(); }, 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); invalidateIssueDetail(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) }); }, 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!) }); invalidateIssueDetail(); }, onError: (err) => { setAttachmentError(err instanceof Error ? err.message : "Delete failed"); }, }); const archiveFromInbox = useMutation({ mutationFn: (id: string) => issuesApi.archiveFromInbox(id), onSuccess: () => { invalidateIssueCollections(); 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", }); }, }); useEffect(() => { setBreadcrumbs([ sourceBreadcrumb, { label: hasLiveRuns ? `🔵 ${breadcrumbTitle}` : breadcrumbTitle }, ]); }, [ breadcrumbTitle, hasLiveRuns, setBreadcrumbs, sourceBreadcrumb.href, sourceBreadcrumb.label, ]); const isFromInbox = resolvedIssueDetailState?.issueDetailSource === "inbox"; // Scroll to top on forward navigation (PUSH/REPLACE) so issue doesn't // inherit the inbox/issues-list scroll position on mobile. useEffect(() => { if (navigationType === "POP") return; window.scrollTo({ top: 0, left: 0, behavior: "auto" }); const main = document.getElementById("main-content"); if (main) main.scrollTop = 0; }, [issueId, navigationType]); // 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 (!panelIssue) { closePanel(); return; } openPanel( ); return () => closePanel(); }, [ closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel, panelChildIssues, panelIssue, ]); 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(() => { const hash = location.hash; if (!hash.startsWith("#document-")) return; const documentKey = decodeURIComponent(hash.slice("#document-".length)); if (documentKey !== ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY) return; setDetailTab("activity"); setHandoffFocusSignal((current) => current + 1); }, [location.hash]); 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); }; // Gmail-style mobile toolbar when viewing an issue from inbox. // Callbacks are stored in a ref so the effect deps stay stable and // don't trigger an infinite render loop (useMutation results and // non-memoized functions change identity every render). const inboxToolbarCallbacksRef = useRef({ onArchive: () => { if (!archiveFromInbox.isPending && issue?.id) archiveFromInbox.mutate(issue.id); }, onCopy: () => copyIssueToClipboard(), onProperties: () => setMobilePropsOpen(true), onHide: () => { updateIssue.mutate( { hiddenAt: new Date().toISOString() }, { onSuccess: () => navigate("/issues/all") }, ); }, }); inboxToolbarCallbacksRef.current = { onArchive: () => { if (!archiveFromInbox.isPending && issue?.id) archiveFromInbox.mutate(issue.id); }, onCopy: () => copyIssueToClipboard(), onProperties: () => setMobilePropsOpen(true), onHide: () => { updateIssue.mutate( { hiddenAt: new Date().toISOString() }, { onSuccess: () => navigate("/issues/all") }, ); }, }; const backHref = sourceBreadcrumb.href ?? "/inbox"; const showInboxToolbar = isMobile && isFromInbox; const archivePending = archiveFromInbox.isPending; const issueHidden = !!issue?.hiddenAt; const canArchiveFromInbox = isFromInbox && !!issue?.id && !issueHidden; useEffect(() => { if (!showInboxToolbar) { setMobileToolbar(null); return; } setMobileToolbar( inboxToolbarCallbacksRef.current.onArchive()} onCopy={() => inboxToolbarCallbacksRef.current.onCopy()} onProperties={() => inboxToolbarCallbacksRef.current.onProperties()} onHide={() => inboxToolbarCallbacksRef.current.onHide()} />, ); return () => setMobileToolbar(null); }, [showInboxToolbar, backHref, issue?.id, issueHidden, archivePending, setMobileToolbar]); const attachmentsInitialLoading = attachmentsLoading && attachments === undefined; const loadOlderComments = useCallback(() => { void fetchOlderComments(); }, [fetchOlderComments]); const refetchLatestComments = useCallback(async () => { // Refetch page 0 first so comments that arrived after initial load are // visible, then load every remaining older page. The chat thread is // paginated and virtualized, so "latest" must be resolved against the // complete comment set rather than the current loaded window. const refreshed = await refetchComments(); const loaded = await loadRemainingIssueCommentPages({ pages: refreshed.data?.pages, pageParams: refreshed.data?.pageParams as Array | undefined, pageSize: ISSUE_COMMENT_PAGE_SIZE, maxPages: JUMP_TO_LATEST_MAX_COMMENT_PAGES, fetchPage: (afterCommentId) => issuesApi.listComments(issueId!, { order: "desc", limit: ISSUE_COMMENT_PAGE_SIZE, after: afterCommentId, }), }); queryClient.setQueryData>( queryKeys.issues.comments(issueId!), loaded, ); await new Promise((resolve) => { if (typeof window === "undefined") { resolve(); return; } window.requestAnimationFrame(() => resolve()); }); }, [issueId, queryClient, refetchComments]); useEffect(() => { if (!shouldPrefetchOlderComments) return; void fetchOlderComments(); }, [fetchOlderComments, shouldPrefetchOlderComments]); const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => { await feedbackVoteMutation.mutateAsync({ targetType: "issue_comment", targetId: commentId, vote, reason: options?.reason, allowSharing: options?.allowSharing, sharingPreferenceAtSubmit: feedbackDataSharingPreference, }); }, [feedbackDataSharingPreference, feedbackVoteMutation]); const handleChatAdd = useCallback(async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => { if (reassignment) { await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); return; } await addComment.mutateAsync({ body, reopen }); }, [addComment, addCommentAndReassign]); const handleCommentImageUpload = useCallback(async (file: File) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; }, [uploadAttachment]); const handleCommentAttachImage = useCallback(async (file: File) => { return uploadAttachment.mutateAsync(file); }, [uploadAttachment]); const handleInterruptQueuedRun = useCallback(async (runId: string) => { await interruptQueuedComment.mutateAsync(runId); }, [interruptQueuedComment]); const handleAcceptInteraction = useCallback(async ( interaction: ActionableIssueThreadInteraction, selectedClientKeys?: string[], ) => { await acceptInteraction.mutateAsync({ interaction, selectedClientKeys }); }, [acceptInteraction]); const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => { await rejectInteraction.mutateAsync({ interaction, reason }); }, [rejectInteraction]); const handleSubmitInteractionAnswers = useCallback(async ( interaction: IssueThreadInteraction, answers: AskUserQuestionsAnswer[], ) => { await answerInteraction.mutateAsync({ interaction, answers }); }, [answerInteraction]); const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => { await cancelInteraction.mutateAsync({ interaction }); }, [cancelInteraction]); const canResumeFromBacklog = issue?.status === "backlog" && Boolean(issue.assigneeAgentId || issue.assigneeUserId); const handleResumeFromBacklog = useCallback(async () => { await updateIssue.mutateAsync({ status: "todo" }); }, [updateIssue.mutateAsync]); const activeRecoveryActionId = issue?.activeRecoveryAction?.id; const handleResolveRecoveryAction = useCallback( (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => { const actionId = activeRecoveryActionId; if (!actionId) return; switch (outcome) { case "done": void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" }); return; case "in_review": void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" }); return; case "false_positive_done": void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "done" }); return; case "false_positive_in_review": void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "in_review" }); return; } }, [activeRecoveryActionId, resolveRecoveryAction.mutateAsync], ); const treePreviewAffectedIssues = useMemo( () => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped), [treeControlPreview], ); const treePreviewDisplayIssues = useMemo( () => { const previewIssues = treeControlPreview?.issues ?? []; if (treeControlMode !== "pause") { return previewIssues.filter((candidate) => !candidate.skipped); } return previewIssues.filter((candidate) => !candidate.skipped || candidate.skipReason === "terminal_status"); }, [treeControlMode, treeControlPreview], ); const activePauseHold = treeControlState?.activePauseHold ?? null; const activeRootPauseHoldsForDisplay = useMemo( () => activePauseHold?.isRoot === true ? activeRootPauseHolds : [], [activePauseHold?.isRoot, activeRootPauseHolds], ); const heldIssueIds = useMemo(() => { const ids = new Set(); for (const hold of activeRootPauseHoldsForDisplay) { for (const member of hold.members ?? []) { if (member.skipped) continue; ids.add(member.issueId); } } return ids; }, [activeRootPauseHoldsForDisplay]); const mutedChildIssueIds = useMemo(() => { const ids = new Set(); for (const child of childIssues) { if (heldIssueIds.has(child.id)) ids.add(child.id); } return ids; }, [childIssues, heldIssueIds]); const childPauseBadgeById = useMemo(() => { const badges = new Map(); for (const child of childIssues) { if (!heldIssueIds.has(child.id)) continue; badges.set(child.id, "Paused"); } return badges; }, [childIssues, heldIssueIds]); const activePauseHoldRoot = useMemo(() => { if (!activePauseHold) return null; if (activePauseHold.rootIssueId === issue?.id) return issue ?? null; return issue?.ancestors?.find((ancestor) => ancestor.id === activePauseHold.rootIssueId) ?? null; }, [activePauseHold, issue]); const activeRootPauseHold = useMemo( () => activeRootPauseHoldsForDisplay.find((hold) => hold.id === activePauseHold?.holdId) ?? null, [activePauseHold?.holdId, activeRootPauseHoldsForDisplay], ); 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 legacyRecoverySourceIssue = (() => { if ( issue.originKind !== "stranded_issue_recovery" && issue.originKind !== "stale_active_run_evaluation" ) { return null; } const parent = ancestors.length > 0 ? ancestors[0] : null; if (!parent) return null; const ref = parent.identifier ?? parent.id; return { identifier: parent.identifier ?? null, title: parent.title ?? null, href: createIssueDetailPath(ref), }; })(); 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 treePreviewWarnings = treeControlPreview?.warnings ?? []; const heldDescendantCount = activeRootPauseHold?.members?.filter((member) => member.depth > 0 && !member.skipped).length ?? Math.max(heldIssueIds.size - 1, 0); const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0; const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true; const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0; const isTerminalIssue = issue.status === "done" || issue.status === "cancelled"; const isAgentOwnedNonTerminalIssue = Boolean(issue.assigneeAgentId) && !isTerminalIssue; const canPauseLeafWork = canManageTreeControl && childIssues.length === 0 && !activePauseHold && !isTerminalIssue; const canResumeLeafWork = canManageTreeControl && childIssues.length === 0 && activePauseHold?.isRoot === true; const treeControlScope: "leaf" | "subtree" = childIssues.length === 0 ? "leaf" : "subtree"; const previewAffectedIssueCount = treePreviewAffectedIssues.length; const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0; const treeControlPrimaryButtonLabel = treeControlMode === "pause" ? treeControlScope === "leaf" ? "Pause work" : "Pause and stop work" : treeControlMode === "cancel" ? `Cancel ${previewAffectedIssueCount} issues` : treeControlMode === "restore" ? `Restore ${previewAffectedIssueCount} issues` : treeControlScope === "leaf" ? "Resume work" : "Resume subtree"; const treePreviewAffectedIssueRows = treePreviewDisplayIssues.map((candidate) => ({ candidate, issue: { ...issue, id: candidate.id, identifier: candidate.identifier, title: candidate.title, status: candidate.status, parentId: candidate.parentId, assigneeAgentId: candidate.assigneeAgentId, assigneeUserId: candidate.assigneeUserId, executionRunId: candidate.activeRun?.id ?? null, } satisfies Issue, })); const treePreviewAffectedAgentRows = (treeControlPreview?.affectedAgents ?? []) .map((previewAgent) => ({ ...previewAgent, agent: agentMap.get(previewAgent.agentId) ?? null, })) .sort((a, b) => (a.agent?.name ?? a.agentId).localeCompare(b.agent?.name ?? b.agentId)); const pausedComposerHint = activePauseHold ? ( issue.assigneeAgentId ? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} for triage while the subtree remains paused.` : "Assign an agent to wake them for triage while the subtree remains paused." ) : null; const composerHint = pausedComposerHint; const queuedCommentReason: "hold" | "active_run" | "other" = activePauseHold ? "hold" : "active_run"; const canApplyTreeControl = Boolean(treeControlPreview) && !treeControlPreviewLoading && (treeControlMode !== "cancel" || treeControlCancelConfirmed); const attachmentUploadButton = ( <> ); return (
{/* Parent chain breadcrumb */} {ancestors.length > 0 && ( )} {issue.hiddenAt && (
This issue is hidden
)} {activePauseHold && (
{activePauseHold.isRoot ? (
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."} {childIssues.length === 0 ? "Issue execution is held until resume. Human comments can still wake the assignee for triage." : "Root and descendant execution is held until resume. Human comments can still wake assignees for triage."}
{childIssues.length === 0 ? "1 issue held" : `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`} {activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
{canShowSubtreeControls || canResumeLeafWork ? (
{canShowSubtreeControls ? ( ) : null}
) : null}
) : (
This issue is paused by ancestor{" "} {activePauseHoldRoot?.identifier ? ( {activePauseHoldRoot.identifier} ) : ( activePauseHold.rootIssueId.slice(0, 8) )} . Resume from the root issue to deliver deferred work.
)}
)}
updateIssue.mutate({ status })} /> updateIssue.mutate({ priority })} /> {issue.identifier ?? issue.id.slice(0, 8)} {hasLiveRuns && ( Live )} {issue.originKind === "routine_execution" && issue.originId && ( Routine )} {issue.productivityReview ? ( ) : null} {issue.originKind === "issue_productivity_review" ? ( Productivity review ) : null} {issue.workMode === "planning" ? ( Planning ) : null} {hasAssignedBacklogBlocker(issue.blockedBy) ? ( Blocked by parked work ) : null} {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} )}
)} {!(isMobile && isFromInbox) && (
)}
{canArchiveFromInbox && ( )} {canPauseLeafWork ? ( ) : null} {canResumeLeafWork ? ( ) : null} {canShowSubtreeControls ? ( <> {canResumeSubtree ? ( ) : null} {canRestoreSubtree ? ( ) : null} ) : null}
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 foldable mentions={mentionOptions} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; }} onDropFile={async (file) => { await uploadAttachment.mutateAsync(file); }} />
{showRichSubIssuesSection ? (

Sub-issues

) : (
)} { 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 : null} /> {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 Related work {issuePluginTabItems.map((item) => ( {item.label} ))} {detailTab === "chat" ? ( ) : null } feedbackVotes={feedbackVotes} feedbackDataSharingPreference={feedbackDataSharingPreference} feedbackTermsUrl={FEEDBACK_TERMS_URL} agentMap={agentMap} currentUserId={currentUserId} userLabelMap={userLabelMap} userProfileMap={userProfileMap} draftKey={`paperclip:issue-comment-draft:${issue.id}`} reassignOptions={commentReassignOptions} currentAssigneeValue={actualAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentionOptions} composerDisabledReason={commentComposerDisabledReason} composerHint={composerHint} queuedCommentReason={queuedCommentReason} onVote={handleCommentVote} onAdd={handleChatAdd} onImageUpload={handleCommentImageUpload} onAttachImage={handleCommentAttachImage} onInterruptQueued={handleInterruptQueuedRun} onPauseWorkRun={canManageTreeControl ? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined) : undefined} onWorkModeChange={(nextMode) => { const currentMode: IssueWorkMode = issue.workMode ?? "standard"; if (currentMode === nextMode) return; return updateIssue.mutateAsync({ workMode: nextMode }).then(() => undefined); }} onCancelQueued={handleCancelQueuedComment} interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} pausingWorkRunId={pauseIssueWorkRun.isPending ? pauseIssueWorkRun.variables?.runId ?? null : null} onImageClick={handleChatImageClick} onAcceptInteraction={handleAcceptInteraction} onRejectInteraction={handleRejectInteraction} onSubmitInteractionAnswers={handleSubmitInteractionAnswers} onCancelInteraction={handleCancelInteraction} assigneeUserId={issue.assigneeUserId ?? null} onResumeFromBacklog={canResumeFromBacklog ? handleResumeFromBacklog : undefined} resumeFromBacklogPending={ updateIssue.isPending && updateIssue.variables?.status === "todo" } /> ) : null} {detailTab === "activity" ? ( { approvalDecision.mutate({ approvalId, action }); }} onCheckMonitorNow={() => checkIssueMonitorNow.mutate()} checkingMonitorNow={checkIssueMonitorNow.isPending} /> ) : null} {activePluginTab && ( )} {issueTreeControlLabel(treeControlMode, treeControlScope)} {issueTreeControlHelpText(treeControlMode, treeControlScope)}
{treeControlMode === "cancel" ? (
Cancelling a subtree is destructive. Non-terminal issues will be marked cancelled, and running or queued work will be interrupted where possible.
) : null}