import { useIssueIdBatches } from "@/hooks/useIssueIdBatches"; import { TaskChatLoadingShell } from "@/components/task-chat/TaskChatLoadingShell"; import { useConversationHistoryPages, type ConversationHistoryPage } from "@/hooks/useConversationHistoryPages"; import { taskChatHashAnchor } from "@/components/task-chat/scroll-navigation"; import { AgentChatReadProvider } from "@/hooks/useAgentChatRead"; import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; import { CoordinatorAgentSwitcher } from "@/components/CoordinatorAgentSwitcher"; import { TaskDecisionRecord } from "@/components/TaskDecisionRecord"; import { useAccountBoundMutation } from "@/hooks/useAccountBoundMutation"; import { bindAccount } from "@/lib/account-scope"; import { mergeWindowComments, useIssueHistoryWindow } from "../hooks/useIssueHistoryWindow"; import type { IssueQueuedCommentQueue } from "@paperclipai/shared"; import { useTaskRunLiveUpdates } from "../context/LiveUpdatesProvider"; import { liveRunRefetchInterval } from "../lib/live-runs-cache"; import { useTaskRetry } from "../hooks/useTaskRetry"; import { grokBotApi } from "@/api/grokBot"; import { WorkspaceBaseRefRecoveryProvider } from "../components/WorkspaceBaseRefRecovery"; import { WorkspaceRetryControl } from "../components/WorkspaceRetryButton"; import { isLockedIssueStub, LockedIssueChip } from "@/components/LockedIssueChip"; import { canManageIssuePrivacy } from "../lib/issuePrivacy"; import { TextAttachmentContext } from "../context/TextAttachmentContext"; import { useTaskPluginPanels, taskPluginPanelActivationKey } from "@/hooks/useTaskPluginPanels"; import { useTaskBrowsers, useBrowserArrivals } from "@/hooks/useTaskBrowsers"; import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery"; import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice"; import { AgentAvatar } from "@/components/AgentAvatar"; import { mergeComposerRunSettings, type ComposerRunSettings } from "@/components/task-chat/composer-run-settings"; import { AgentIdentity } from "@/components/AgentIdentity"; import { clearLegacyChatMessageRequests } from "@/lib/chat-message-request"; import { agentChatDraft } from "@/lib/agent-chat-draft"; import { trackRecentProject } from "@/lib/recent-projects"; import { Settings as ChatSettings } from "lucide-react"; import { agentDetailHref } from "./agent-detail-navigation"; import { ExecutionBlockerNotice } from "../components/ExecutionBlockerNotice"; import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover"; import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel"; import { IssueCreatedFromNote } from "@/components/task-detail/IssueCreatedFromNote"; import { EmailThreadProvider } from "../components/EmailMessageCard"; import { EmailTaskActivity } from "../components/EmailTaskActivity"; import { TaskChatScrollNavigation, taskChatScrollEntry } from "@/components/task-chat/scroll-navigation"; import { lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, 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 { usePublishSharedQueryData, useSharedPollingQuery, } from "@/hooks/useSharedPolling"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { classifySendFailure } from "../lib/pending-send"; import { useUpdateIssueMutation } from "../hooks/useUpdateIssueMutation"; import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error"; 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 } from "../api/access"; import { canBoardManageRuntime, canBoardResolveRecoveryAction, isRetainedWorkspaceExportCause, readRecoveryReconcileWorkspaceId, } from "../lib/recovery-reconcile"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; import { projectsApi } from "../api/projects"; import { executionWorkspacesApi } from "../api/execution-workspaces"; 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, formatAssigneeUserLabel, formatUserLabel, suggestedCommentAssigneeValue, } from "../lib/assignees"; import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions, isAgentTaskTarget, } from "../lib/company-members"; import { extractIssueTimelineEvents, extractIssueWorkModeChanges, } from "../lib/issue-timeline-events"; import { queryKeys } from "../lib/queryKeys"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; import { useIssueWorkProducts } from "../hooks/useIssueWorkProducts"; import { mergePendingIssueQueuedComments, normalizeIssueQueuedCommentQueue, startingQueuedCommentIds, } from "../lib/issue-queued-comment-queue"; import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { hasLegacyIssueDetailQuery, createIssueDetailPath, readIssueDetailLocationState, readIssueDetailBreadcrumb, readIssueDetailHeaderSeed, withIssueDetailHeaderSeed, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; import { resolveIssueActiveRun, shouldTrackIssueActiveRun, } from "../lib/issueActiveRun"; import { prefetchIssueThread } from "../lib/issue-thread-queries"; import { getIssueDetailQueryOptions, seedIssueDetailProjections } from "../lib/issueDetailCache"; import { beginIssueDetailNavigation, recordIssueDetailReadiness, ISSUE_DETAIL_CONTENT_MEASURE, ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_HEADER_MEASURE, ISSUE_DETAIL_HEADER_PAINT_MARK, reportIssueDetailWebVitals, scheduleIssueDetailPaintMeasure, } from "../lib/issue-detail-performance"; import { beginLocalInboxArchive, boundLocalInboxArchive, cancelInboxIssueQueries, clearLocalInboxArchive, confirmLocalInboxArchive, invalidateInboxIssueQueries, getIssuePresenceInActiveInboxCaches, removeIssueFromInboxCaches, restoreIssueToInboxCaches, snapshotInboxIssueCaches, type InboxIssueCacheSnapshot, } from "../lib/inboxArchiveCache"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, resolveInboxQuickArchiveKeyAction, } from "../lib/keyboardShortcuts"; import { applyOptimisticIssueCommentUpdate, applyLocalQueuedIssueCommentState, createOptimisticIssueComment, flattenIssueCommentPages, getNextIssueCommentPageParam, ISSUE_COMMENT_PAGE_SIZE, 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 { recordRecentTask } from "../lib/recent-tasks"; import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd, visibleRunTokenTotal, } from "../lib/utils"; import { liveBlueBadge } from "../lib/status-colors"; import { ApprovalCard } from "../components/ApprovalCard"; import { ProjectTile } from "../components/ProjectTile"; import { InlineEditor } from "../components/InlineEditor"; import { useDesignSandbox } from "../context/DesignSandboxContext"; import { IssueChatThread, type IssueChatComposerHandle, type IssueChatRunFinalizationAction, } from "../components/IssueChatThread"; import { TaskChatThread } from "../components/TaskChatThread"; import type { TaskChatIssueBrief } from "../components/task-chat/TaskChatDescriptionBubble"; import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled"; import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { workModeMetaFor } from "../lib/work-mode-meta"; 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, isVideoAttachment } from "../lib/issue-attachments"; import { getIssueOutputs, getPromotedOutputAttachmentIds, isImageLikeOutput, isVideoLikeOutput, } from "../lib/issue-output"; import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation"; import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody"; import { IssuesList } from "../components/IssuesList"; import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary"; import { IssueFieldChangeReceipt } from "../components/IssueFieldChangeReceipt"; import { IssueWriteDenialNotice } from "../components/IssueWriteDenialNotice"; import { issueWriteDenialForActivity } from "../lib/issue-write-denial-activity"; import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel"; import { IssueMonitorBanner, IssueMonitorComposerStrip, hasVisibleMonitorSurface, } from "../components/IssueMonitorBanner"; import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard"; import { ExternallyConnectedTaskBanner } from "../components/chat/ExternallyConnectedTaskBanner"; import { IssueProperties, type IssuePropertiesDocumentDeepLink, } from "../components/IssueProperties"; import { TaskSidePanel, type TaskSidePanelProps } from "../components/task-side-panel"; import { SidePanelToggleButton } from "../components/side-panel"; import { TaskTreeControlDialog, TaskTreeControlMenuItems, } from "../components/TaskTreeControls"; import { waitForStoppedRuns } from "../lib/wait-for-stopped-runs"; import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects"; import { IssueGalleryContext } from "../context/IssueGalleryContext"; import { useIssuePlanDocument } from "../hooks/useIssuePlanDocument"; import { useTaskArtifactArrival } from "../hooks/useTaskArtifactArrival"; import { IssueRunLedger } from "../components/IssueRunLedger"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import type { MentionOption } from "../components/MarkdownEditor"; import { ImageGalleryModal, type GalleryMediaItem, } 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 { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; 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 { QueryErrorState, useQueryView } from "@/components/QueryView"; import { describeError, isTransientError } from "@/api/errors"; import { Sheet, SheetContent, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { Avatar, AvatarFallback, AvatarGroup, AvatarImage, } from "@/components/ui/avatar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; 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 { copyTextToClipboard } from "../lib/clipboard"; import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"; import { openSkillPanelState, shouldSuppressTaskPanelUntilPlan } from "../lib/task-side-panel-state"; import { interactionReadinessRefetchInterval, isDeferredConversationInteraction, buildAnsweredQuestionsDeliveryText, buildIssueThreadInteractionSummary, } from "../lib/issue-thread-interactions"; import { resolveIssueDocumentDeepLink, sameIssueDocumentHash } from "../lib/issue-document-deep-link"; 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, EyeOff, ScanEye, Flag, FileCode2, ListTree, MessageSquare, MoreHorizontal, MoreVertical, Paperclip, Plus, Repeat, SlidersHorizontal, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { IssuePrivacyActions } from "@/components/IssuePrivacyActions"; import type { ShareSheetImplicitPrincipal } from "@/components/IssueShareSheet"; import { deriveOriginatingActor, isClosedIsolatedExecutionWorkspace, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, ONBOARDING_FIRST_TASK_ORIGIN_KIND, type AskUserQuestionsAnswer, type AskUserQuestionsInteraction, type ActivityEvent, type Agent, type FeedbackVote, type Issue, type IssueRecoveryAction, type IssueAttachment, type IssueComment, type IssueRecentComments, type IssueDocumentSummary, type IssueWorkProduct, type Project, type IssueWorkMode, type IssueThreadInteraction, type RequestCheckboxConfirmationInteraction, type RequestConfirmationInteraction, type RequestItemVerdictsInteraction, type RequestItemVerdictValue, type SuggestTasksInteraction, type IssueTreeControlMode, type WorkspaceFileRef, workspaceFileRefSchema, isUuidLike, } from "@paperclipai/shared"; // Stable empty array for React Query `data` defaults. A literal `= []` default // creates a new array reference on every render while `data` is undefined // (loading/idle), which destabilizes downstream memos and panel keys that // depend on it. Reusing one shared reference keeps those values stable. const EMPTY_ISSUES: Issue[] = []; const EMPTY_PROJECTS: Project[] = []; type StopAndFinalizeRunError = Error & { runCancelledBeforeStatusUpdateFailed?: boolean; }; function createRunCancelledStatusUpdateError( err: unknown, ): StopAndFinalizeRunError { const message = err instanceof Error ? `Run was stopped, but updating the task failed: ${describeError(err).body}` : "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"; consumedByRunId?: string | null; consumedQueueIds?: string[]; steeredIntoRunId?: string | null; conversationAnchorAt?: Date | string | null; conversationAnchorSequence?: number; }; function isPlanConfirmationInteraction( interaction: IssueThreadInteraction, ): interaction is RequestConfirmationInteraction { return ( interaction.kind === "request_confirmation" && interaction.payload.target?.type === "issue_document" && interaction.payload.target.key === "plan" ); } function buildPlanDecisionResponseText( interaction: RequestConfirmationInteraction, ) { if (interaction.status === "accepted") return "Approved plan"; const reason = interaction.result?.reason?.trim(); return reason ? `Requested changes\n\n${reason}` : "Requested changes"; } const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3; const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10; 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."; } // `canBoardManageRuntime` and `readRecoveryReconcileWorkspaceId` moved to `@/lib/recovery-reconcile` // so the run-page recovery surface can reuse them without importing this page module. Re-exported // here (from the top-of-file import) to keep existing import sites — and their tests — stable, while // the imported bindings stay usable within this module. export { canBoardManageRuntime, canBoardResolveRecoveryAction, readRecoveryReconcileWorkspaceId }; 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 resolveInterruptibleIssueRun( activeRun: ActiveRunForIssue | null | undefined, liveRuns: readonly LiveRunForIssue[] | undefined, ) { const issueLiveRun = (liveRuns ?? []).find((run) => run.status === "running") ?? (liveRuns ?? []).find((run) => run.status === "queued") ?? null; return ( issueLiveRun ?? (activeRun?.status === "running" || activeRun?.status === "queued" ? activeRun : 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, issue: Pick | null | undefined, ) { const liveRuns = queryClient.getQueryData( queryKeys.issues.liveRuns(issueId), ); const activeRun = queryClient.getQueryData( queryKeys.issues.activeRun(issueId), ); const activeRunIsLive = Boolean( activeRun && liveRuns?.some((run) => run.id === activeRun.id), ); const activeRunMatchesIssueLock = Boolean( activeRun && issue?.executionRunId && activeRun.id === issue.executionRunId, ); const resolvedActiveRun = activeRunIsLive || activeRunMatchesIssueLock ? activeRun : null; return { liveRuns, activeRun: resolvedActiveRun, interruptibleIssueRun: resolveInterruptibleIssueRun( resolvedActiveRun, 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< string, import("../lib/company-members").CompanyUserProfile >; }) { 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 ; } export type AttributionActor = { appearance?: Agent["appearance"]; kind: "agent" | "user"; id: string; name: string; avatarUrl?: string | null; }; function attributionInitials(name: string): string { const parts = name.trim().split(/\s+/); if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); return name.slice(0, 2).toUpperCase(); } function AttributionAvatar({ label, actor, via, }: { label: "Assignee" | "Originating"; actor: AttributionActor; via?: string | null; }) { const accessibleLabel = via ? `${label}: ${actor.name} · via ${via}` : `${label}: ${actor.name}`; const testIdLabel = label.toLowerCase(); return ( {actor.kind === "agent" ? : ( {actor.avatarUrl ? : null} {attributionInitials(actor.name)} )}
{actor.kind === "agent" ? : ( {actor.avatarUrl ? : null} {attributionInitials(actor.name)} )}
{label}
{actor.name}
{via ? (
via {via}
) : null}
); } function IssueAttributionByline({ issue, agentMap, userProfileMap, userLabelMap, }: { issue: Issue; agentMap: Map; userProfileMap: ReadonlyMap< string, import("../lib/company-members").CompanyUserProfile >; userLabelMap: ReadonlyMap; }) { const assignee: AttributionActor | null = issue.assigneeAgentId ? { kind: "agent", id: issue.assigneeAgentId, appearance: agentMap.get(issue.assigneeAgentId)?.appearance, name: agentMap.get(issue.assigneeAgentId)?.name ?? issue.assigneeAgentId.slice(0, 8), } : issue.assigneeUserId ? { kind: "user", id: issue.assigneeUserId, name: formatUserLabel(issue.assigneeUserId, userLabelMap) ?? userProfileMap.get(issue.assigneeUserId)?.label ?? "User", avatarUrl: userProfileMap.get(issue.assigneeUserId)?.image ?? null, } : null; const originatingActor = deriveOriginatingActor(issue); const originator: AttributionActor | null = originatingActor ? originatingActor.kind === "agent" ? { kind: "agent", id: originatingActor.id, appearance: agentMap.get(originatingActor.id)?.appearance, name: agentMap.get(originatingActor.id)?.name ?? originatingActor.id.slice(0, 8), } : { kind: "user", id: originatingActor.id, name: formatUserLabel(originatingActor.id, userLabelMap) ?? userProfileMap.get(originatingActor.id)?.label ?? "User", avatarUrl: userProfileMap.get(originatingActor.id)?.image ?? null, } : null; const originatorVia = originatingActor?.kind === "user" && originatingActor.viaAgentId ? (agentMap.get(originatingActor.viaAgentId)?.name ?? originatingActor.viaAgentId.slice(0, 8)) : null; if (!assignee && !originator) return null; return ( {assignee ? ( ) : null} {originator ? ( ) : null} ); } function IssueSectionSkeleton({ titleWidth = "w-28", rows = 3, }: { titleWidth?: string; rows?: number; }) { return (
{Array.from({ length: rows }).map((_, index) => ( ))}
); } /** * One chat-bubble placeholder mirroring TaskChatBubble's anatomy: agent replies * sit left under an avatar + name author row, human messages sit right with no * header. The bubble reuses the real rounding (rounded-2xl with a squared tail * corner) so the skeleton reads as a conversation, not a stack of cards. */ function ChatBubbleSkeleton({ side, className, }: { side: "agent" | "human"; className?: string; }) { const isHuman = side === "human"; return (
{isHuman ? null : ( )}
); } /** * Alternating chat-bubble placeholders for the thread body. Widths and heights * vary so the skeleton mirrors a real back-and-forth (TaskChatThreadView) * rather than the pre-chat bordered card it replaced. */ function IssueChatSkeleton() { return (
); } function useTaskDetailInterfaceMode(conversationMode = false) { const { enabled: classicTaskInterfacePreferenceEnabled, loaded: classicTaskInterfaceLoaded, } = useClassicTaskInterfaceEnabled(); const { enabled: classicStreamlinedUiEnabled, loaded: streamlinedUiLoaded } = useStreamlinedUiEnabled(); const { enabled: coordinatorWorkspaceEnabled, loaded: coordinatorLoaded } = useCoordinatorWorkspaceEnabled(); const streamlinedUiEnabled = classicStreamlinedUiEnabled || coordinatorWorkspaceEnabled; const classicTaskInterfaceEnabled = classicTaskInterfacePreferenceEnabled && !conversationMode && !coordinatorWorkspaceEnabled; const taskChatShellEnabled = !classicTaskInterfaceEnabled; return { coordinatorWorkspaceEnabled, classicTaskInterfaceEnabled, taskChatShellEnabled, streamlinedTaskDetailEnabled: streamlinedUiEnabled && taskChatShellEnabled, streamlinedUiEnabled, loaded: classicTaskInterfaceLoaded && streamlinedUiLoaded && coordinatorLoaded, }; } export function IssueDetailLoadingState({ headerSeed, titleEditor, }: { headerSeed: ReturnType; titleEditor?: React.ReactNode; }) { const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null; const { taskChatShellEnabled, streamlinedUiEnabled, coordinatorWorkspaceEnabled, loaded } = useTaskDetailInterfaceMode(); const { isMobile } = useSidebar(); if (titleEditor) { return
{titleEditor}
; } if (!loaded) { return
; } if (taskChatShellEnabled) { return
; } return (
{headerSeed ? ( <> {/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */} {SHOW_TASK_PRIORITY_UI && ( )} {identifier ? ( {identifier} ) : null} {headerSeed.originKind === "routine_execution" && headerSeed.originId ? ( Routine ) : null} {/* Seeded header — same anatomy as the resolved one below, so the eyebrow does not change shape when the real issue arrives. */} {headerSeed.projectId ? ( {headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)} ) : ( No project )} ) : ( <> )}
{headerSeed ? ( <>

{headerSeed.title}

) : ( <> )}
); } interface InboxMobileToolbarProps { backHref: string; preferHistoryBack: boolean; issueId: string | undefined; issueHidden: boolean; onArchive: () => void; archivePending: boolean; onCopy: () => void; onProperties: () => void; onHide: () => void; } function InboxMobileToolbar({ backHref, preferHistoryBack, issueId: issueIdProp, issueHidden, onArchive, archivePending, onCopy, onProperties, onHide, }: InboxMobileToolbarProps) { const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(false); return (
{issueIdProp && !issueHidden && ( )} {issueIdProp && ( )}
); } interface RecentIssueRunWindow { runs: RunForIssue[]; hasEarlierRuns: boolean; } type IssueDetailChatTabProps = { executionBlocked?: boolean; interruption?: Issue["interruption"]; retryPreparing?: boolean; queryIssueId?: string; hasLiveConnection: boolean; browsers?: import("@paperclipai/shared").TaskBrowser[]; onOpenBrowser?: (browserId: string) => void; onOpenSkill?: (skillId: string, name: string) => void; issueId: string; companyId: string; projectId: string | null; issueStatus: Issue["status"]; /** Marks cross-issue agent comments in the thread (the open cross-task write design (attribution)). */ issueAssigneeAgentId: Issue["assigneeAgentId"]; issueWorkMode: IssueWorkMode; executionRunId: string | null; blockedBy: Issue["blockedBy"]; liveIssueIds: ReadonlySet; blockerAttention: Issue["blockerAttention"] | null; successfulRunHandoff: Issue["successfulRunHandoff"] | null; scheduledRetry: Issue["scheduledRetry"] | null; recoveryAction: Issue["activeRecoveryAction"]; onResolveRecoveryAction?: ( outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome, ) => void; onReissueIsolatedRecoveryAction?: ( request: import("../components/IssueRecoveryActionCard").RecoveryReissueRequest, ) => void; reissueIsolatedRecoveryActionPending?: boolean; onReconcileForwardRecoveryAction?: () => void; onBreakGlassOverrideRecoveryAction?: (reason: string) => void; onQuarantineRestoreRecoveryAction?: () => void; quarantineRestoreRecoveryActionPending?: boolean; canBreakGlassRecoveryAction?: boolean; reconcileRecoveryActionPending?: boolean; canFalsePositiveRecoveryAction?: boolean; legacyRecoverySourceIssue?: { identifier: string | null; href: string; title?: string | null; } | null; comments: IssueDetailComment[]; commentsInitialLoading?: boolean; historyExpanded?: boolean; onExpandHistory?: () => void; hasEarlierMessages?: boolean; initialHistoryPending?: boolean; initialMetadataPending?: boolean; initialHistoryError?: boolean; onRetryInitialHistory?: () => void; locallyQueuedCommentRunIds: ReadonlyMap; interactions: IssueThreadInteraction[]; documents: IssueDocumentSummary[]; createdTasks: Issue[]; workProducts: IssueWorkProduct[]; attachments: IssueAttachment[]; hasOlderComments: boolean; commentsLoadingOlder: boolean; commentsOlderError?: boolean; onLoadOlderComments: () => void; onRefreshLatestComments: () => Promise | void; onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; composerRef: Ref; /** Optional node rendered inline directly above the reply composer (e.g. the monitor strip). */ composerAccessory?: ReactNode; /** * Issue header (title row, badges, plugin toolbars) that the chat-style * thread renders inside its scroll viewport so it scrolls away with the * messages. Ignored by the classic thread (flag: enableClassicTaskInterface). */ threadHeader?: ReactNode; /** * The task description rendered as the requester's first chat bubble in the * chat-style thread (PAP-375). Ignored by the classic thread. */ issueBrief?: TaskChatIssueBrief; footer?: ReactNode; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt"; feedbackTermsUrl: string | null; agentMap: Map; currentUserId: string | null; userLabelMap: ReadonlyMap | null; userProfileMap: ReadonlyMap< string, import("../lib/company-members").CompanyUserProfile > | null; draftKey: string; reassignOptions: Array<{ id: string; label: string; searchText?: string }>; currentAssigneeValue: string; assigneeAdapterOverrides?: Issue["assigneeAdapterOverrides"]; suggestedAssigneeValue: string; mentions: MentionOption[]; conversationMode?: boolean; composerPause?: TaskComposerPause | null; 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, attachmentIds?: string[], clientRequestId?: string, runSettings?: ComposerRunSettings, ) => Promise; onImageUpload: (file: File) => Promise; onAttachImage: (file: File) => Promise; onInterruptQueued: (runId: string | null) => Promise; onRemoveCommentFromCache: (commentId: string) => Promise; onDeleteComment?: (commentId: string) => Promise | void; onPauseWorkRun?: (runId: string, feedback?: "composer") => Promise; onStopResponse?: (runId: string) => Promise; stopResponsePending?: boolean; pauseWorkPending?: boolean; pauseWorkScope?: "leaf" | "subtree"; runFinalizationActions?: readonly IssueChatRunFinalizationAction[]; onCancelQueued: (commentId: string) => void; interruptingQueuedRunId: string | null; pausingWorkRunId: string | null; onImageClick: (src: string) => void; onAcceptInteraction: ( interaction: ActionableIssueThreadInteraction, selectedClientKeys?: string[], selectedOptionIds?: string[], rememberAction?: boolean, ) => Promise; onRejectInteraction: ( interaction: ActionableIssueThreadInteraction, reason?: string, ) => Promise; onSubmitInteractionAnswers: ( interaction: IssueThreadInteraction, answers: AskUserQuestionsAnswer[], ) => Promise; onCancelInteraction: ( interaction: AskUserQuestionsInteraction, ) => Promise; onSkipInteraction: (interaction: IssueThreadInteraction) => Promise; onDismissInteraction: (interaction: IssueThreadInteraction) => Promise; onSubmitInteractionVerdicts: ( interaction: RequestItemVerdictsInteraction, verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string; }[], ) => Promise; assigneeUserId: string | null; onResumeFromBacklog?: () => Promise | void; resumeFromBacklogPending?: boolean; onResumeAssignee?: () => Promise | void; resumeAssigneePending?: boolean; onTryAgainNoLiveExecutionPath?: () => Promise | void; tryAgainNoLiveExecutionPathPending?: boolean; externalReferences?: MarkdownExternalReferenceMap; linkCaseReferences?: boolean; }; const DesignSandboxTaskThread = lazy(() => import("../components/design-sandbox/DesignSandboxTaskThread")); const IssueDetailChatTab = memo(function IssueDetailChatTab({ executionBlocked, interruption, retryPreparing, queryIssueId, hasLiveConnection, browsers, onOpenBrowser, onOpenSkill, issueId, companyId, projectId, issueWorkMode, issueStatus, issueAssigneeAgentId, executionRunId, blockedBy, liveIssueIds, blockerAttention, successfulRunHandoff, scheduledRetry, recoveryAction, onResolveRecoveryAction, onReissueIsolatedRecoveryAction, reissueIsolatedRecoveryActionPending, onReconcileForwardRecoveryAction, onBreakGlassOverrideRecoveryAction, onQuarantineRestoreRecoveryAction, quarantineRestoreRecoveryActionPending, canBreakGlassRecoveryAction, reconcileRecoveryActionPending, canFalsePositiveRecoveryAction, legacyRecoverySourceIssue, comments, commentsInitialLoading = false, historyExpanded = true, onExpandHistory, hasEarlierMessages = false, initialHistoryPending = false, initialMetadataPending = false, initialHistoryError = false, onRetryInitialHistory, locallyQueuedCommentRunIds, interactions, documents, createdTasks, workProducts, attachments, hasOlderComments, commentsLoadingOlder, commentsOlderError = false, onLoadOlderComments, onRefreshLatestComments, onWorkModeChange, composerRef, composerAccessory, threadHeader, issueBrief, footer, feedbackVotes, feedbackDataSharingPreference, feedbackTermsUrl, agentMap, currentUserId, userLabelMap, userProfileMap, draftKey, reassignOptions, currentAssigneeValue, assigneeAdapterOverrides, suggestedAssigneeValue, mentions, conversationMode, composerPause, composerDisabledReason, composerHint, queuedCommentReason, onVote, onAdd, onImageUpload, onAttachImage, onInterruptQueued, onRemoveCommentFromCache, onDeleteComment, onPauseWorkRun, onStopResponse, stopResponsePending, pauseWorkPending, pauseWorkScope, runFinalizationActions, onCancelQueued, interruptingQueuedRunId, pausingWorkRunId, onImageClick, onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, onCancelInteraction, onSkipInteraction, onDismissInteraction, onSubmitInteractionVerdicts, assigneeUserId, onResumeFromBacklog, resumeFromBacklogPending, onResumeAssignee, resumeAssigneePending, onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending, externalReferences, linkCaseReferences, }: IssueDetailChatTabProps) { // Preserve master's Classic Task Interface seam: Streamlined UI changes the // TaskChatThread presentation but never swaps it for IssueChatThread. const { classicTaskInterfaceEnabled, streamlinedTaskDetailEnabled } = useTaskDetailInterfaceMode(!!conversationMode); const ThreadComponent = classicTaskInterfaceEnabled ? IssueChatThread : TaskChatThread; const designSandbox = useDesignSandbox(); const queryClient = useQueryClient(); const issueQueryRef = queryIssueId ?? issueId; const scrollLocation = useLocation(); const scrollNavigationType = useNavigationType(); const { pushToast } = useToastActions(); const { data: activity, isPending: activityPending, isError: activityError, refetch: refetchActivity, } = useQuery({ queryKey: historyExpanded ? queryKeys.issues.activity(issueQueryRef) : [...queryKeys.issues.activity(issueQueryRef), "recent"], queryFn: async () => { if (historyExpanded) return activityApi.forIssue(issueQueryRef); const recent = await activityApi.recentForIssue(issueQueryRef); const previous = queryClient.getQueryData([...queryKeys.issues.activity(issueQueryRef), "recent"]) ?? []; return [...new Map([...previous, ...recent].map((event) => [event.id, event])).values()]; }, enabled: !!issueId, placeholderData: historyExpanded ? queryClient.getQueryData([...queryKeys.issues.activity(issueQueryRef), "recent"]) : undefined, }); const inputCommentIds = comments.filter((comment) => !("clientStatus" in comment) || !comment.clientStatus).map((comment) => comment.id).sort(); const inputFacts = useIssueIdBatches( [...queryKeys.issues.activity(issueQueryRef), "comment-inputs"], inputCommentIds, ids => activityApi.inputFactsForIssue(issueQueryRef, ids), !!issueId && !historyExpanded, ); const { data: liveRuns, isFetched: liveRunsFetched, isError: liveRunsError, refetch: refetchLiveRuns, } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueQueryRef), queryFn: () => heartbeatsApi.liveRunsForIssue(issueQueryRef), enabled: !!issueId, refetchInterval: (query) => liveRunRefetchInterval(hasLiveConnection, query.state, 1000), placeholderData: keepPreviousDataForSameQueryTail(issueQueryRef), }); const resolvedLiveRuns = liveRuns ?? []; const liveRunCount = resolvedLiveRuns.length; const activeRunQueryEnabled = !!executionRunId || issueStatus === "in_progress"; const { data: activeRun = null, isFetched: activeRunFetched, isError: activeRunError, refetch: refetchActiveRun, } = useQuery({ queryKey: queryKeys.issues.activeRun(issueQueryRef), queryFn: () => heartbeatsApi.activeRunForIssue(issueQueryRef), enabled: activeRunQueryEnabled, refetchInterval: (query) => liveRunCount > 0 ? false : liveRunRefetchInterval(hasLiveConnection, query.state, 1000), placeholderData: keepPreviousDataForSameQueryTail( issueQueryRef, ), }); const resolvedActiveRun = useMemo( () => resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun, liveRuns), [activeRun, executionRunId, issueStatus, liveRuns], ); const assigneeUsesPaperclipRunner = Boolean( issueAssigneeAgentId && agentMap.get(issueAssigneeAgentId)?.adapterType === "paperclip_runner", ); const liveRuntimeRun = resolvedActiveRun ?? resolvedLiveRuns.find( (run) => run.status === "running" || run.status === "queued", ) ?? null; // Do not briefly select queue behavior from the current assignee while the // authoritative active-run lookup is still loading. The active runtime owns // the protocol: native Paperclip turns can steer in place, while legacy // adapters expose the same composer queue with an interrupt fallback. const runtimeSelectionKnown = liveRunsFetched && (!activeRunQueryEnabled || activeRunFetched); const queuedCommentQueueEnabled = !classicTaskInterfaceEnabled && runtimeSelectionKnown && Boolean(liveRuntimeRun || issueAssigneeAgentId); const { data: authoritativeQueuedCommentQueue } = useQuery({ queryKey: queryKeys.issues.queuedComments(issueId), queryFn: async () => normalizeIssueQueuedCommentQueue( await issuesApi.getQueuedComments(issueId), issueId, ), enabled: queuedCommentQueueEnabled, refetchInterval: (query) => queuedCommentQueueEnabled && (liveRuntimeRun || query.state.data?.entries.length) ? 1000 : false, }); const [consumedQueuedCommentIds, setConsumedQueuedCommentIds] = useState< ReadonlySet >(() => new Set()); const [discardedQueuedCommentIds, setDiscardedQueuedCommentIds] = useState< ReadonlySet >(() => new Set()); const [localSteeringPlacements, setLocalSteeringPlacements] = useState< ReadonlyMap< string, { targetRunId: string; anchorAt: string; sequence: number; comment?: IssueDetailComment; } > >(() => new Map()); const [localInterruptPlacements, setLocalInterruptPlacements] = useState< ReadonlyMap< string, { anchorAt: string; sequence: number; comment?: IssueDetailComment } > >(() => new Map()); const [classicQueuedDeliveryError, setClassicQueuedDeliveryError] = useState(null); useEffect(() => { setConsumedQueuedCommentIds(new Set()); setDiscardedQueuedCommentIds(new Set()); }, [issueId]); useEffect(() => { setLocalSteeringPlacements(new Map()); setLocalInterruptPlacements(new Map()); setClassicQueuedDeliveryError(null); }, [issueId]); const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun; const linkedRunsKey = historyExpanded ? queryKeys.issues.runs(issueQueryRef) : [...queryKeys.issues.runs(issueQueryRef), "recent-window"]; const { data: linkedRunHistory, isPending: linkedRunsPending, isError: linkedRunsError, refetch: refetchLinkedRuns, } = useQuery({ queryKey: linkedRunsKey, queryFn: async () => { if (historyExpanded) return activityApi.runsForIssue(issueQueryRef); let recent = await activityApi.recentRunsForIssue(issueQueryRef); const previousWindow = queryClient.getQueryData(linkedRunsKey); let previous = previousWindow?.runs ?? []; let reconciledFullHistory = false; if (previous.some((run) => (run.status === "running" || run.status === "queued") && !recent.some((next) => next.runId === run.runId), )) { // After a reconnect, several turns may have completed beyond the small // window. Reconcile watched runs once against authoritative summaries. recent = conversationMode ? await activityApi.runsByIdsForIssue(issueQueryRef, [...new Set([...previous.map(run => run.runId), ...recent.map(run => run.runId)])]) : await activityApi.runsForIssue(issueQueryRef); reconciledFullHistory = true; const existingRunIds = new Set(recent.map((run) => run.runId)); previous = previous.filter((run) => existingRunIds.has(run.runId)); } // The second row refreshes a just-finished predecessor. Start with only // the latest run, then keep every run observed while this task is open. const visible = new Set([...previous.map((run) => run.runId), ...recent.slice(0, 1).map((run) => run.runId)]); return { runs: [...new Map([...previous, ...recent].filter((run) => visible.has(run.runId)).map((run) => [run.runId, run])).values()], // A first run is already visible. The disclosure needs evidence of an // omitted run, and must retain that evidence as later turns arrive. hasEarlierRuns: recent.some((run) => !visible.has(run.runId)) || (!reconciledFullHistory && recent.length >= 2 && previousWindow?.hasEarlierRuns === true), }; }, enabled: !!issueId, refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 1000 : false, placeholderData: historyExpanded ? queryClient.getQueryData([...queryKeys.issues.runs(issueQueryRef), "recent-window"])?.runs : undefined, }); const observedRunIds = useMemo(() => [...new Set([ ...comments.flatMap(comment => [comment.createdByRunId, comment.derivedCreatedByRunId]), ...(inputFacts.data ?? []).flatMap(event => [event.runId, event.details?.targetRunId]), ].filter((id): id is string => typeof id === "string" && isUuidLike(id)))].sort(), [comments, inputFacts.data]); const commentRuns = useIssueIdBatches( [...queryKeys.issues.runs(issueQueryRef), "comment-window"], observedRunIds, ids => activityApi.runsByIdsForIssue(issueQueryRef, ids), !!issueId && !!conversationMode && !historyExpanded, ); const linkedRuns = useMemo(() => [...new Map([ ...(Array.isArray(linkedRunHistory) ? linkedRunHistory : linkedRunHistory?.runs ?? []), ...(conversationMode && !historyExpanded ? commentRuns.data ?? [] : []), ].map(run => [run.runId, run])).values()], [linkedRunHistory, conversationMode, historyExpanded, commentRuns.data]); const hasEarlierRuns = !Array.isArray(linkedRunHistory) && linkedRunHistory?.hasEarlierRuns === true; const resolvedActivity = useMemo( () => [...new Map([...(activity ?? []), ...(inputFacts.data ?? [])].map((event) => [event.id, event])).values()], [activity, inputFacts.data], ); const resolvedLinkedRuns = linkedRuns ?? []; useEffect(() => { recordIssueDetailReadiness({ activity: !activityPending, "linked-runs": !linkedRunsPending, runtime: runtimeSelectionKnown }); }, [issueId, issueQueryRef, activityPending, linkedRunsPending, runtimeSelectionKnown]); const retryFailedRun = useMutation({ mutationFn: async (runId: string) => { const failedRun = resolvedLinkedRuns.find((run) => run.runId === runId); if (!failedRun) throw new Error("Failed run is no longer available."); return agentsApi.retryFailedRun( failedRun.agentId, failedRun.runId, companyId, ); }, onSuccess: (result) => { if (!result.runId) { pushToast({ title: "Retry queued", body: "The exact request will retry when this task is ready.", tone: "success", }); } }, onSettled: () => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueQueryRef), }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueQueryRef), }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueQueryRef), }); }, onError: (error) => { pushToast({ title: "Run retry failed", body: error instanceof Error ? error.message : "Unable to retry run", tone: "error", }); }, }); const interruptibleIssueRun = useMemo( () => resolveInterruptibleIssueRun(resolvedActiveRun, resolvedLiveRuns), [resolvedActiveRun, resolvedLiveRuns], ); const currentRunAgent = interruptibleIssueRun ? agentMap.get(interruptibleIssueRun.agentId) : null; const grokWork = currentRunAgent?.adapterType === "paperclip_runner" && currentRunAgent.adapterConfig.provider === "grok_bot"; const stopGrokWork = useMutation({ mutationFn: (run: { id: string; agentId: string }) => grokBotApi.stop(companyId, run.agentId, run.id), onSettled: (_data, _error, run) => Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId) }), queryClient.invalidateQueries({ queryKey: ["grok-binding", companyId, run.agentId] }), ]), }); 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 = interruptibleIssueRun?.startedAt ?? interruptibleIssueRun?.createdAt ?? null; const runMetaByCommentId = new Map< string, { runId: string; runAgentId: string | null; interruptedRunId: string | null; } >(); const followUpCommentIds = new Set(); const agentIdByRunId = new Map(); const inputPlacementByCommentId = new Map< string, { runId: string; anchorAt: string; sequence: number; anchorMs: number; kind: "run_start" | "steer"; queueIds: Set; } >(); for (const run of resolvedLinkedRuns) { agentIdByRunId.set(run.runId, run.agentId); if (run.status === "queued" || !run.startedAt) continue; const batchedIds = Array.isArray(run.wakeCommentIds) ? run.wakeCommentIds.filter( (value): value is string => typeof value === "string" && value.length > 0, ) : []; const fallbackId = typeof run.wakeCommentId === "string" && run.wakeCommentId.length > 0 ? run.wakeCommentId : typeof run.contextCommentId === "string" && run.contextCommentId.length > 0 ? run.contextCommentId : null; const inputIds = batchedIds.length > 0 ? batchedIds : fallbackId ? [fallbackId] : []; const anchorAt = run.startedAt ?? run.createdAt; const anchorMs = new Date(anchorAt).getTime(); inputIds.forEach((commentId, sequence) => { const existing = inputPlacementByCommentId.get(commentId); const queueIds = existing?.queueIds ?? new Set(); if (run.wakeupRequestId) queueIds.add(run.wakeupRequestId); if (Array.isArray(run.queuedCommentSourceWakeIds)) { for (const queueId of run.queuedCommentSourceWakeIds) queueIds.add(queueId); } if (existing && existing.anchorMs <= anchorMs) return; inputPlacementByCommentId.set(commentId, { runId: run.runId, anchorAt, sequence, anchorMs, kind: "run_start", queueIds, }); }); } for (const run of [resolvedActiveRun, ...resolvedLiveRuns]) { if (!run || run.status !== "running" || !run.startedAt) continue; const anchorAt = new Date(run.startedAt).toISOString(); for (const commentId of [run.contextCommentId, run.contextWakeCommentId]) { if (!commentId) continue; const existing = inputPlacementByCommentId.get(commentId); const queueIds = existing?.queueIds ?? new Set(); if (run.wakeupRequestId) queueIds.add(run.wakeupRequestId); if (Array.isArray(run.queuedCommentSourceWakeIds)) { for (const queueId of run.queuedCommentSourceWakeIds) queueIds.add(queueId); } if (existing) continue; inputPlacementByCommentId.set(commentId, { runId: run.id, anchorAt, sequence: 0, anchorMs: new Date(anchorAt).getTime(), kind: "run_start", queueIds, }); } } // Same-turn steering has a durable PRP acknowledgement and a matching // activity fact keyed by commentId/targetRunId. Its acknowledgement time, // not the comment submission time, is the causal conversation slot. Use // only the first acknowledgement; an idempotent replay emits a diagnostic // activity row with duplicate=true but must not move the message later. const steeringEvents = resolvedActivity .filter((evt) => evt.action === "issue.queued_comment_steered") .sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); const steeringSequenceByRunId = new Map(); for (const evt of steeringEvents) { const details = evt.details ?? {}; if (details["duplicate"] === true) continue; const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; const targetRunId = typeof details["targetRunId"] === "string" ? details["targetRunId"] : null; if (!commentId || !targetRunId) continue; const anchorAt = evt.createdAt instanceof Date ? evt.createdAt.toISOString() : evt.createdAt; const anchorMs = new Date(anchorAt).getTime(); if (!Number.isFinite(anchorMs)) continue; const sequence = steeringSequenceByRunId.get(targetRunId) ?? 0; steeringSequenceByRunId.set(targetRunId, sequence + 1); inputPlacementByCommentId.set(commentId, { runId: targetRunId, anchorAt, sequence, anchorMs, kind: "steer", queueIds: inputPlacementByCommentId.get(commentId)?.queueIds ?? new Set(), }); } for (const [commentId, placement] of localSteeringPlacements) { if (inputPlacementByCommentId.has(commentId)) continue; inputPlacementByCommentId.set(commentId, { runId: placement.targetRunId, anchorAt: placement.anchorAt, sequence: placement.sequence, anchorMs: new Date(placement.anchorAt).getTime(), kind: "steer", queueIds: new Set(), }); } 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); } } // The queue can contain a saved message outside the loaded history page. // Keep its optimistic bubble until the canonical comment is available. const commentsToProject = [...comments]; const loadedCommentIds = new Set(comments.map((comment) => comment.id)); for (const placement of [ ...localSteeringPlacements.values(), ...localInterruptPlacements.values(), ]) { if (placement.comment && !loadedCommentIds.has(placement.comment.id)) { commentsToProject.push(placement.comment); loadedCommentIds.add(placement.comment.id); } } const projectedComments = commentsToProject.map((comment) => { if (comment.metadata?.chatMessage) return { ...comment, runId: null }; const activityMeta = runMetaByCommentId.get(comment.id); // Internal run finalization can persist a reply without a separate // comment_added activity row. Its durable authoring run is stronger // evidence than activity projection, and lets the transcript's chosen // completion comment own the answer after a refresh. const authoredRunId = comment.authorType === "agent" && comment.authorAgentId ? comment.createdByRunId : null; const meta = authoredRunId ? { runId: authoredRunId, runAgentId: comment.authorAgentId, interruptedRunId: activityMeta?.runId === authoredRunId ? activityMeta.interruptedRunId : null, } : activityMeta; const inputPlacement = inputPlacementByCommentId.get(comment.id); const submittedAtMs = new Date(comment.createdAt).getTime(); // Older activity rows may predate the explicit followUpRequested flag. // A run-start input is a provable queued follow-up only when it was // submitted during a completed run for this issue and the same agent, // before the target run consumed it. Merely overlapping any linked run // is not enough: issue activity can link otherwise unrelated runs. const targetRun = inputPlacement?.kind === "run_start" ? resolvedLinkedRuns.find((run) => run.runId === inputPlacement.runId) : undefined; const targetStartedAtMs = targetRun ? new Date(targetRun.startedAt ?? targetRun.createdAt).getTime() : Number.NaN; const submittedDuringSourceRun = Boolean( targetRun?.contextIssueId === issueId && Number.isFinite(targetStartedAtMs) && Number.isFinite(submittedAtMs) && resolvedLinkedRuns.some((run) => { if ( run.runId === targetRun.runId || run.agentId !== targetRun.agentId || run.contextIssueId !== issueId || !run.finishedAt ) { return false; } const startedAtMs = new Date( run.startedAt ?? run.createdAt, ).getTime(); const finishedAtMs = new Date(run.finishedAt).getTime(); return ( Number.isFinite(startedAtMs) && Number.isFinite(finishedAtMs) && startedAtMs <= submittedAtMs && submittedAtMs <= finishedAtMs && finishedAtMs <= targetStartedAtMs ); }), ); const nextComment: IssueDetailComment = { ...comment, ...(meta ?? {}), ...(inputPlacement ? { consumedByRunId: inputPlacement.runId, consumedQueueIds: [...inputPlacement.queueIds], ...(inputPlacement.kind === "steer" ? { steeredIntoRunId: inputPlacement.runId } : {}), conversationAnchorAt: inputPlacement.anchorAt, conversationAnchorSequence: inputPlacement.sequence, } : {}), }; if (followUpCommentIds.has(comment.id) || submittedDuringSourceRun) { nextComment.followUpRequested = true; } const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? nextComment.queueTargetRunId ?? null; if (inputPlacement) { return { ...nextComment, clientStatus: undefined, queueState: undefined, queueTargetRunId: null, queueReason: undefined, }; } const locallyQueuedComment = applyLocalQueuedIssueCommentState( nextComment, { queuedTargetRunId, targetRunIsLive: queuedTargetRunId ? liveRunIds.has(queuedTargetRunId) : false, runningRunId: interruptibleIssueRun?.id ?? null, }, ); if (locallyQueuedComment !== nextComment) { return locallyQueuedComment; } // A queued target is fixed when the message is submitted. If that run // settles while the request is still in flight, do not rebind the // message's Interrupt action to an unrelated run that became live later. if (queuedTargetRunId) { return nextComment; } if ( isQueuedIssueComment({ comment: nextComment, activeRunStartedAt, activeRunAgentId: interruptibleIssueRun?.agentId ?? null, activeRunCommentId: interruptibleIssueRun?.contextCommentId ?? null, activeRunWakeCommentId: interruptibleIssueRun?.contextWakeCommentId ?? null, runId: meta?.runId ?? nextComment.runId ?? null, interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null, }) ) { return { ...nextComment, queueState: "queued" as const, queueTargetRunId: interruptibleIssueRun?.id ?? null, queueReason: queuedCommentReason, }; } return nextComment; }); const questionDeliveryByInteractionId = new Map< string, { targetRunId: string; deliveryMode: string | null } >(); for (const event of resolvedActivity) { if (event.action !== "issue.question_response_delivered") continue; const details = event.details ?? {}; const interactionId = typeof details["interactionId"] === "string" ? details["interactionId"] : null; const targetRunId = typeof details["targetRunId"] === "string" ? details["targetRunId"] : null; if ( !interactionId || !targetRunId || questionDeliveryByInteractionId.has(interactionId) ) { continue; } questionDeliveryByInteractionId.set(interactionId, { targetRunId, deliveryMode: typeof details["deliveryMode"] === "string" ? details["deliveryMode"] : null, }); } const responseComments = classicTaskInterfaceEnabled ? [] : interactions.flatMap((interaction): IssueDetailComment[] => { const answeredQuestions = interaction.kind === "ask_user_questions" && interaction.status === "answered"; const resolvedPlanDecision = isPlanConfirmationInteraction(interaction) && (interaction.status === "accepted" || interaction.status === "rejected"); if ( (!answeredQuestions && !resolvedPlanDecision) || !interaction.resolvedAt ) { return []; } const resolvedAt = interaction.resolvedAt instanceof Date ? interaction.resolvedAt : new Date(interaction.resolvedAt); if (Number.isNaN(resolvedAt.getTime())) return []; const delivery = answeredQuestions ? (questionDeliveryByInteractionId.get(interaction.id) ?? null) : null; const queuedTargetRunId = interaction.sourceRunId && interruptibleIssueRun?.id === interaction.sourceRunId && interruptibleIssueRun.adapterType !== "paperclip_runner" ? interaction.sourceRunId : null; const body = interaction.kind === "ask_user_questions" ? buildAnsweredQuestionsDeliveryText(interaction) : isPlanConfirmationInteraction(interaction) ? buildPlanDecisionResponseText(interaction) : ""; return [ { id: `interaction-response:${interaction.id}`, companyId: interaction.companyId, issueId: interaction.issueId, authorType: interaction.resolvedByAgentId ? "agent" : "user", authorAgentId: interaction.resolvedByAgentId ?? null, authorUserId: interaction.resolvedByUserId ?? null, createdByRunId: interaction.resolvedByRunId ?? null, body, presentation: null, metadata: null, createdAt: resolvedAt, updatedAt: resolvedAt, consumedByRunId: delivery?.targetRunId ?? null, ...(delivery?.deliveryMode === "steered" ? { steeredIntoRunId: delivery.targetRunId } : {}), conversationAnchorAt: resolvedAt, conversationAnchorSequence: 0, ...(queuedTargetRunId ? { queueState: "queued" as const, queueTargetRunId: queuedTargetRunId, queueReason: queuedCommentReason, } : {}), }, ]; }); return [...projectedComments, ...responseComments]; }, [ comments, classicTaskInterfaceEnabled, interactions, liveRunIds, localSteeringPlacements, localInterruptPlacements, locallyQueuedCommentRunIds, queuedCommentReason, resolvedActivity, resolvedLinkedRuns, resolvedActiveRun, resolvedLiveRuns, interruptibleIssueRun, ]); const startingCommentIds = useMemo(() => startingQueuedCommentIds(authoritativeQueuedCommentQueue, resolvedLinkedRuns), [authoritativeQueuedCommentQueue, resolvedLinkedRuns]); const effectiveQueuedCommentQueue = useMemo(() => { if (!queuedCommentQueueEnabled) return null; const deliveredCommentRunIds = new Map(); const deliveredCommentQueueIds = new Map(); for (const comment of commentsWithRunMeta) { if (comment.consumedByRunId) { deliveredCommentRunIds.set(comment.id, comment.consumedByRunId); deliveredCommentQueueIds.set(comment.id, comment.consumedQueueIds ?? []); } } const visibleAuthoritativeQueue = authoritativeQueuedCommentQueue ? { ...authoritativeQueuedCommentQueue, entries: authoritativeQueuedCommentQueue.entries.filter( (entry) => !consumedQueuedCommentIds.has(entry.comment.id) && !discardedQueuedCommentIds.has(entry.comment.id) && !deliveredCommentQueueIds.get(entry.comment.id)?.includes( authoritativeQueuedCommentQueue.queueId ?? "", ) && deliveredCommentRunIds.get(entry.comment.id) !== authoritativeQueuedCommentQueue.targetRunId, ), } : null; const pendingComments = commentsWithRunMeta.flatMap((comment) => { if ( consumedQueuedCommentIds.has(comment.id) || deliveredCommentRunIds.has(comment.id) || discardedQueuedCommentIds.has(comment.id) || comment.steeredIntoRunId ) { return []; } const targetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? ("clientStatus" in comment && comment.clientStatus === "queued" ? (comment.queueTargetRunId ?? null) : comment.queueState === "queued" ? (comment.queueTargetRunId ?? null) : null); return targetRunId ? [{ comment, targetRunId }] : []; }); const fallbackProtocol = liveRuntimeRun?.adapterType === "paperclip_runner" || (!liveRuntimeRun && assigneeUsesPaperclipRunner) ? "paperclip_runner_v1" : "legacy"; return mergePendingIssueQueuedComments({ issueId, authoritativeQueue: visibleAuthoritativeQueue, pendingComments, fallbackProtocol, }); }, [ authoritativeQueuedCommentQueue, commentsWithRunMeta, consumedQueuedCommentIds, discardedQueuedCommentIds, issueId, liveRuntimeRun, assigneeUsesPaperclipRunner, locallyQueuedCommentRunIds, queuedCommentQueueEnabled, ]); const visibleQueuedCommentQueue = useMemo(() => { if (!effectiveQueuedCommentQueue) return null; const entries = effectiveQueuedCommentQueue.entries .filter(entry => !startingCommentIds.has(entry.comment.id)) .map((entry, position) => ({ ...entry, position })); return entries.length ? { ...effectiveQueuedCommentQueue, entries } : null; }, [effectiveQueuedCommentQueue, startingCommentIds]); const commentsForThread = useMemo( () => [...commentsWithRunMeta, ...(authoritativeQueuedCommentQueue?.entries ?? []) .filter(entry => startingCommentIds.has(entry.comment.id) && !commentsWithRunMeta.some(comment => comment.id === entry.comment.id)) .map((entry): IssueDetailComment => entry.comment)].flatMap((comment) => { if (discardedQueuedCommentIds.has(comment.id)) return []; if (!consumedQueuedCommentIds.has(comment.id) && !startingCommentIds.has(comment.id)) return [comment]; return [ { ...comment, ...(!comment.consumedByRunId && localInterruptPlacements.has(comment.id) ? { conversationAnchorAt: localInterruptPlacements.get(comment.id)!.anchorAt, conversationAnchorSequence: localInterruptPlacements.get(comment.id)!.sequence, } : {}), clientStatus: undefined, queueState: undefined, queueTargetRunId: null, queueReason: undefined, }, ]; }), [ commentsWithRunMeta, consumedQueuedCommentIds, discardedQueuedCommentIds, localInterruptPlacements, authoritativeQueuedCommentQueue, startingCommentIds, ], ); const storeQueuedCommentQueue = useCallback( (value: unknown) => { const queue = normalizeIssueQueuedCommentQueue(value, issueId); queryClient.setQueryData(queryKeys.issues.queuedComments(issueId), queue); return queue; }, [issueId, queryClient], ); const refreshQueueAfterConflict = useCallback( async (error: unknown) => { if (error instanceof ApiError && error.status === 409) { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.queuedComments(issueId), }); } throw error; }, [issueId, queryClient], ); const editQueuedComment = useCallback( async (commentId: string, body: string, revision: string) => { const queueId = effectiveQueuedCommentQueue?.queueId; if (!queueId) throw new Error( "The queued message is awaiting server acknowledgement.", ); try { storeQueuedCommentQueue( await issuesApi.editQueuedComment(issueId, commentId, { body, queueId, revision, }), ); await queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId), }); } catch (error) { await refreshQueueAfterConflict(error); } }, [ effectiveQueuedCommentQueue?.queueId, issueId, queryClient, refreshQueueAfterConflict, storeQueuedCommentQueue, ], ); const reorderQueuedComments = useCallback( async (orderedCommentIds: string[], revision: string) => { const queueId = effectiveQueuedCommentQueue?.queueId; if (!queueId) throw new Error( "The queued messages are awaiting server acknowledgement.", ); try { let nextPosition = 0; const fullOrder = effectiveQueuedCommentQueue.entries.map(entry => startingCommentIds.has(entry.comment.id) ? entry.comment.id : orderedCommentIds[nextPosition++]!); storeQueuedCommentQueue( await issuesApi.reorderQueuedComments(issueId, { orderedCommentIds: fullOrder, queueId, revision, }), ); } catch (error) { await refreshQueueAfterConflict(error); } }, [ effectiveQueuedCommentQueue, startingCommentIds, issueId, refreshQueueAfterConflict, storeQueuedCommentQueue, ], ); const steerQueuedComment = useCallback( async (commentId: string, revision: string) => { const queueId = effectiveQueuedCommentQueue?.queueId; const targetRunId = effectiveQueuedCommentQueue?.targetRunId; if (!queueId || !targetRunId) throw new Error( "The queued message no longer has an active run target.", ); const anchorAt = new Date().toISOString(); setLocalSteeringPlacements((current) => { const next = new Map(current); const sequence = [...current.values()].filter( (placement) => placement.targetRunId === targetRunId, ).length; next.set(commentId, { targetRunId, anchorAt, sequence, comment: effectiveQueuedCommentQueue?.entries.find( (entry) => entry.comment.id === commentId, )?.comment, }); return next; }); setConsumedQueuedCommentIds((current) => new Set(current).add(commentId)); try { const nextQueue = await issuesApi.steerQueuedComment( issueId, commentId, { queueId, targetRunId, revision, }, ); // The local placement bridges the durable receipt refresh. Release the // control as soon as delivery is acknowledged, even on a slow refetch. storeQueuedCommentQueue(nextQueue); void Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId), }), queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueQueryRef), }), ]); } catch (error) { setConsumedQueuedCommentIds((current) => { const next = new Set(current); next.delete(commentId); return next; }); setLocalSteeringPlacements((current) => { const next = new Map(current); next.delete(commentId); return next; }); await refreshQueueAfterConflict(error); } }, [ effectiveQueuedCommentQueue, issueId, issueQueryRef, queryClient, refreshQueueAfterConflict, storeQueuedCommentQueue, ], ); const interruptQueuedComments = useCallback( async (runId: string | null) => { const queuedComments = effectiveQueuedCommentQueue?.targetRunId === runId ? effectiveQueuedCommentQueue.entries.map((entry) => entry.comment) : commentsWithRunMeta.filter( (comment) => comment.queueState === "queued" && comment.queueTargetRunId === runId, ); const ids = queuedComments.map((comment) => comment.id); const anchorAt = new Date().toISOString(); setClassicQueuedDeliveryError(null); setConsumedQueuedCommentIds((current) => new Set([...current, ...ids])); setLocalInterruptPlacements((current) => { const next = new Map(current); queuedComments.forEach((comment, sequence) => { next.set(comment.id, { anchorAt, sequence, comment }); }); return next; }); try { const updatedQueue = await onInterruptQueued(runId); if (updatedQueue?.state === "deferred") { const remaining = new Set(updatedQueue.entries.map(entry => entry.comment.id)); setConsumedQueuedCommentIds(current => new Set([...current].filter(id => !remaining.has(id)))); setLocalInterruptPlacements(current => new Map([...current].filter(([id]) => !remaining.has(id)))); storeQueuedCommentQueue(updatedQueue); if (classicTaskInterfaceEnabled) setClassicQueuedDeliveryError(updatedQueue.executionWait?.message ?? "Messages are still queued."); } return updatedQueue; } catch (error) { setConsumedQueuedCommentIds((current) => { const next = new Set(current); for (const id of ids) next.delete(id); return next; }); setLocalInterruptPlacements((current) => { const next = new Map(current); for (const id of ids) next.delete(id); return next; }); if (classicTaskInterfaceEnabled) { setClassicQueuedDeliveryError("Couldn’t interrupt. Message is still queued."); return; } await refreshQueueAfterConflict(error); throw error; } }, [ effectiveQueuedCommentQueue, commentsWithRunMeta, onInterruptQueued, storeQueuedCommentQueue, refreshQueueAfterConflict, classicTaskInterfaceEnabled, ], ); const discardQueuedComment = useCallback( async (commentId: string, revision: string) => { const queueId = effectiveQueuedCommentQueue?.queueId; if (!queueId) throw new Error( "The queued message is awaiting server acknowledgement.", ); try { storeQueuedCommentQueue( await issuesApi.discardQueuedComment(issueId, commentId, { queueId, revision, }), ); setDiscardedQueuedCommentIds((current) => new Set(current).add(commentId), ); // Discard deletes the persisted comment. Update both history caches // and task aliases so the row stays removed after this view unmounts. await onRemoveCommentFromCache(commentId); await queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId), }); } catch (error) { const responseBody = error instanceof ApiError && typeof error.body === "object" && error.body !== null ? (error.body as { code?: unknown; details?: unknown }) : null; const details = responseBody && typeof responseBody.details === "object" && responseBody.details !== null ? (responseBody.details as { code?: unknown }) : null; const code = typeof details?.code === "string" ? details.code : typeof responseBody?.code === "string" ? responseBody.code : null; if (code === "queued_comment_already_dispatching") { pushToast({ title: "Message is already being sent", body: "The continuation started before the discard was confirmed, so Paperclip could not unsend it.", tone: "error", ttlMs: 15_000, dedupeKey: `queued-comment-already-dispatching:${issueId}:${commentId}`, }); } await refreshQueueAfterConflict(error); } }, [ effectiveQueuedCommentQueue?.queueId, issueId, onRemoveCommentFromCache, pushToast, queryClient, refreshQueueAfterConflict, storeQueuedCommentQueue, ], ); const timelineEvents = useMemo( () => extractIssueTimelineEvents(resolvedActivity), [resolvedActivity], ); const workModeChanges = useMemo( () => extractIssueWorkModeChanges(resolvedActivity), [resolvedActivity], ); const scrollingHistory = conversationMode ? { hasMore: !!hasOlderComments, loading: commentsLoadingOlder, error: commentsOlderError, load: onLoadOlderComments, } : undefined; const loadOlderButton = !conversationMode && hasOlderComments ? (
) : null; const savedThread = (
{/* Chat-style: the button rides inside the thread's scroll viewport with the header so nothing sits above the thread in the page flow. */} {classicTaskInterfaceEnabled ? loadOlderButton : null} {classicTaskInterfaceEnabled && commentsInitialLoading && commentsWithRunMeta.length === 0 && interactions.length === 0 ? ( ) : ( More messages ) : null, } : {})} executionBlocked={executionBlocked} interruption={interruption} retryPreparing={retryPreparing} key={conversationMode ? draftKey : issueId} {...(!classicTaskInterfaceEnabled ? { creationActivity: resolvedActivity, createdTasks } : {})} onOpenSkill={onOpenSkill} browsers={browsers} onOpenBrowser={onOpenBrowser} hasOlderComments={hasOlderComments} initialHistoryPending={!!issueId && ( initialHistoryPending || commentsInitialLoading || // These responses add rows or change the composer takeover. // Coordinate the first reveal so each response does not move a // conversation the reader has already started looking at. initialMetadataPending || activityPending || linkedRunsPending || (conversationMode && observedRunIds.length > 0 && commentRuns.isPending) || inputFacts.isLoading || !runtimeSelectionKnown) } initialHistoryError={ initialHistoryError || activityError || (!historyExpanded && inputFacts.isError) || linkedRunsError || (conversationMode && commentRuns.isError) || liveRunsError || (activeRunQueryEnabled && activeRunError) } onRetryInitialHistory={() => { onRetryInitialHistory?.(); void refetchActivity(); if (!historyExpanded && inputCommentIds.length) void inputFacts.refetch(); void refetchLinkedRuns(); if (conversationMode && observedRunIds.length) void commentRuns.refetch(); void refetchLiveRuns(); if (activeRunQueryEnabled) void refetchActiveRun(); }} composerRef={composerRef} composerAccessory={<>{composerAccessory}} threadHeader={ !classicTaskInterfaceEnabled && (threadHeader || loadOlderButton) ? ( <> {threadHeader} {loadOlderButton} ) : null } issueBrief={issueBrief} comments={commentsForThread} interactions={interactions} documents={documents} workProducts={workProducts} attachments={attachments} feedbackVotes={feedbackVotes} feedbackDataSharingPreference={feedbackDataSharingPreference} feedbackTermsUrl={feedbackTermsUrl} linkedRuns={timelineRuns} onRetryFailedRun={(runId) => retryFailedRun.mutateAsync(runId).then(() => undefined) } retryFailedRunId={ retryFailedRun.isPending ? retryFailedRun.variables : null } timelineEvents={timelineEvents} workModeChanges={workModeChanges} liveRuns={resolvedLiveRuns} activeRun={resolvedActiveRun} issueId={issueId} blockedBy={blockedBy ?? []} liveIssueIds={liveIssueIds} blockerAttention={blockerAttention} successfulRunHandoff={successfulRunHandoff} scheduledRetry={scheduledRetry} recoveryAction={recoveryAction?.cause === "native_workspace_finalization_owner_unverified" ? null : recoveryAction ?? null} onResolveRecoveryAction={onResolveRecoveryAction} onReissueIsolatedRecoveryAction={onReissueIsolatedRecoveryAction} reissueIsolatedRecoveryActionPending={ reissueIsolatedRecoveryActionPending } onReconcileForwardRecoveryAction={onReconcileForwardRecoveryAction} onBreakGlassOverrideRecoveryAction={ onBreakGlassOverrideRecoveryAction } onQuarantineRestoreRecoveryAction={ onQuarantineRestoreRecoveryAction } quarantineRestoreRecoveryActionPending={ quarantineRestoreRecoveryActionPending } canBreakGlassRecoveryAction={canBreakGlassRecoveryAction} reconcileRecoveryActionPending={reconcileRecoveryActionPending} canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction} legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null} companyId={companyId} projectId={projectId} issueStatus={issueStatus} issueAssigneeAgentId={issueAssigneeAgentId} agentMap={agentMap} currentUserId={currentUserId} userLabelMap={userLabelMap} userProfileMap={userProfileMap} draftKey={draftKey} conversationMode={conversationMode} enableReassign={!conversationMode} reassignOptions={reassignOptions} currentAssigneeValue={currentAssigneeValue} assigneeAdapterOverrides={assigneeAdapterOverrides} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentions} composerPause={composerPause} composerDisabledReason={composerDisabledReason} composerHint={composerHint} onVote={onVote} onAdd={onAdd} imageUploadHandler={onImageUpload} onAttachImage={onAttachImage} onInterruptQueued={interruptQueuedComments} queuedCommentQueue={visibleQueuedCommentQueue} onEditQueuedComment={editQueuedComment} onReorderQueuedComments={reorderQueuedComments} onSteerQueuedComment={steerQueuedComment} onDiscardQueuedComment={discardQueuedComment} onDeleteComment={onDeleteComment} onCancelQueued={onCancelQueued} interruptingQueuedRunId={interruptingQueuedRunId} stoppingRunId={ stopGrokWork.isPending ? stopGrokWork.variables.id : pauseWorkPending ? (pausingWorkRunId ?? interruptibleIssueRun?.id ?? null) : pausingWorkRunId } onStopRun={ grokWork || onPauseWorkRun ? async (runId) => { const selectedRun = resolvedLiveRuns.find(run => run.id === runId) ?? (resolvedActiveRun?.id === runId ? resolvedActiveRun : null); const selectedAgent = selectedRun ? agentMap.get(selectedRun.agentId) : null; if (selectedRun && selectedAgent?.adapterType === "paperclip_runner" && selectedAgent.adapterConfig.provider === "grok_bot") { await stopGrokWork.mutateAsync(selectedRun).catch(() => undefined); } else if (onPauseWorkRun) { await onPauseWorkRun(runId).catch(() => undefined); } } : undefined } stopRunLabel={grokWork ? "Stop Paperclip work" : "Pause work"} stoppingRunLabel={grokWork ? "Stopping Paperclip work…" : "Pausing..."} stopRunVariant={grokWork ? "stop" : "pause"} runFinalizationActions={runFinalizationActions} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} onSubmitInteractionAnswers={(interaction, answers) => onSubmitInteractionAnswers(interaction, answers) } onCancelInteraction={onCancelInteraction} onSkipInteraction={onSkipInteraction} onDismissInteraction={onDismissInteraction} onSubmitInteractionVerdicts={onSubmitInteractionVerdicts} issueWorkMode={issueWorkMode} onWorkModeChange={onWorkModeChange} stopPending={grokWork ? stopGrokWork.isPending : stopResponsePending} onCancelRun={ grokWork && interruptibleIssueRun ? () => stopGrokWork.mutateAsync(interruptibleIssueRun).then(() => undefined) : interruptibleIssueRun && onStopResponse ? () => onStopResponse(interruptibleIssueRun.id) : undefined } onImageClick={onImageClick} onRefreshLatestComments={onRefreshLatestComments} assigneeUserId={assigneeUserId} onResumeFromBacklog={onResumeFromBacklog} resumeFromBacklogPending={resumeFromBacklogPending} onResumeAssignee={onResumeAssignee} resumeAssigneePending={resumeAssigneePending} onTryAgainNoLiveExecutionPath={onTryAgainNoLiveExecutionPath} tryAgainNoLiveExecutionPathPending={ tryAgainNoLiveExecutionPathPending } footer={grokWork || stopGrokWork.error || classicQueuedDeliveryError ? <> {grokWork &&

Runs in Grok Bot · Usage not reported. Stop Paperclip work revokes this session’s authority; remote execution may continue. Open the Grok Bot desktop app to inspect it.

} {stopGrokWork.error &&

{describeError(stopGrokWork.error).body}

} {classicQueuedDeliveryError &&

{classicQueuedDeliveryError}

} {footer} : footer} externalReferences={externalReferences} linkCaseReferences={linkCaseReferences} />
)}
); return import.meta.env.DEV && designSandbox && !classicTaskInterfaceEnabled && !conversationMode ? ( {savedThread} ) : savedThread; }); type IssueDetailActivityTabProps = { issue: Issue; issueId: string; companyId: string; issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: Map; hasLiveRuns: boolean; currentUserId: string | null; userProfileMap: Map< string, import("../lib/company-members").CompanyUserProfile >; pendingApprovalAction: { approvalId: string; action: "approve" | "reject"; } | null; onApprovalAction: (approvalId: string, action: "approve" | "reject") => void; handoffFocusSignal?: number; externalReferences?: MarkdownExternalReferenceMap; }; function IssueDetailActivityTab({ issue, issueId, companyId, issueStatus, childIssues, agentMap, hasLiveRuns, currentUserId, userProfileMap, pendingApprovalAction, onApprovalAction, handoffFocusSignal = 0, externalReferences, }: 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< Awaited> >(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; } }, placeholderData: keepPreviousDataForSameQueryTail > | null>(issueId), }); const { data: issueTreeCostSummary } = useQuery({ queryKey: queryKeys.issues.costSummary(issueId), queryFn: () => issuesApi.getCostSummary(issueId), placeholderData: keepPreviousDataForSameQueryTail< Awaited> >(issueId), }); const initialLoading = (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined); const issueCostSummary = useMemo(() => { let input = 0; let output = 0; let cached = 0; let totalTokens = 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; totalTokens += visibleRunTokenTotal(usage); 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, hasCost, hasTokens, runtimeMs, runCount, hasRuntime: runtimeMs > 0, }; }, [linkedRuns]); const issueTreeCostTokens = (issueTreeCostSummary?.inputTokens ?? 0) + (issueTreeCostSummary?.cachedInputTokens ?? 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}
)}
)}
userProfileMap.get(userId)?.label ?? null } renderActivityEvent={(evt) => { 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)}
{/* Field-level who/what/why receipt for agent and board edits alike. */} agentMap.get(agentId)?.name ?? null } resolveUserLabel={(userId) => userProfileMap.get(userId)?.label ?? null } /> {/* A refused write explains itself here, not just in the API error. */} {(() => { const denial = issueWriteDenialForActivity( evt.action, evt.details, { actorLabel: evt.agentId ? (agentMap.get(evt.agentId)?.name ?? null) : null, responsibleUserName: evt.responsibleUserId ? (userProfileMap.get(evt.responsibleUserId)?.label ?? null) : null, }, ); return denial ? ( ) : null; })()}
); }} />
{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 } /> ))}
)} {/* Waiting-monitor state now lives in the pinned top banner (IssueMonitorBanner) — PAP-14557 decision 1. */} ); } export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"] }) { return ; } /** One controller and surface for both task URLs and agent conversations. */ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"]; conversation?: { agent: Agent; issue: Issue | null; ensureIssue: () => Promise; } }) { const { issueId: routeIssueId, companyPrefix } = useParams<{ issueId: string; companyPrefix: string }>(); const issueId = conversation ? conversation.issue?.id : routeIssueId; const [loadingTitleIssueId, setLoadingTitleIssueId] = useState(null); useEffect(() => setLoadingTitleIssueId(null), [issueId, companyPrefix]); const [draftWorkMode, setDraftWorkMode] = useState("standard"); const draftIssue = useMemo(() => conversation ? agentChatDraft(conversation.agent, draftWorkMode) : undefined, [conversation?.agent, draftWorkMode]); const pendingDraftWorkMode = useRef(null); const { companies, selectedCompanyId } = useCompany(); // Classic Task Interface remains the sole task-chat-vs-pre-chat switch from // master. Streamlined UI only layers the new task-detail presentation onto // master's default task-chat shell. const { classicTaskInterfaceEnabled, taskChatShellEnabled, streamlinedTaskDetailEnabled, streamlinedUiEnabled, loaded: taskInterfaceSettingsLoaded, coordinatorWorkspaceEnabled, } = useTaskDetailInterfaceMode(!!conversation); const designSandbox = useDesignSandbox(); const proposedTaskHeader = (designSandbox || coordinatorWorkspaceEnabled) && streamlinedTaskDetailEnabled; // Chat-style: the page wrapper spans the full center pane so the thread's // scroll viewport (and its scrollbar) reaches the properties-pane border; // every non-thread section re-centers itself at the 60rem shell cap instead. const shellSectionClass = taskChatShellEnabled ? "mx-auto w-full max-w-(--tc-shell-max-w)" : undefined; const { openNewIssue } = useDialogActions(); const { openPanel, closePanel, panelVisible, setPanelVisible, requestPanelMaximize, clearPanelMaximizeRequest, } = usePanel(); const { setBreadcrumbs, setBreadcrumbToolbar, setBreadcrumbPanelControl, 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 [artifactsOpenRequest, setArtifactsOpenRequest] = useState<{ issueId: string; requestId: number; handled?: boolean; } | null>(null); const [openAttachment, setOpenAttachment] = useState<{ issueId: string; id: string; title: string; requestId: number } | null>(null); const [openSkill, setOpenSkill] = useState<{ id: string; name: string } | null>(null); const handleSkillOpened = useCallback((skillId: string) => { setOpenSkill((current) => current?.id === skillId ? null : current); }, []); const [documentDeepLink, setDocumentDeepLink] = useState< (IssuePropertiesDocumentDeepLink & { issueId: string }) | null >(null); const [fileViewerPromptOpen, setFileViewerPromptOpen] = useState(false); const [detailTab, setDetailTab] = useState("chat"); // Redesign: the center tab strip is hidden, so chat is the only surface — // deep links that would switch tabs (e.g. #document- hashes) stay on chat. const resolvedDetailTab = taskChatShellEnabled ? "chat" : detailTab; 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 [treeControlWakeWarning, setTreeControlWakeWarning] = useState< string | null >(null); const [treeControlMode, setTreeControlMode] = useState>("resume"); const [treeControlWakeAgentsOnResume, setTreeControlWakeAgentsOnResume] = useState(false); const [optimisticComments, setOptimisticComments] = useState< OptimisticIssueComment[] >([]); const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState< Map >(() => new Map()); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); const fileInputRef = useRef(null); const readReceiptRef = useRef<{ issueId: string; account: ReturnType; attemptedSessionAt?: number; confirmed: boolean; } | null>(null); const pendingReadReceiptsRef = useRef(new Map>()); const [readReceiptRevision, setReadReceiptRevision] = useState(0); const lastScrollIssueIdRef = useRef(undefined); const commentComposerRef = useRef(null); const cancelledQueuedOptimisticCommentIdsRef = useRef(new Set()); const commentRenderKeys = useRef(new Map()); const resolvedIssueDetailState = useMemo( () => readIssueDetailLocationState(issueId, location.state, location.search), [issueId, location.state, location.search], ); const relationIssueLinkState = useMemo(() => { const sourceState = resolvedIssueDetailState ?? location.state; if (!streamlinedTaskDetailEnabled) return sourceState; if (typeof sourceState !== "object" || sourceState === null) return sourceState; return { ...sourceState, // The inbox `y` shortcut is intentionally armed only for the selected // inbox row. Preserve the origin/breadcrumb when opening a related task, // but do not let that one-row archive affordance leak to the relation. issueDetailInboxQuickArchiveArmed: false, }; }, [location.state, resolvedIssueDetailState, streamlinedTaskDetailEnabled]); const preferInboxHistoryBack = useMemo( () => readIssueDetailLocationState(null, location.state)?.issueDetailSource === "inbox", [location.state], ); const issueHeaderSeed = useMemo( () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), [location.state, resolvedIssueDetailState], ); const coreIssueQuery = useQuery({ queryKey: [...queryKeys.issues.detail(issueId!), "core"], queryFn: async ({ signal }) => { const core = await issuesApi.getCore(issueId!, { signal }); seedIssueDetailProjections(queryClient, core, issueId); return core; }, enabled: !!issueId, }); const { historyExpanded, expandHistory, recent: recentComments, retainComment, acknowledgedComments } = useIssueHistoryWindow( issueId, // Classic settings can resolve before the personal workspace preference. // Do not persist full-history expansion for a surface we have not chosen. Boolean(!conversation && !coreIssueQuery.data?.conversationAgentId && taskInterfaceSettingsLoaded && classicTaskInterfaceEnabled), coreIssueQuery.data, Boolean(conversation || coreIssueQuery.data?.conversationAgentId), ); const paginatedConversation = Boolean(conversation || coreIssueQuery.data?.conversationAgentId); const issueQuery = useQuery({ ...getIssueDetailQueryOptions(queryClient, issueId!, { placeholderIssue: issueHeaderSeed ? { id: issueHeaderSeed.id, identifier: issueHeaderSeed.identifier, } : null, }), enabled: !!issueId && (coreIssueQuery.isFetched || !!queryClient.getQueryData(queryKeys.issues.detail(issueId))), }); const { data: queriedIssue, isLoading, isPlaceholderData, error, refetch: refetchIssueDetail, } = issueQuery; // A header seed is a placeholder, not loaded data: it must not count as // content worth keeping on screen through a failure. const issueView = useQueryView(isPlaceholderData ? { ...issueQuery, data: undefined } : issueQuery); const coreIssueView = useQueryView(coreIssueQuery); useEffect(() => { if (issueId && historyExpanded) void prefetchIssueThread(queryClient, issueId); }, [issueId, historyExpanded, queryClient]); const accessError = [error, coreIssueQuery.error].find((value) => value instanceof ApiError && [401, 403, 404].includes(value.status), ); const issue = accessError ? undefined : queriedIssue ?? coreIssueQuery.data ?? conversation?.issue ?? draftIssue; const loadingTitleOwner = issue?.id; const handleLoadingTitleEditingChange = useCallback((editing: boolean) => { if (!loadingTitleOwner) return; setLoadingTitleIssueId((current) => editing ? loadingTitleOwner : current === loadingTitleOwner ? null : current); }, [loadingTitleOwner]); const taskRetry = useTaskRetry(issue ?? undefined); const resolveWritableIssueId = async () => { if (!conversation) return issueId!; const resolved = await conversation.ensureIssue(); const requestedMode = pendingDraftWorkMode.current; if (requestedMode !== null && requestedMode !== resolved.workMode) { await issuesApi.update(resolved.id, { workMode: requestedMode }); } pendingDraftWorkMode.current = null; return resolved.id; }; // A cached header seed can paint during navigation, but must not redirect // or upload against the previous task while the requested task is loading. // A failed refetch of an already loaded task does not unload it. const loadedIssue = !isPlaceholderData && !(error && !queriedIssue && !coreIssueQuery.data) && issue && issueId && (issue.id.toLowerCase() === issueId.toLowerCase() || issue.identifier?.toLowerCase() === issueId.toLowerCase()) ? issue : null; const loadedIssueCompany = loadedIssue ? companies.find((company) => company.id === loadedIssue.companyId) : undefined; const taskRouteReady = Boolean(conversation || ( loadedIssue && issueId === (loadedIssue.identifier ?? loadedIssue.id) && (!loadedIssueCompany || companyPrefix === loadedIssueCompany.issuePrefix) && !hasLegacyIssueDetailQuery(location.search) )); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; const hasLiveConnection = useTaskRunLiveUpdates({ companyId: resolvedCompanyId, issueId: issue?.id ?? issueId, issueQueryRef: issueId ?? "pending", assigneeAgentId: issue?.assigneeAgentId }); const externalObjectsState = useIssueExternalObjects(conversation && !conversation.issue ? null : issue?.id ?? null); // A closed isolated workspace no longer blocks the composer. The server reopens // the workspace when the next comment or resume arrives, so the composer stays // enabled and a hint tells the user what happens. const closedIsolatedWorkspaceReopenPending = useMemo( () => Boolean( issue?.currentExecutionWorkspace && isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace), ), [issue?.currentExecutionWorkspace], ); const { data: commentPages, isLoading: commentsLoading, isError: commentsError, 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 && historyExpanded, initialPageParam: null as string | null, getNextPageParam: (lastPage) => getNextIssueCommentPageParam(lastPage, ISSUE_COMMENT_PAGE_SIZE), placeholderData: keepPreviousDataForSameQueryTail< InfiniteData >(issueId ?? "pending"), }); const earlierChatComments = useConversationHistoryPages(issueId, paginatedConversation, recentComments.data?.oldestCommentId, recentComments.data?.comments ?? []); const commentAnchor = location.hash.startsWith("#comment-") ? location.hash.slice("#comment-".length) : null; const linkedCommentId = commentAnchor && isUuidLike(commentAnchor) ? commentAnchor.trim() : null; const windowComments = useMemo( () => mergeWindowComments(recentComments.data?.comments ?? [], paginatedConversation ? earlierChatComments.data?.pages.flatMap(page => page.comments) ?? [] : flattenIssueCommentPages(commentPages?.pages), acknowledgedComments), [paginatedConversation, earlierChatComments.data?.pages, commentPages?.pages, recentComments.data?.comments, acknowledgedComments], ); const linkedComment = useQuery({ queryKey: [...queryKeys.issues.comments(issueId!), "linked", linkedCommentId], queryFn: ({ signal }) => issuesApi.getComment(issue!.id, linkedCommentId!, { signal }), enabled: !!issue && !!linkedCommentId && !historyExpanded && !windowComments.some(comment => comment.id === linkedCommentId), }); useEffect(() => { const target = linkedComment.data; if (!target || !recentComments.data) return; // A document click changes the hash, not the reader's conversation. Keep // every fetched target in the observed window so that click cannot remove // its source row. The preview cursor still belongs to the latest reply. queryClient.setQueryData( [...queryKeys.issues.comments(issueId!), "recent"], current => !current || current.comments.some(comment => comment.id === target.id) ? current : { ...current, comments: mergeWindowComments([target], current.comments) }, ); }, [issueId, linkedComment.data, recentComments.data, queryClient]); const comments = useMemo( () => mergeWindowComments(linkedComment.data ? [linkedComment.data] : [], windowComments), [linkedComment.data, windowComments], ); useLayoutEffect(() => { beginIssueDetailNavigation(); }, [issueId]); useEffect(() => { if (!(import.meta.env.DEV || import.meta.env.MODE === "qa")) return; return reportIssueDetailWebVitals(); }, [issueId]); useEffect(() => { if (!issue || !taskInterfaceSettingsLoaded || !taskRouteReady) return; scheduleIssueDetailPaintMeasure( ISSUE_DETAIL_HEADER_PAINT_MARK, ISSUE_DETAIL_HEADER_MEASURE, ); }, [issue?.id, taskInterfaceSettingsLoaded, taskRouteReady]); useEffect(() => { if (!classicTaskInterfaceEnabled || !issue || commentsLoading) return; scheduleIssueDetailPaintMeasure( ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_CONTENT_MEASURE, ); }, [classicTaskInterfaceEnabled, commentsLoading, issue?.id]); const linkedCommentPending = Boolean( linkedCommentId && !comments.some((comment) => comment.id === linkedCommentId) && !(historyExpanded ? commentsError : linkedComment.isError) && (historyExpanded ? commentsLoading || hasOlderComments : linkedComment.isPending), ); const shouldPrefetchOlderComments = useMemo( () => historyExpanded && shouldAutoloadOlderIssueComments({ activeDetailTab: detailTab, hasOlderComments: hasOlderComments ?? false, loadedCommentCount: comments.length, initialPageLoading: commentsLoading, olderPageLoading: commentsLoadingOlder, autoLoadLimit: ISSUE_COMMENT_AUTOLOAD_LIMIT, }), [ comments.length, commentsLoading, commentsLoadingOlder, historyExpanded, detailTab, hasOlderComments, ], ); const { data: interactions = [], isLoading: interactionsLoading, isError: interactionsError, refetch: refetchInteractions, } = useQuery({ queryKey: queryKeys.issues.interactions(issueId!), queryFn: () => issuesApi.listInteractions(issueId!), enabled: !!issueId, // A review can be committed between the initial fetch and live-socket // subscription. Reconcile even after its originating run has ended. refetchInterval: (query) => interactionReadinessRefetchInterval(query.state.data, 20_000), placeholderData: keepPreviousDataForSameQueryTail( issueId ?? "pending", ), }); const { data: attachments, isLoading: attachmentsLoading, isError: attachmentsError, refetch: refetchAttachments, } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), queryFn: () => issuesApi.listAttachments(issueId!), enabled: !!issueId, placeholderData: keepPreviousDataForSameQueryTail( issueId ?? "pending", ), }); const { data: workProducts, isLoading: workProductsLoading, isError: workProductsError, refetch: refetchWorkProducts, } = useIssueWorkProducts(issueId); useEffect(() => { recordIssueDetailReadiness({ issue: !!loadedIssue, comments: !commentsLoading, interactions: !interactionsLoading, attachments: !attachmentsLoading, "work-products": !workProductsLoading }); }, [issueId, loadedIssue, commentsLoading, interactionsLoading, attachmentsLoading, workProductsLoading]); const { data: liveRunCount = 0 } = useQuery( { queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), enabled: !!issueId, refetchInterval: (query) => liveRunRefetchInterval(hasLiveConnection, query.state, 3000), select: (runs) => runs.length, placeholderData: keepPreviousDataForSameQueryTail( issueId ?? "pending", ), }, ); const { data: hasActiveRun = false } = useQuery< ActiveRunForIssue | null, Error, boolean >({ queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), refetchInterval: (query) => liveRunCount > 0 ? false : liveRunRefetchInterval(hasLiveConnection, query.state, 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: rawChildIssuesData, isLoading: childIssuesLoading, isError: childIssuesError, refetch: refetchChildIssues, } = useQuery({ queryKey: issue?.id && resolvedCompanyId ? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id) : ["issues", "parent", "pending"], queryFn: () => issuesApi.listAll(resolvedCompanyId!, { descendantOf: issue!.id, includeBlockedBy: true, }), enabled: !!resolvedCompanyId && !!issue?.id && !issue.id.startsWith("chat:"), placeholderData: keepPreviousDataForSameQueryTail( issue?.id ?? "pending", ), }); const rawChildIssues: Issue[] = rawChildIssuesData ?? EMPTY_ISSUES; const createdTasksQuery = useQuery({ queryKey: queryKeys.issues.listCreatedFromIssue( resolvedCompanyId ?? "pending", issue?.id ?? "pending", ), queryFn: () => issuesApi.listAll(resolvedCompanyId!, { createdFromIssueId: issue!.id, includeRoutineExecutions: true, }), enabled: !!resolvedCompanyId && !!issue?.id && !issue.id.startsWith("chat:") && ( (taskChatShellEnabled && !conversation) || (streamlinedTaskDetailEnabled && !tasksTab) ), }); const { data: rawSiblingIssuesData, 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 rawSiblingIssues: Issue[] = rawSiblingIssuesData ?? EMPTY_ISSUES; const companyLiveRunsQueryKey = resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : (["live-runs", "pending"] as const); const sharedCompanyLiveRuns = useSharedPollingQuery({ companyId: resolvedCompanyId, resourceKey: "live-runs", queryKey: companyLiveRunsQueryKey, enabled: !!resolvedCompanyId, // Event-sourced via LiveUpdatesProvider (GitHub issue 9627); no interval poll needed. refetchInterval: false, leaderOnly: true, }); const { data: companyLiveRuns, dataUpdatedAt: companyLiveRunsUpdatedAt } = useQuery({ queryKey: companyLiveRunsQueryKey, queryFn: () => heartbeatsApi.liveRunsForCompany(resolvedCompanyId!), enabled: sharedCompanyLiveRuns.enabled, refetchInterval: sharedCompanyLiveRuns.refetchInterval, placeholderData: keepPreviousDataForSameQueryTail( resolvedCompanyId ?? "pending", ), }); usePublishSharedQueryData( sharedCompanyLiveRuns, companyLiveRuns, companyLiveRunsUpdatedAt, ); 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, }); // Bounded pool of recently-updated issues to back the `@task` reference picker. // The picker filters this list client-side by identifier/title. const { data: mentionIssues = EMPTY_ISSUES } = useQuery({ queryKey: resolvedCompanyId ? queryKeys.issues.mentionPool(resolvedCompanyId) : ["issues", "mention-pool", "pending"], queryFn: () => issuesApi.list(resolvedCompanyId!, { limit: 100, sortField: "updated", sortDir: "desc", }), enabled: !!resolvedCompanyId, staleTime: 60_000, placeholderData: keepPreviousDataForSameQueryTail( resolvedCompanyId ?? "pending", ), }); const { data: session, isFetched: sessionResolved, dataUpdatedAt: sessionUpdatedAt } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); const { data: projects = EMPTY_PROJECTS } = useQuery({ queryKey: queryKeys.projects.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; useEffect(() => { if (!streamlinedUiEnabled || !issue || !sessionResolved) return; recordRecentTask(issue, currentUserId); }, [issue, currentUserId, sessionResolved, streamlinedUiEnabled]); const { data: boardAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), enabled: !!session?.user?.id, }); const canManageTreeControl = Boolean( selectedCompanyId && boardAccess?.companyIds?.includes(selectedCompanyId), ); const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction( selectedCompanyId, boardAccess, ); // The break-glass override reconcile is `runtime:manage`-gated server-side, not gated on the // recovery-resolution permission — so hide its affordance behind the matching client check. const canManageBoardRuntime = canBoardManageRuntime( 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, }); const { data: instanceExperimentalSettings } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), enabled: !!issueId, }); // Experimental Cases: linkify `PAP-C7` chips in this issue's comment bodies. const casesChipsEnabled = instanceExperimentalSettings?.enableCases === true; const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt"; const showPlanDecompositionsSection = instanceExperimentalSettings?.enableIssuePlanDecompositions === true; const fileViewerEnabled = instanceExperimentalSettings?.enableExperimentalFileViewer === true; const { orderedProjects } = useProjectOrder({ 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, }); const { data: treeControlState, error: treeControlStateError } = useQuery({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"], queryFn: () => issuesApi.getTreeControlState(issueId!), enabled: !!issueId, }); 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 canManagePrivacy = canManageIssuePrivacy(issue, currentUserId, boardAccess); // Role-based principals for the share sheet's implicit rows (no revoke). const privacyImplicitPrincipals = useMemo(() => { if (!issue) return []; const list: ShareSheetImplicitPrincipal[] = []; const seen = new Set(); const pushUser = (userId: string | null, roleLabel: string) => { if (!userId || seen.has(`user:${userId}`)) return; seen.add(`user:${userId}`); const profile = userProfileMap.get(userId); list.push({ id: `user:${userId}`, displayName: profile?.label ?? userId.slice(0, 5), roleLabel, avatarUrl: profile?.image ?? null, }); }; const pushAgent = (agentId: string | null, roleLabel: string) => { if (!agentId || seen.has(`agent:${agentId}`)) return; seen.add(`agent:${agentId}`); const agent = agentMap.get(agentId); list.push({ id: `agent:${agentId}`, displayName: agent?.name ?? agentId.slice(0, 8), roleLabel, }); }; pushUser(issue.responsibleUserId, "Owner"); pushAgent(issue.assigneeAgentId, "Assignee"); pushUser(issue.assigneeUserId, "Assignee"); return list; }, [issue, userProfileMap, agentMap]); const mentionOptions = useMemo(() => { return buildMarkdownMentionOptions({ agents, projects: orderedProjects, members: companyMembers?.users, issues: mentionIssues, }); }, [agents, companyMembers?.users, orderedProjects, mentionIssues]); 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 threadCreatedTasks = useMemo(() => [ ...childIssues.filter((task) => task.parentId === issue?.id), ...(createdTasksQuery.data ?? EMPTY_ISSUES), ], [childIssues, createdTasksQuery.data, issue?.id]); const resolvedTasksTab = useMemo(() => { if (tasksTab) return tasksTab; if (!streamlinedTaskDetailEnabled) return undefined; const createdTasks = createdTasksQuery.data ?? EMPTY_ISSUES; const hasError = createdTasksQuery.isError || childIssuesError; return { count: new Set([ ...(issue?.createdFrom ? [issue.createdFrom.issue] : []), ...(issue?.ancestors ?? []), ...childIssues, ...createdTasks, ].map((task) => task.id)).size, hasError, content: ( { void createdTasksQuery.refetch(); void refetchChildIssues(); }} /> ), }; }, [ tasksTab, issue?.ancestors, issue?.createdFrom, resolvedIssueDetailState, location.state, streamlinedTaskDetailEnabled, childIssues, childIssuesLoading, childIssuesError, refetchChildIssues, projects, createdTasksQuery.data, createdTasksQuery.isError, createdTasksQuery.isLoading, createdTasksQuery.refetch, ]); const liveIssueIds = useMemo( () => collectLiveIssueIds( companyLiveRuns, issue ? [issue, ...childIssues] : childIssues, ), [childIssues, companyLiveRuns, issue], ); const issuePanelKey = useMemo( () => buildIssuePropertiesPanelKey(issue ?? null, childIssues), [childIssues, issue], ); const panelIssue = useMemo(() => issue ?? null, [issue?.id, issuePanelKey]); const panelChildIssues = useMemo(() => childIssues, [issuePanelKey]); // Planning tasks start as chat-only until a plan exists, then reveal the // sidebar already on the Plan tab. The onboarding first task keeps the same // behavior even if its work mode changes. We gate the panel *mount* (withhold // the panel content) rather than flipping the global `panelVisible` preference // — that preference persists to localStorage and would leak "hidden" into every // other task. The user can still opt in early via the "Show properties" header // button, which sets a per-issue override (keyed on the issue id so it resets // across navigations). const isOnboardingFirstTask = taskChatShellEnabled && issue?.originKind === ONBOARDING_FIRST_TASK_ORIGIN_KIND; const shouldDeferPanelUntilPlan = taskChatShellEnabled && (isOnboardingFirstTask || issue?.workMode === "planning"); const { data: deferredPanelPlanDoc } = useIssuePlanDocument( shouldDeferPanelUntilPlan ? issue?.id : null, ); const [panelBeforePlanOverrideIssueId, setPanelBeforePlanOverrideIssueId] = useState(null); const panelBeforePlanOverride = panelBeforePlanOverrideIssueId !== null && panelBeforePlanOverrideIssueId === issue?.id; const suppressPanelUntilPlan = shouldDeferPanelUntilPlan && shouldSuppressTaskPanelUntilPlan({ deferredPlanAvailable: Boolean(deferredPanelPlanDoc), panelBeforePlanOverride, }); const openTaskSidePanel = useCallback(() => { if (suppressPanelUntilPlan && issue?.id) { setPanelBeforePlanOverrideIssueId(issue.id); } setPanelVisible(true); }, [issue?.id, setPanelVisible, suppressPanelUntilPlan]); const handleAttachmentOpened = useCallback(() => setOpenAttachment(null), []); const handleOpenTextAttachment = useCallback((id: string, title: string) => { if (!issue?.id) return; setOpenAttachment({ issueId: issue.id, id, title, requestId: Date.now() }); openTaskSidePanel(); if (isMobile) setMobilePropsOpen(true); }, [issue?.id, openTaskSidePanel, isMobile]); const decisionAttachmentOpened = useRef(null); useEffect(() => { if (!coordinatorWorkspaceEnabled || !issue?.id) return; const params = new URLSearchParams(location.hash.split("&").slice(1).join("&")); const attachmentId = params.get("attachment") ?? new URLSearchParams(location.search).get("attachmentId"); if (!attachmentId) { decisionAttachmentOpened.current = null; return; } const key = `${issue.id}:${location.key}:${attachmentId}`; if (decisionAttachmentOpened.current === key) return; const attachment = attachments?.find(item => item.id === attachmentId); if (!attachment) return; decisionAttachmentOpened.current = key; handleOpenTextAttachment(attachment.id, attachment.originalFilename ?? "Attachment"); }, [coordinatorWorkspaceEnabled, issue?.id, location.hash, location.search, location.key, attachments, handleOpenTextAttachment]); // History links restore the exact point of decision while its artifact opens // in the side pane. Wait for the real thread to mount, without guessing a row. useEffect(() => { if (!issue?.id) return; const anchor = taskChatHashAnchor(location.hash); if (!anchor || (!anchor.startsWith("interaction-") && !anchor.startsWith("decision-"))) return; if (!coordinatorWorkspaceEnabled && !anchor.startsWith("decision-")) return; const focusDecision = () => { const target = document.getElementById(anchor); if (!target) return false; target.tabIndex = -1; target.scrollIntoView({ block: "center", behavior: "instant" }); target.focus({ preventScroll: true }); return true; }; if (focusDecision()) return; const observer = new MutationObserver(() => { if (focusDecision()) observer.disconnect(); }); observer.observe(document.getElementById("main-content") ?? document.body, { subtree: true, childList: true }); const timeout = window.setTimeout(() => observer.disconnect(), 10_000); return () => { observer.disconnect(); window.clearTimeout(timeout); }; }, [coordinatorWorkspaceEnabled, issue?.id, location.hash, location.key]); const taskPanelAccountScope = coordinatorWorkspaceEnabled ? `coordinator:${currentUserId ?? "anonymous"}` : currentUserId ?? "anonymous"; const { panels: pluginPanels, isLoading: pluginPanelsLoading } = useTaskPluginPanels(issue, taskChatShellEnabled); const revealedPluginPanels = useRef(new Set()); const openedPluginPanelKeys = useRef(new Set()); const dismissedPluginPanelKeys = useRef(new Set()); useEffect(() => { for (const panel of pluginPanels) { if (!panel.autoOpen || !panel.runId) continue; const key = taskPluginPanelActivationKey(panel, taskPanelAccountScope, issue!); if (revealedPluginPanels.current.has(key)) continue; revealedPluginPanels.current.add(key); if (dismissedPluginPanelKeys.current.has(key)) continue; openTaskSidePanel(); if (isMobile) setMobilePropsOpen(true); } }, [pluginPanels, taskPanelAccountScope, issue?.companyId, issue?.id, openTaskSidePanel, isMobile]); const browserQuery = useTaskBrowsers(issue?.id); const { openBrowserId, openBrowser: setOpenBrowserId, acknowledgeBrowserOpened: handleBrowserOpened } = useBrowserArrivals(currentUserId ?? "anonymous", issue?.id, browserQuery.data); useEffect(() => { if (!openBrowserId) return; openTaskSidePanel(); if (isMobile) setMobilePropsOpen(true); }, [openBrowserId, openTaskSidePanel, isMobile]); const handleOpenSkill = useCallback((skillId: string, name: string) => { const next = openSkillPanelState( { panelBeforePlanOverrideIssueId }, { id: skillId, name }, issue?.id ?? null, suppressPanelUntilPlan, ); setOpenSkill(next.skill); setPanelBeforePlanOverrideIssueId(next.panelBeforePlanOverrideIssueId); setPanelVisible(true); if (isMobile) setMobilePropsOpen(true); }, [isMobile, issue?.id, panelBeforePlanOverrideIssueId, setPanelVisible, suppressPanelUntilPlan]); const registerArtifactTab = useCallback(() => { if (!issue?.id) return; setArtifactsOpenRequest((previous) => ({ issueId: issue.id, requestId: (previous?.requestId ?? 0) + 1, })); }, [issue?.id]); const handleArtifactsOpened = useCallback((requestId: number) => { setArtifactsOpenRequest((request) => request?.requestId === requestId ? { ...request, handled: true } : request); }, []); useTaskArtifactArrival({ issueId: taskChatShellEnabled ? issue?.id : undefined, attachments, workProducts, documents: issue?.documentSummaries, onArrival: registerArtifactTab, }); const toggleTaskSidePanel = useCallback(() => { if (!panelVisible || suppressPanelUntilPlan) { openTaskSidePanel(); return; } setPanelVisible(false); }, [ openTaskSidePanel, panelVisible, setPanelVisible, suppressPanelUntilPlan, ]); 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 { data: assignmentActivity } = useQuery({ queryKey: queryKeys.issues.activity(issueId!), queryFn: () => activityApi.forIssue(issueId!), enabled: Boolean(issueId && currentUserId && issue?.assigneeUserId === currentUserId), }); const suggestedAssigneeValue = useMemo( () => suggestedCommentAssigneeValue( issue ?? {}, mergeIssueComments(comments ?? [], optimisticComments), currentUserId, undefined, assignmentActivity, ), [issue, comments, optimisticComments, currentUserId, assignmentActivity], ); const threadComments = useMemo( () => mergeIssueComments(comments ?? [], optimisticComments) .filter(comment => !(comment.authorType === "system" && comment.metadata?.recoveryResponsibility === "system")) .map((comment) => { if ("clientId" in comment && comment.clientId) commentRenderKeys.current.set(comment.id, comment.clientId); const clientId = commentRenderKeys.current.get(comment.id); return clientId ? { ...comment, clientId } : comment; }), [comments, optimisticComments], ); const breadcrumbTitle = issue?.title ?? issueHeaderSeed?.title ?? ""; const breadcrumbIdentifier = issue?.identifier ?? issueHeaderSeed?.identifier ?? (issueId && /^[a-z0-9]+-\d+$/i.test(issueId) ? issueId : undefined); const breadcrumbStatus = issue?.status ?? issueHeaderSeed?.status; const breadcrumbBlockerAttention = issue?.blockerAttention; // Stable identity for the breadcrumb status glyph. The glyph's shape/colour // depend on status (+ covered state), and its accessible label is derived // from the blocker counts — so the key signs over the full blockerAttention, // not just `state`, to avoid a stale label when counts change. const breadcrumbStatusKey = breadcrumbStatus ? `${breadcrumbStatus}|${issue?.displayStatus ?? issueHeaderSeed?.displayStatus ?? ""}|${issue?.externalConversationState ?? ""}|${JSON.stringify(breadcrumbBlockerAttention ?? null)}` : "loading-task-status"; const breadcrumbStatusLeading = useMemo( () => breadcrumbStatus ? ( ) :