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, isAgentTaskTarget } 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, type IssueChatRunFinalizationAction, } from "../components/IssueChatThread"; import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssuePlanDecompositionsSection } from "../components/IssuePlanDecompositionsSection"; import { IssueOutputSection } from "../components/issue-output/IssueOutputSection"; import { isImageAttachment } from "../lib/issue-attachments"; import { getPromotedOutputAttachmentIds } from "../lib/issue-output"; 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 { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews"; import { computePauseAffectsSummary } from "../lib/interrupt-handoff"; import { IssueRunLedger } from "../components/IssueRunLedger"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import type { MentionOption } from "../components/MarkdownEditor"; import { ImageGalleryModal } from "../components/ImageGalleryModal"; import { FileViewerProvider, useRequiredFileViewer } from "../context/FileViewerContext"; import { FileViewerSheet } from "../components/FileViewerSheet"; import { ArtifactFileChip } from "../components/ArtifactFileChip"; 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, FileCode2, Hexagon, ListTree, MessageSquare, MoreHorizontal, MoreVertical, PauseCircle, Paperclip, PlayCircle, Plus, Repeat, SlidersHorizontal, 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 IssueWorkProduct, type IssueWorkMode, type IssueThreadInteraction, type RequestCheckboxConfirmationInteraction, type RequestConfirmationInteraction, type SuggestTasksInteraction, type IssueTreeControlMode, type WorkspaceFileRef, workspaceFileRefSchema, } from "@paperclipai/shared"; type StopAndFinalizeRunError = Error & { runCancelledBeforeStatusUpdateFailed?: boolean; }; function createRunCancelledStatusUpdateError(err: unknown): StopAndFinalizeRunError { const message = err instanceof Error ? `Run was stopped, but updating the task failed: ${err.message}` : "Run was stopped, but updating the task failed. Retry the task status update."; const error = new Error(message) as StopAndFinalizeRunError; error.runCancelledBeforeStatusUpdateFailed = true; return error; } function didRunCancelBeforeStatusUpdateFail(err: unknown): err is StopAndFinalizeRunError { return err instanceof Error && (err as StopAndFinalizeRunError).runCancelledBeforeStatusUpdateFailed === true; } type CommentReassignment = IssueCommentReassignment; type ActionableIssueThreadInteraction = | SuggestTasksInteraction | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; 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 task subtree until an explicit resume.", resume: "Release the active subtree pause hold so held work can continue.", cancel: "Cancel non-terminal tasks in this subtree and stop queued/running work where possible.", restore: "Restore tasks cancelled by this subtree operation so work can resume.", }; const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial> = { pause: "Pause active execution on this task until an explicit resume.", resume: "Release the active pause hold so this task 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 tasks."; } 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; } export function shouldScrollIssueDetailToTopOnNavigation(input: { previousIssueId: string | undefined; nextIssueId: string | undefined; navigationType: ReturnType; }): boolean { if (input.navigationType === "POP") return false; return input.previousIssueId !== input.nextIssueId; } 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 extractWorkspaceFileRefFromWorkProduct( workProduct: { metadata: Record | null }, ): WorkspaceFileRef | null { const metadata = asRecord(workProduct.metadata); if (!metadata) return null; const parsed = workspaceFileRefSchema.safeParse(metadata.resourceRef); return parsed.success ? parsed.data : null; } 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; scheduledRetry: Issue["scheduledRetry"] | 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; onDeleteComment?: (commentId: string) => Promise | void; onPauseWorkRun?: (runId: string) => Promise; runFinalizationActions?: readonly IssueChatRunFinalizationAction[]; onCancelQueued: (commentId: string) => void; interruptingQueuedRunId: string | null; pausingWorkRunId: string | null; onImageClick: (src: string) => void; onAcceptInteraction: ( interaction: ActionableIssueThreadInteraction, selectedClientKeys?: string[], selectedOptionIds?: 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, scheduledRetry, 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, onDeleteComment, onPauseWorkRun, runFinalizationActions, 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 task {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-tasks {(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} task{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 [fileViewerPromptOpen, setFileViewerPromptOpen] = useState(false); const [detailTab, setDetailTab] = useState("chat"); const [handoffFocusSignal, setHandoffFocusSignal] = useState(0); const [pendingApprovalAction, setPendingApprovalAction] = useState<{ approvalId: string; action: "approve" | "reject"; } | null>(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 lastScrollIssueIdRef = useRef(undefined); 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: workProducts } = useQuery({ queryKey: queryKeys.issues.workProducts(issueId!), queryFn: () => issuesApi.listWorkProducts(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: "Tasks", 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 { data: instanceExperimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), enabled: !!issueId, retry: false, }); const keyboardShortcutsEnabled = instanceGeneralSettings?.keyboardShortcuts === true; const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt"; const showPlanDecompositionsSection = instanceExperimentalSettings?.enableIssuePlanDecompositions === true; const fileViewerEnabled = instanceExperimentalSettings?.enableExperimentalFileViewer === true; 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(isAgentTaskTarget) .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 ?? "Task"; 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 invalidateIssueDocumentAnnotationState = useCallback(() => { queryClient.invalidateQueries({ queryKey: ["issues", "document-annotations", issueId!] }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) }); }, [issueId, 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 clearCommentHashIfCurrent = useCallback((commentId: string) => { if (typeof window === "undefined") return; if (window.location.hash !== `#comment-${commentId}`) return; window.history.replaceState(null, "", `${location.pathname}${location.search}`); }, [location.pathname, location.search]); const upsertCommentInCache = useCallback((comment: IssueComment) => { for (const ref of issueCacheRefs) { queryClient.setQueryData | undefined>( queryKeys.issues.comments(ref), (current) => current ? { ...current, pages: upsertIssueCommentInPages(current.pages, comment), } : current, ); } }, [issueCacheRefs, 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: "Task update failed", body: err instanceof Error ? err.message : "Unable to save task 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: "todo" | "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 task 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 task 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 stopAndFinalizeRun = useMutation({ mutationFn: async ({ runId, status }: { runId: string; status: "cancelled" | "done" }) => { await heartbeatsApi.cancel(runId); try { return await issuesApi.update(issueId!, { status }); } catch (err) { throw createRunCancelledStatusUpdateError(err); } }, onSuccess: ({ comment: _comment, ...nextIssue }, { status }) => { const issueRefs = new Set([issueId!, nextIssue.id]); if (nextIssue.identifier) issueRefs.add(nextIssue.identifier); mergeIssueResponseIntoCaches(issueRefs, nextIssue); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); invalidateIssueRunState(); invalidateIssueCollections(); pushToast({ title: status === "done" ? "Run stopped and task done" : "Run stopped and task cancelled", tone: "success", }); }, onError: (err, { status }) => { const runWasStopped = didRunCancelBeforeStatusUpdateFail(err); pushToast({ title: runWasStopped ? "Run stopped; task update failed" : status === "done" ? "Stop and done failed" : "Stop and cancel failed", body: err instanceof Error ? err.message : "Unable to stop the run and update the task", tone: "error", }); }, onSettled: (_data, err) => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); if (err) invalidateIssueRunState(); if (selectedCompanyId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); } }, }); 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: "Task update failed", body: err instanceof Error ? err.message : "Unable to save sub-task 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, selectedOptionIds, }: { interaction: ActionableIssueThreadInteraction; selectedClientKeys?: string[]; selectedOptionIds?: string[]; }) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys, selectedOptionIds }), 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" : interaction.kind === "request_checkbox_confirmation" ? "Selection 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 deleteComment = useMutation({ mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.deleteComment(issueId!, commentId), onSuccess: (comment) => { upsertCommentInCache(comment); clearCommentHashIfCurrent(comment.id); invalidateIssueDetail(); invalidateIssueThreadLazily(); invalidateIssueCollections(); invalidateIssueDocumentAnnotationState(); pushToast({ title: "Comment deleted", body: "The thread now shows a deleted-comment marker.", tone: "success", }); }, onError: (err) => { pushToast({ title: "Delete failed", body: err instanceof Error ? err.message : "Unable to delete the 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: "Task archived from inbox", tone: "success" }); }, onError: (err) => { pushToast({ title: "Archive failed", body: err instanceof Error ? err.message : "Unable to archive this task 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(() => { const previousIssueId = lastScrollIssueIdRef.current; lastScrollIssueIdRef.current = issueId; if (!shouldScrollIssueDetailToTopOnNavigation({ previousIssueId, nextIssueId: issueId, navigationType })) 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, resolvedHasActiveRun, ]); 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); } if (action === "open_file_viewer") { if (!fileViewerEnabled) return; event.preventDefault(); event.stopPropagation(); setFileViewerPromptOpen(true); } }; 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); }; }, [fileViewerEnabled, 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]); // Scroll + briefly highlight work-product / direct-attachment anchors so the // company Artifacts page (PAP-10359) can deep-link to a specific artifact in // its issue context. Retries while the section data loads in. useEffect(() => { const match = location.hash.match(/^#(work-product|attachment)-(.+)$/); if (!match) return; const targetId = `${match[1]}-${decodeURIComponent(match[2]!)}`; let cancelled = false; let attempts = 0; let timer: ReturnType | undefined; const tryScroll = () => { if (cancelled) return; const element = document.getElementById(targetId); if (!element) { if (attempts < 30) { attempts += 1; timer = setTimeout(tryScroll, 100); } return; } element.scrollIntoView({ behavior: "smooth", block: "center" }); element.classList.add("ring-2", "ring-primary/50", "transition-shadow"); timer = setTimeout(() => element.classList.remove("ring-2", "ring-primary/50", "transition-shadow"), 3000); }; tryScroll(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [location.hash, workProducts, attachments]); useEffect(() => { if (pendingCommentComposerFocusKey === 0) return; if (detailTab !== "chat") return; commentComposerRef.current?.focus(); }, [detailTab, pendingCommentComposerFocusKey]); useEffect(() => { if (!fileViewerEnabled) return; const handleOpenFileViewer = () => { setFileViewerPromptOpen(true); }; window.addEventListener("paperclip:open-file-viewer", handleOpenFileViewer as EventListener); return () => { window.removeEventListener( "paperclip:open-file-viewer", handleOpenFileViewer as EventListener, ); }; }, [fileViewerEnabled]); const promotedOutputAttachmentIds = useMemo(() => getPromotedOutputAttachmentIds(workProducts), [workProducts]); const attachmentList = useMemo( () => (attachments ?? []).filter((attachment) => !promotedOutputAttachmentIds.has(attachment.id)), [attachments, promotedOutputAttachmentIds], ); const imageAttachments = useMemo(() => (attachments ?? []).filter(isImageAttachment), [attachments]); 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 runFinalizationActions = useMemo(() => [ { id: "cancel", label: "Stop and cancel", pendingLabel: "Stopping and cancelling...", isPending: stopAndFinalizeRun.isPending && stopAndFinalizeRun.variables?.status === "cancelled", disabled: stopAndFinalizeRun.isPending, onSelect: (runId) => stopAndFinalizeRun.mutateAsync({ runId, status: "cancelled" }).then(() => undefined, () => undefined), }, { id: "done", label: "Stop and done", pendingLabel: "Stopping and marking done...", isPending: stopAndFinalizeRun.isPending && stopAndFinalizeRun.variables?.status === "done", disabled: stopAndFinalizeRun.isPending, onSelect: (runId) => stopAndFinalizeRun.mutateAsync({ runId, status: "done" }).then(() => undefined, () => undefined), }, ], [ stopAndFinalizeRun.isPending, stopAndFinalizeRun.mutateAsync, stopAndFinalizeRun.variables?.status, ]); const handleAcceptInteraction = useCallback(async ( interaction: ActionableIssueThreadInteraction, selectedClientKeys?: string[], selectedOptionIds?: string[], ) => { await acceptInteraction.mutateAsync({ interaction, selectedClientKeys, selectedOptionIds }); }, [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 "todo": void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "todo" }); return; 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], ); // "What this affects" buckets for the pause/hold dialog (design surface 4). const pauseAffectsSummary = useMemo( () => computePauseAffectsSummary(treeControlPreview?.issues ?? []), [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} tasks` : treeControlMode === "restore" ? `Restore ${previewAffectedIssueCount} tasks` : 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 task is hidden
)} {activePauseHold && (
{activePauseHold.isRoot ? (
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."} {childIssues.length === 0 ? "Task 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 task held" : `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`} {activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
{canShowSubtreeControls || canResumeLeafWork ? (
{canShowSubtreeControls ? ( ) : null}
) : null}
) : (
This task is paused by ancestor{" "} {activePauseHoldRoot?.identifier ? ( {activePauseHoldRoot.identifier} ) : ( activePauseHold.rootIssueId.slice(0, 8) )} . Resume from the root task 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 && ( )} {fileViewerEnabled ? ( ) : null} {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-tasks

) : (
)} {showPlanDecompositionsSection ? ( ) : null} { 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} agentMap={agentMap} userProfileMap={userProfileMap} /> {attachmentsInitialLoading ? ( ) : hasAttachments ? ( deleteAttachment.mutate(attachmentId)} onImageClick={(attachment) => { const idx = imageAttachments.findIndex((a) => a.id === attachment.id); setGalleryIndex(idx >= 0 ? idx : 0); setGalleryOpen(true); }} onDragEnter={(evt) => { 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)} /> ) : null} updateIssue.mutate(data)} onBrowseFiles={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined} onOpenFileByPath={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined} /> {fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => { const workProductsWithFileRefs = issue.workProducts .map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) })) .filter(({ fileRef }) => fileRef !== null); if (workProductsWithFileRefs.length === 0) return null; return (

Artifacts

{workProductsWithFileRefs.map(({ product, fileRef }) => ( ))}
); })()} 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} onDeleteComment={(commentId) => deleteComment.mutateAsync({ commentId }).then(() => undefined)} onPauseWorkRun={canManageTreeControl ? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined) : undefined} runFinalizationActions={runFinalizationActions} 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 tasks will be marked cancelled, and running or queued work will be interrupted where possible.
) : null}