import type { ActivityEvent } from "@paperclipai/shared"; import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems"; import { skillCreatedItems } from "@/components/task-chat/skill-created-items"; import { requiresExecutionReconciliation } from "@paperclipai/shared"; import { TaskChatExpansionState } from "@/components/task-chat/expansion-state"; import { TaskChatScrollReady } from "@/components/task-chat/scroll-navigation"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, } from "react"; import { IssueAssigneePausedNotice, IssueChatThread, } from "@/components/IssueChatThread"; import { useLiveRunTranscripts, type RunTranscriptSource, } from "@/components/transcript/useLiveRunTranscripts"; import { useNativeRunTranscripts } from "@/components/transcript/useNativeRunTranscripts"; import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail"; import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn"; import { TaskChatTurnStatusIsland, taskChatTurnStatusModel, } from "@/components/task-chat/TaskChatTurnStatusIsland"; import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter"; import { assembleThreadItems, attachSettledTurns, buildTurnSummary, coalesceSettledTurns, isTerminalRunStatus, embedPlanDocumentAtWriteBoundary, omitProgressRepeatedByResponseAcrossSegments, paperclipRunnerFinalResponse, paperclipRunnerAcceptedResponseWake, paperclipRunnerTimelineItems, prependIssueBrief, settledRunChildren, splitTranscriptAtAnchors, transcriptToTaskChatItems, type SettledTurnMergeMeta, } from "@/components/task-chat/transcript-adapter"; import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble"; import { TaskChatLiveRunPill, toolCountSummaryFromEntries, } from "@/components/task-chat/TaskChatLiveRunPill"; import type { TaskChatInteractionItem, TaskChatItem, TaskChatMessageItem, TaskChatPlanDocumentItem, TaskChatRuntimeRequestDecision, TaskChatRuntimeRequestItem, TaskChatTurnItem, } from "@/components/task-chat/task-chat-model"; import { latestPendingRuntimeRequest, runtimeRequestReplacesComposerSkip, } from "@/components/task-chat/task-chat-model"; import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; import { TaskChatProtocolCard } from "@/components/task-chat/TaskChatProtocolCard"; import { interactionThreadAnchorMs, isSuppressedThreadInteraction, } from "@/components/task-chat/interaction-thread-order"; import { interactionReplacesComposerSkip, shouldHideInteractionCard, } from "@/lib/issue-thread-interactions"; import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions"; import type { FeedbackVoteValue, IssueDocument, IssueThreadInteraction, } from "@paperclipai/shared"; import { TaskChatThreadView, taskChatContentKey, } from "@/components/task-chat/TaskChatThreadView"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { RunnerGoalWidget, useRunnerGoalControl, } from "@/components/task-chat/RunnerGoalWidget"; import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages"; import { TaskChatWindowScroll } from "@/components/task-chat/useWindowAutoFollow"; import { useSidebar } from "@/context/SidebarContext"; import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled"; import { cn } from "@/lib/utils"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; import { isRedundantAiRecoveryNotice, latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages"; import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds"; import { resolveTaskChatBlockers, resolveTaskChatLiveWork, TaskChatBlockerLinks, TaskChatLiveWorkLinks, } from "@/components/task-chat/TaskChatBlockerLinks"; import { buildDocumentAnnotationHash } from "@/lib/document-annotation-hash"; import { documentDisplayTitle, selectAgentArtifactAttachments, workProductHref, } from "@/lib/issue-artifacts"; import { heartbeatsApi, type RuntimeRequestResolution } from "@/api/heartbeats"; import { issuesApi } from "@/api/issues"; import { queryKeys } from "@/lib/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; import { TaskChatPresentationProvider } from "@/components/task-chat/presentation-mode"; function toMs(value: Date | string | null | undefined): number { if (!value) return 0; const ms = new Date(value).getTime(); return Number.isNaN(ms) ? 0 : ms; } function normalizedMessageText(value: string | null | undefined): string { return (value ?? "").trim().replace(/\s+/g, " "); } function safeResourceHref(value: string | null | undefined): string | null { if (!value) return null; if (value.startsWith("/") || value.startsWith("#")) return value; try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:" ? value : null; } catch { return null; } } function formatResourceBytes(value: number): string { if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; return `${(value / (1024 * 1024)).toFixed(1)} MB`; } function interactionTargetsPlanRevision( interaction: IssueThreadInteraction, planDocument: IssueDocument, ): boolean { if (interaction.kind !== "request_confirmation") return false; const target = interaction.payload.target; if (target?.type !== "issue_document" || target.key !== "plan") return false; if (target.issueId && target.issueId !== planDocument.issueId) return false; if (planDocument.latestRevisionId) { return target.revisionId === planDocument.latestRevisionId; } return target.revisionNumber === planDocument.latestRevisionNumber; } function isResolvedPlanConfirmation(interaction: IssueThreadInteraction) { return ( interaction.kind === "request_confirmation" && (interaction.status === "accepted" || interaction.status === "rejected") && interaction.payload.target?.type === "issue_document" && interaction.payload.target.key === "plan" ); } // A runner-authored plan review is only the lifecycle receipt. Its full plan // preview already belongs to the source turn's write_document boundary, so // resolving or expiring the review must not insert a second large block there. function runnerTurnOwnsPlanPreview( interaction: IssueThreadInteraction, renderedRunIds: ReadonlySet, tailRunId: string | null, ): boolean { if (!interaction.sourceRunId || interaction.kind !== "request_confirmation") return false; const target = interaction.payload.target; return ( target?.type === "issue_document" && target.key === "plan" && (renderedRunIds.has(interaction.sourceRunId) || tailRunId === interaction.sourceRunId) ); } function threadOwnsPlanPreview( interaction: IssueThreadInteraction, planDocument: IssueDocument | null | undefined, planDocumentSourceRunId: string | null, renderedRunIds: ReadonlySet, tailRunId: string | null, ): boolean { if ( !planDocument || !interactionTargetsPlanRevision(interaction, planDocument) ) return false; // Plans without a native-run owner are canonical chronological thread // entries. In particular, legacy adapters write them through shell/API // tools rather than a semantic write_document boundary, so the pending // confirmation must not replace that durable timeline position. if (!planDocumentSourceRunId) return true; return runnerTurnOwnsPlanPreview(interaction, renderedRunIds, tailRunId); } function isRunnerResponseComment(params: { comment: TaskChatThreadProps["comments"][number]; runId: string; runStartedAtMs: number | null; finalText: string | null; }): boolean { const { comment, runId, runStartedAtMs, finalText } = params; if (comment.deletedAt || comment.authorType === "user") return false; if (comment.runId === runId) return true; if (comment.runId || !finalText) return false; const commentAtMs = toMs(comment.createdAt); if (runStartedAtMs != null && commentAtMs < runStartedAtMs - 5_000) return false; return ( normalizedMessageText(comment.body) === normalizedMessageText(finalText) ); } // PAP-462 B4: backstop for how long the just-settled run's transcript stays // mounted when neither a settled turn nor a reply comment ever arrives to hand // off to (e.g. a stopped run with no tool activity). Normal completions hand off // well within this as soon as the settled turn/comment lands. const SETTLING_TAIL_MAX_MS = 15_000; const EMPTY_LIVE_ISSUE_IDS: ReadonlySet = new Set(); const LONG_THREAD_BLOCKER_REPEAT_COUNT = 4; export function shouldRepeatTaskChatBlockers(items: TaskChatItem[]): boolean { const conversationItems = items.filter( (item) => item.kind === "brief" || item.kind === "interaction" || (item.kind === "message" && !item.interstitial), ); return conversationItems.length >= LONG_THREAD_BLOCKER_REPEAT_COUNT; } function isNativePaperclipRunnerRun( run: | { adapterType?: string | null; runtimeMode?: string | null; } | null | undefined, ): boolean { return ( run?.runtimeMode === "native" && run.adapterType === "paperclip_runner" ); } const LEGACY_WITHHELD_RUN_COMMENT = "Run completed. Agent did not post a summary comment this run (transcript withheld — see run log)."; function acceptedSemanticResult( value: unknown, ): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const resultJson = value as Record; const semanticResult = resultJson.semanticResult; const candidates = [ resultJson.nativeResult, resultJson.acceptedResult, semanticResult && typeof semanticResult === "object" && !Array.isArray(semanticResult) ? (semanticResult as Record).result : null, ]; for (const candidate of candidates) { if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; const result = candidate as Record; if (result.schema !== "paperclip.run_result.v1") continue; return result; } return null; } function acceptedSemanticResultSummary(value: unknown): string | null { const result = acceptedSemanticResult(value); return typeof result?.summary === "string" && result.summary.trim() ? result.summary : null; } function acceptedSemanticResultDisposition(value: unknown): string | null { const result = acceptedSemanticResult(value); return typeof result?.reportedWorkDisposition === "string" ? result.reportedWorkDisposition : null; } function presentationDecisionCommentId( value: unknown, ): string | null | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const decision = (value as Record).presentationDecision; if (!decision || typeof decision !== "object" || Array.isArray(decision)) return undefined; const record = decision as Record; if (record.schema !== "paperclip.run_presentation_decision.v1") return undefined; return typeof record.commentId === "string" && record.commentId.trim() ? record.commentId : null; } function semanticProgressCommentIds(value: unknown): Set { if (!value || typeof value !== "object" || Array.isArray(value)) return new Set(); const receipts = (value as Record).semanticToolReceipts; if (!receipts || typeof receipts !== "object" || Array.isArray(receipts)) return new Set(); const ids = new Set(); for (const receipt of Object.values(receipts)) { if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) continue; const record = receipt as Record; if (record.operationId !== "report_progress") continue; const result = record.result; if (!result || typeof result !== "object" || Array.isArray(result)) continue; const commentId = (result as Record).commentId; if (typeof commentId === "string" && commentId) ids.add(commentId); } return ids; } function acceptedSemanticResultVerificationCaveats(value: unknown) { const runResult = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; const finalizedCaveats = Array.isArray(runResult.verificationCaveats) ? runResult.verificationCaveats : []; if (finalizedCaveats.length > 0) { return finalizedCaveats.flatMap((candidate) => { if ( !candidate || typeof candidate !== "object" || Array.isArray(candidate) ) return []; const caveat = candidate as Record; if (typeof caveat.commandOrCheck !== "string") return []; return [ { commandOrCheck: caveat.commandOrCheck, reasonCode: typeof caveat.reasonCode === "string" ? caveat.reasonCode : null, detail: typeof caveat.detail === "string" ? caveat.detail : null, }, ]; }); } const result = acceptedSemanticResult(value); const verification = Array.isArray(result?.verification) ? result.verification : []; return verification.flatMap((candidate) => { if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return []; const check = candidate as Record; if (check.status !== "not_run" || typeof check.commandOrCheck !== "string") return []; return [ { commandOrCheck: check.commandOrCheck, reasonCode: typeof check.reasonCode === "string" ? check.reasonCode : null, detail: typeof check.detail === "string" ? check.detail : null, }, ]; }); } function resolvedWithoutUserFacingResponse(value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const decision = (value as Record).presentationDecision; return Boolean( decision && typeof decision === "object" && !Array.isArray(decision) && (decision as Record).schema === "paperclip.run_presentation_decision.v1" && (decision as Record).chosenSource === "none", ); } export type TaskChatThreadProps = ComponentProps & { conversationMode?: boolean; creationActivity?: ActivityEvent[]; initialHistoryPending?: boolean; initialHistoryError?: boolean; onRetryInitialHistory?: () => void; onOpenSkill?: (skillId: string, name: string) => void; }; type PendingComposerInput = | { key: string; kind: "runtime"; item: TaskChatRuntimeRequestItem; label: string; } | { key: string; kind: "durable"; interaction: TaskChatInteractionItem["interaction"]; label: string; }; function durableInputLabel( interaction: TaskChatInteractionItem["interaction"], ): string { if (interaction.kind === "ask_user_questions") return interaction.title ?? "Questions"; if (interaction.kind === "suggest_tasks") return interaction.title ?? "Suggested tasks"; if (interaction.kind === "request_checkbox_confirmation") return interaction.title ?? "Choose options"; if (interaction.kind === "request_item_verdicts") return interaction.title ?? "Review items"; if (interaction.kind === "connection_intent") return interaction.title ?? "Connect service"; if ( interaction.payload.target?.type === "issue_document" && interaction.payload.target.key === "plan" ) { return interaction.title ?? "Review plan"; } if (interaction.payload.toolAction) return interaction.title ?? "Approve tool action"; if (interaction.payload.secretProposal) return interaction.title ?? "Review secret proposal"; return interaction.title ?? "Confirmation"; } /** * Chat-style task thread — the default task detail experience. * * Renders the Claude-Code-style thread for the live task. It shares * IssueChatThread's exact prop type — so the IssueDetail seam ternary * (`classic ? IssueChatThread : TaskChatThread`, flag: * `enableClassicTaskInterface`) type-checks with no casts. * * Two data sources feed the render layer, both reused from the existing thread: * - the comment stream (incl. optimistic echoes) → author-typed bubbles, and * - the live run transcript (useLiveRunTranscripts, the same poll+websocket * source the current thread uses) → clean TaskChatLiveTail rows (tool cards, * diffs, streamed reply markdown) while in flight, via the same * transcriptToTaskChatItems converter the settled turns use (PAP-463). * * Once a run terminates, its settled turn anchors after the run's * last comment (comment.runId linkage) and — when it directly follows that * reply bubble — attaches to it: the "✓ Worked · …" summary renders appended * to the bubble's always-visible timestamp line (round 9), still expandable * to the tool history. Turns without a reply bubble keep the standalone * folded row. flag-OFF remains byte-for-byte IssueChatThread. */ export function TaskChatThread(props: TaskChatThreadProps) { const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled(); const { initialHistoryPending = false, initialHistoryError = false, onRetryInitialHistory, comments, interactions, documents = [], workProducts = [], attachments = [], timelineEvents, issueId = null, agentMap, userLabelMap, userProfileMap, currentUserId, onAdd, onReviewConversation, onCancelRun, stopPending, stopScope, issueWorkMode = "standard", onWorkModeChange, composerAccessory, footer, showComposer = true, composerPause, composerDisabledReason, emptyMessage = "No messages yet.", companyId, linkedRuns, liveRuns, activeRun, onAttachImage, imageUploadHandler, mentions, enableReassign, conversationMode, reassignOptions, currentAssigneeValue, issueStatus, issueAssigneeAgentId = null, onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, onCancelInteraction, onSkipInteraction, onSubmitInteractionVerdicts, externalReferences, threadHeader, issueBrief, feedbackVotes, feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, onVote, draftKey, onInterruptQueued, onCancelQueued, interruptingQueuedRunId, onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending = false, onRetryFailedRun, retryFailedRunId = null, queuedCommentQueue, onEditQueuedComment, onReorderQueuedComments, onSteerQueuedComment, onDiscardQueuedComment, blockedBy = [], blockerAttention, liveIssueIds, onResumeAssignee, resumeAssigneePending = false, onOpenSkill, } = props; const retryFailedRunHandler = isTerminalIssueStatus(issueStatus) || interactions?.some((interaction) => interaction.status === "pending") || requiresExecutionReconciliation(props.recoveryAction?.cause) || props.scheduledRetry || linkedRuns?.some((run) => { // The server accepts explicit new attempts for these stopped legacy // conversations. It still proves process/lease termination and ownership; // offering Retry does not certify prior action outcomes or resume them. // Keep this set aligned with conversation-continuation.ts on the server. if ( run.runtimeMode === "legacy" && (run.status === "failed" || run.status === "timed_out") && run.execution?.phase === "recovery_needed" && run.execution.cause === "legacy_execution_requires_reconciliation" && [ "claude_local", "codex_local", "cursor", "gemini_local", "opencode_local", "pi_local", "grok_local", "kimi_local", "hermes_local", ].includes(run.adapterType ?? "") ) return false; return [ "working", "retry_scheduled", "reconnecting", "finishing", "queued", "recovery_needed", ].includes(run.execution?.phase ?? ""); }) ? undefined : onRetryFailedRun; const canRetryFailedRun = Boolean(retryFailedRunHandler); const queryClient = useQueryClient(); const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId); const createdSkillItems = useMemo(() => skillCreatedItems(props.creationActivity ?? []), [props.creationActivity]); const [pendingComposerAssignee, setPendingComposerAssignee] = useState< string | null >(null); const effectiveGoalAgentId = pendingComposerAssignee === null ? issueAssigneeAgentId : pendingComposerAssignee.startsWith("agent:") ? pendingComposerAssignee.slice("agent:".length) || null : null; const runnerGoal = useRunnerGoalControl(issueId, effectiveGoalAgentId); useEffect(() => { setPendingComposerAssignee(null); }, [currentAssigneeValue, issueId]); const reassignForRunnerGoal = useCallback( async (reassignment: { assigneeAgentId: string | null; assigneeUserId: string | null; }) => { if (!issueId) throw new Error("The task is not available for reassignment."); await issuesApi.update(issueId, { ...reassignment, deferWakeForGoal: true, }); await queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId), }); }, [issueId, queryClient], ); const queuedMessageQueue = queuedCommentQueue && queuedCommentQueue.entries.length > 0 ? queuedCommentQueue : null; const queuedCommentIds = useMemo( () => new Set( queuedMessageQueue?.entries.map((entry) => entry.comment.id) ?? [], ), [queuedMessageQueue], ); const [queuedEdit, setQueuedEdit] = useState<{ commentId: string; body: string; revision: string; stale?: boolean; } | null>(null); const beginQueuedEdit = useCallback( (commentId: string) => { const entry = queuedMessageQueue?.entries.find( (candidate) => candidate.comment.id === commentId, ); if (!entry?.canEdit || !queuedMessageQueue) return; setQueuedEdit({ commentId, body: entry.comment.body, revision: queuedMessageQueue.revision, }); }, [queuedMessageQueue], ); const saveQueuedEdit = useCallback( async (commentId: string, body: string) => { if (!queuedEdit || queuedEdit.commentId !== commentId) { throw new Error("This queued message is no longer editable."); } if (queuedEdit.stale) { await onAdd(body); return; } if (!onEditQueuedComment) throw new Error("This queued message is no longer editable."); try { await onEditQueuedComment(commentId, body, queuedEdit.revision); } catch (error) { const payload = typeof error === "object" && error !== null ? (error as { status?: unknown; body?: unknown; message?: unknown }) : null; const responseBody = typeof payload?.body === "object" && payload.body !== null ? (payload.body as { code?: unknown; details?: unknown }) : null; const details = 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; const stale = payload?.status === 409 && (code === "queued_comment_stale_target" || code === "queued_comment_not_pending"); if (stale) { setQueuedEdit((current) => current?.commentId === commentId ? { ...current, stale: true } : current, ); } throw error; } }, [onAdd, onEditQueuedComment, queuedEdit], ); useEffect(() => { if (!queuedEdit || queuedEdit.stale) return; const targetStillQueued = queuedMessageQueue?.entries.some( (entry) => entry.comment.id === queuedEdit.commentId, ); if (!targetStillQueued) { setQueuedEdit((current) => current ? { ...current, stale: true } : current, ); } else if ( queuedMessageQueue && queuedEdit.revision !== queuedMessageQueue.revision ) { // A concurrent reorder/edit refreshes the optimistic-lock token without // replacing the Markdown currently in the editor. setQueuedEdit((current) => current ? { ...current, revision: queuedMessageQueue.revision } : current, ); } }, [queuedEdit, queuedMessageQueue]); const liveWorkLinks = useMemo( () => issueStatus === "blocked" && blockerAttention?.state === "covered" ? resolveTaskChatLiveWork( blockedBy, liveIssueIds ?? EMPTY_LIVE_ISSUE_IDS, blockerAttention.terminalBlocker, ) : null, [ blockedBy, blockerAttention?.state, blockerAttention?.terminalBlocker, issueStatus, liveIssueIds, ], ); const blockerLinks = useMemo( () => issueStatus === "blocked" && !liveWorkLinks ? resolveTaskChatBlockers( blockedBy, blockerAttention?.terminalBlockerIssueId, blockerAttention?.directBlockerIssueId, blockerAttention?.terminalBlocker, ) : null, [ blockedBy, blockerAttention?.directBlockerIssueId, blockerAttention?.terminalBlocker, blockerAttention?.terminalBlockerIssueId, issueStatus, liveWorkLinks, ], ); const threadHeaderWithBlockers = threadHeader || blockerLinks || liveWorkLinks ? ( <> {threadHeader} {liveWorkLinks ? ( ) : blockerLinks ? ( ) : null} ) : undefined; const linkedRunMetaById = useMemo(() => { const map = new Map< string, NonNullable[number] >(); for (const run of linkedRuns ?? []) map.set(run.runId, run); return map; }, [linkedRuns]); const verificationCaveatsByRunId = useMemo(() => { const map = new Map< string, ReturnType >(); for (const run of linkedRuns ?? []) { const caveats = acceptedSemanticResultVerificationCaveats(run.resultJson); if (caveats.length > 0) map.set(run.runId, caveats); } return map; }, [linkedRuns]); // Historical placeholder comments remain untouched in storage. Projection // prefers the accepted semantic result so runs such as DOT-20 recover their // real upstream response without a data migration. const projectedComments = useMemo( () => comments.flatMap((comment) => { if (isRedundantAiRecoveryNotice(comment, interactions)) return []; if (comment.body !== LEGACY_WITHHELD_RUN_COMMENT || !comment.runId) return [comment]; const resultJson = linkedRunMetaById.get(comment.runId)?.resultJson; // Historical native runs wrote a placeholder comment even when the // accepted result only yielded for interaction. Do not turn that // control-plane wait into an ordinary assistant bubble. if (acceptedSemanticResultDisposition(resultJson) === "yielded") { return []; } const summary = acceptedSemanticResultSummary(resultJson); return [summary ? { ...comment, body: summary } : comment]; }), [comments, interactions, linkedRunMetaById], ); const commentItems = useMemo( () => commentsToTaskChatItems(projectedComments, { agentMap, userLabelMap, currentUserId, issueAssigneeAgentId, verificationCaveatsByRunId, }), [ projectedComments, agentMap, userLabelMap, currentUserId, issueAssigneeAgentId, verificationCaveatsByRunId, ], ); // Every run we might need a transcript for (history + live), deduped by id. const runs = useMemo(() => { const map = new Map(); for (const r of linkedRuns ?? []) { map.set(r.runId, { id: r.runId, status: r.status, adapterType: r.adapterType ?? "", runtimeMode: r.runtimeMode, hasStoredOutput: r.hasStoredOutput, logBytes: r.logBytes, }); } for (const r of liveRuns ?? []) { map.set(r.id, { id: r.id, status: r.status, adapterType: r.adapterType, runtimeMode: r.runtimeMode, hasStoredOutput: map.get(r.id)?.hasStoredOutput, logBytes: r.logBytes, lastOutputBytes: r.lastOutputBytes, }); } if (activeRun) { map.set(activeRun.id, { id: activeRun.id, status: activeRun.status, adapterType: activeRun.adapterType, runtimeMode: activeRun.runtimeMode, logBytes: activeRun.logBytes, lastOutputBytes: activeRun.lastOutputBytes, }); } return [...map.values()]; }, [linkedRuns, liveRuns, activeRun]); const nativeRuns = useMemo( () => runs.filter((run) => run.runtimeMode === "native"), [runs], ); const { transcriptByRun: logTranscriptByRun, isInitialHydrating: logsAreInitiallyHydrating, hydratedRunIds: hydratedLogRunIds, errorsByRun: logErrorsByRun, retry: retryLogs, } = useLiveRunTranscripts({ // Native events are authoritative, but the persisted/live log remains a // compatibility source when an upgraded server has no event history or // the native event endpoint is temporarily unavailable. runs, companyId, }); const { transcriptByRun: nativeTranscriptByRun, errorsByRun: nativeTranscriptErrorsByRun, isInitialHydrating: nativeEventsAreInitiallyHydrating, hydratedRunIds: hydratedNativeRunIds, retry: retryNativeEvents, } = useNativeRunTranscripts(nativeRuns); const fallbackByRunRef = useRef( new Map>>(), ); const transcriptByRun = useMemo(() => { const next = new Map(logTranscriptByRun); for (const run of nativeRuns) { const nativeTranscript = nativeTranscriptByRun.get(run.id) ?? []; const logTranscript = logTranscriptByRun.get(run.id) ?? []; if (nativeTranscriptErrorsByRun.has(run.id) && logTranscript.length > 0) { fallbackByRunRef.current.set(run.id, logTranscript); } const fallback = fallbackByRunRef.current.get(run.id); const lastTimestamp = (entries: typeof nativeTranscript) => entries.reduce((latest, entry) => Math.max(latest, toMs(entry.ts)), 0); // Keep a newer fallback visible through native transport recovery. An // empty successful poll must not rewind a response the reader just saw. if ( fallback && (nativeTranscriptErrorsByRun.has(run.id) || lastTimestamp(nativeTranscript) < lastTimestamp(fallback)) ) { next.set(run.id, fallback); } else if (nativeTranscript.length > 0) { fallbackByRunRef.current.delete(run.id); next.set(run.id, nativeTranscript); } } for (const id of fallbackByRunRef.current.keys()) { if (!nativeRuns.some((run) => run.id === id)) fallbackByRunRef.current.delete(id); } return next; }, [ logTranscriptByRun, nativeRuns, nativeTranscriptByRun, nativeTranscriptErrorsByRun, ]); // The single in-flight run whose turn we stream live (non-terminal). const liveRun = useMemo(() => { if (isTerminalIssueStatus(issueStatus)) return null; if (activeRun && isLiveIssueRun(activeRun, issueStatus)) return activeRun; return (liveRuns ?? []).find((r) => isLiveIssueRun(r, issueStatus)) ?? null; }, [activeRun, issueStatus, liveRuns]); // The live runner turn remains the sole owner of its response until the // canonical comment and settled activity are both ready. This state is // established in a layout effect so the live lane never disappears for a // paint between `liveRun` ending and the persistence handoff beginning. const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null; } | null>(null); const prevLiveRunRef = useRef(null); useLayoutEffect(() => { if (liveRun) { prevLiveRunRef.current = liveRun; setSettlingRun((current) => current && current.id !== liveRun.id ? null : current, ); return; } const prev = prevLiveRunRef.current; prevLiveRunRef.current = null; if (!prev) return; setSettlingRun({ id: prev.id, startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt), }); }, [liveRun]); const heldPaperclipRunnerRunId = liveRun && isNativePaperclipRunnerRun(liveRun) ? liveRun.id : settlingRun && isNativePaperclipRunnerRun( runs.find((run) => run.id === settlingRun.id), ) ? settlingRun.id : null; const heldPaperclipRunnerStartedAtMs = liveRun && heldPaperclipRunnerRunId === liveRun.id ? ((liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt)) : (settlingRun?.startedAtMs ?? null); const heldPaperclipRunnerFinalText = useMemo(() => { if (!heldPaperclipRunnerRunId) return null; const entries = transcriptByRun.get(heldPaperclipRunnerRunId) ?? []; for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]; if ( entry.kind === "assistant" && entry.channel === "final" && entry.text.trim() ) { return entry.text; } } return null; }, [heldPaperclipRunnerRunId, transcriptByRun]); // Runs observed non-terminal while mounted: their turns ANIMATE the fold when // they settle. Runs already terminal at mount collapse instantly. const liveSeenRef = useRef>(new Set()); useEffect(() => { if (liveRun) liveSeenRef.current.add(liveRun.id); }, [liveRun]); // Each terminal run's turn anchors immediately after the run's last comment // (its reply bubble), via the comment.runId linkage — the summary line lands // below the bubble, where the live "Running…" pill sat. A run with no linked // comment (stopped run, or reply not yet fetched) instead slots into the // backbone chronologically at its start time (PAP-367). const lastCommentIdByRun = useMemo(() => { const map = new Map(); for (const comment of comments) { if (comment.deletedAt || !comment.runId || !comment.id) continue; map.set(comment.runId, comment.id); } // A settled Paperclip turn normally attaches to its durable final reply. // Same-turn steering splits that causal interval into timestamped segments, // so each segment must stay unanchored and interleave around the injected // human bubble through the chronological assembler. for (const comment of comments) { const runId = comment.steeredIntoRunId; if (!comment.deletedAt && runId && comment.conversationAnchorAt) { map.delete(runId); } } return map; }, [comments]); const steeringAnchorsByRun = useMemo(() => { const map = new Map(); for (const comment of comments) { const runId = comment.steeredIntoRunId; if (comment.deletedAt || !runId || !comment.conversationAnchorAt) continue; const anchorMs = toMs(comment.conversationAnchorAt); if (!Number.isFinite(anchorMs)) continue; const anchors = map.get(runId) ?? []; if (!anchors.includes(anchorMs)) anchors.push(anchorMs); map.set(runId, anchors); } for (const anchors of map.values()) anchors.sort((a, b) => a - b); return map; }, [comments]); const { data: planDocument, isLoading: planLoading, isError: planError, refetch: retryPlan, } = useIssuePlanDocument(issueId); // A native-runner Plan is part of the turn that wrote its current revision. // Legacy adapters do not expose the semantic write_document boundary needed // for stable embedding, so their Plans remain chronological thread entries. // Prefer a native review request's explicit run binding; while that request // is still being created, infer the native owner from the document timestamp. const planDocumentSourceRunId = useMemo(() => { if (!planDocument) return null; const candidates = new Map< string, { id: string; adapterType: string; runtimeMode?: string | null; agentId?: string | null; createdAt?: Date | string | null; startedAt?: Date | string | null; finishedAt?: Date | string | null; } >(); for (const run of linkedRuns ?? []) { candidates.set(run.runId, { ...run, id: run.runId, adapterType: run.adapterType ?? "", }); } for (const run of liveRuns ?? []) candidates.set(run.id, run); if (activeRun) candidates.set(activeRun.id, activeRun); const linkedReview = (interactions ?? []).find((interaction) => { if (!interaction.sourceRunId) return false; const sourceRun = candidates.get(interaction.sourceRunId); return ( isNativePaperclipRunnerRun(sourceRun) && interactionTargetsPlanRevision(interaction, planDocument) ); }); if (linkedReview?.sourceRunId) return linkedReview.sourceRunId; const documentAtMs = toMs(planDocument.updatedAt); const matchingRuns = [...candidates.values()].filter((run) => { if (!isNativePaperclipRunnerRun(run)) return false; if ( planDocument.updatedByAgentId && run.agentId && planDocument.updatedByAgentId !== run.agentId ) { return false; } const startedAtMs = toMs(run.startedAt ?? run.createdAt); const finishedAtMs = run.finishedAt ? toMs(run.finishedAt) : Number.POSITIVE_INFINITY; return ( startedAtMs <= documentAtMs + 1_000 && documentAtMs <= finishedAtMs + 1_000 ); }); matchingRuns.sort( (a, b) => toMs(b.startedAt ?? b.createdAt) - toMs(a.startedAt ?? a.createdAt), ); return matchingRuns[0]?.id ?? null; }, [activeRun, interactions, linkedRuns, liveRuns, planDocument]); const planTurnItem = useMemo(() => { if (!planDocument || !planDocumentSourceRunId) return null; return { id: `plan-doc:${planDocument.latestRevisionId ?? planDocument.id}`, kind: "plan_document", document: planDocument, }; }, [planDocument, planDocumentSourceRunId]); // Comments, interactions, and the plan-doc marker merged into one // chronological backbone (same sort keys and same-run handoff shift as the // legacy buildIssueChatMessages), so plan-mode confirmation/question cards // land where they happened in the conversation. const orderedEntries = useMemo(() => { const entries: { ms: number; order: number; id: string; item: TaskChatItem; }[] = []; // commentsToTaskChatItems skips deleted comments — mirror its filter so the // two lists stay index-aligned. const visibleComments = projectedComments.filter( (comment) => !comment.deletedAt, ); visibleComments.forEach((comment, index) => { if (queuedCommentIds.has(comment.id)) return; // The live/settling runner lane already renders this run's final answer. // Keep a just-persisted duplicate out of the backbone until the complete // canonical bubble + Worked header can replace that lane atomically. if ( heldPaperclipRunnerRunId && isRunnerResponseComment({ comment, runId: heldPaperclipRunnerRunId, runStartedAtMs: heldPaperclipRunnerStartedAtMs, finalText: heldPaperclipRunnerFinalText, }) ) return; const item = commentItems[index]; if (!item) return; entries.push({ // A held/queued input belongs where the runner actually consumes it, // not where the user first submitted it while another turn was live. ms: comment.conversationAnchorAt ? toMs(comment.conversationAnchorAt) : toMs(comment.createdAt), order: 1 + Math.min(comment.conversationAnchorSequence ?? 0, 999) / 1000, id: item.id, item, }); }); for (const interaction of interactions ?? []) { // Withdrawn/superseded confirmations are retracted calls to action — drop // them so a dead card never stacks above the one that replaced it. if (isSuppressedThreadInteraction(interaction)) continue; // A never-rendered card — a degenerate `ask_user_questions` (e.g. the // onboarding `Test / A` placeholder) or a stale sibling superseded by a // newer question (PAP-437) — is filtered here so it leaves no empty slot // or gap in the ordered backbone (PAP-424, plan from PAP-420). const isSupersededQuestionReceipt = interaction.kind === "ask_user_questions" && interaction.status !== "pending" && interaction.result?.expirationReason === "superseded_by_newer_interaction"; if ( shouldHideInteractionCard(interaction) && !isSupersededQuestionReceipt ) continue; // The plan preview is already embedded at this run's write_document // boundary. Pending controls live in the composer takeover, so mounting // the interaction's second preview here would make the plan jump and // duplicate during the live-to-settled handoff. if ( interaction.status === "pending" && planDocument && planDocumentSourceRunId === interaction.sourceRunId && interactionTargetsPlanRevision(interaction, planDocument) ) { continue; } const createdAtMs = toMs(interaction.createdAt); const handoffAtMs = interaction.kind === "request_confirmation" && interaction.sourceRunId ? latestSameRunHandoffTimestamp({ interactionCreatedAtMs: createdAtMs, sourceRunId: interaction.sourceRunId, comments, timelineEvents: timelineEvents ?? [], linkedRuns: linkedRuns ?? [], liveRuns: liveRuns ?? [], }) : null; const id = `interaction:${interaction.id}`; entries.push({ // Question forms and plan previews remain where they were asked. Their // separately projected human responses stay at resolvedAt. ms: isResolvedPlanConfirmation(interaction) ? (handoffAtMs ?? createdAtMs) : interactionThreadAnchorMs(interaction, handoffAtMs ?? createdAtMs), order: 2, id, item: { id, kind: "interaction", interaction }, }); } for (const document of documents) { if (document.key === "plan") continue; const id = `resource:document:${document.id}`; entries.push({ ms: toMs(document.updatedAt), order: 3, id, item: { id, kind: "protocol", surface: "resource", resourceKind: "document", title: documentDisplayTitle(document), subtitle: `Document · rev ${document.latestRevisionNumber}`, href: buildDocumentAnnotationHash({ documentKey: document.key, threadId: null, commentId: null, }), timestamp: new Date(document.updatedAt).toISOString(), document, }, }); } for (const workProduct of workProducts) { const id = `resource:deliverable:${workProduct.id}`; const href = workProductHref(workProduct); entries.push({ ms: toMs(workProduct.createdAt), order: 3, id, item: { id, kind: "protocol", surface: "resource", resourceKind: "deliverable", title: workProduct.title, subtitle: [ workProduct.type.replaceAll("_", " "), workProduct.status, workProduct.reviewState !== "none" ? workProduct.reviewState : null, ] .filter(Boolean) .join(" · "), href: safeResourceHref(href), timestamp: new Date(workProduct.createdAt).toISOString(), workProduct, }, }); } for (const attachment of selectAgentArtifactAttachments( attachments, workProducts, )) { const id = `resource:attachment:${attachment.id}`; entries.push({ ms: toMs(attachment.createdAt), order: 3, id, item: { id, kind: "protocol", surface: "resource", resourceKind: "attachment", title: attachment.originalFilename ?? "Agent attachment", subtitle: `${attachment.contentType} · ${formatResourceBytes(attachment.byteSize)}`, href: safeResourceHref(attachment.openPath ?? attachment.contentPath), timestamp: new Date(attachment.createdAt).toISOString(), attachment, }, }); } if (planDocument && !planDocumentSourceRunId) { const id = `plan-doc:${planDocument.latestRevisionId ?? planDocument.id}`; entries.push({ ms: toMs(planDocument.updatedAt), order: 0, id, item: { id, kind: "plan_document", document: planDocument, }, }); } for (const item of createdProjectItems) { entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 2 }); } for (const item of createdSkillItems) { entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 2 }); } return entries.sort( (a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id), ); }, [ createdProjectItems, createdSkillItems, comments, projectedComments, commentItems, interactions, documents, workProducts, attachments, timelineEvents, linkedRuns, liveRuns, planDocument, planDocumentSourceRunId, heldPaperclipRunnerRunId, heldPaperclipRunnerStartedAtMs, heldPaperclipRunnerFinalText, queuedCommentIds, ]); const legacyTimelineAnchorsByRun = useMemo(() => { const windows = new Map< string, { adapterType: string; runtimeMode?: string | null; startMs: number; endMs: number; } >(); for (const run of linkedRuns ?? []) { windows.set(run.runId, { adapterType: run.adapterType ?? "", runtimeMode: run.runtimeMode, startMs: toMs(run.startedAt ?? run.createdAt), endMs: run.finishedAt ? toMs(run.finishedAt) : Number.POSITIVE_INFINITY, }); } for (const run of liveRuns ?? []) { windows.set(run.id, { adapterType: run.adapterType, runtimeMode: run.runtimeMode, startMs: toMs(run.startedAt ?? run.createdAt), endMs: run.finishedAt ? toMs(run.finishedAt) : Number.POSITIVE_INFINITY, }); } if (activeRun) { windows.set(activeRun.id, { adapterType: activeRun.adapterType, runtimeMode: activeRun.runtimeMode, startMs: toMs(activeRun.startedAt ?? activeRun.createdAt), endMs: activeRun.finishedAt ? toMs(activeRun.finishedAt) : Number.POSITIVE_INFINITY, }); } const anchorsByRun = new Map(); for (const [runId, window] of windows) { if ( isNativePaperclipRunnerRun(window) || !Number.isFinite(window.startMs) ) { continue; } const anchors = orderedEntries .map((entry) => entry.ms) .filter( (entryMs) => Number.isFinite(entryMs) && entryMs > window.startMs && entryMs <= window.endMs, ); if (anchors.length > 0) { anchorsByRun.set( runId, [...new Set(anchors)].sort((a, b) => a - b), ); } } return anchorsByRun; }, [activeRun, linkedRuns, liveRuns, orderedEntries]); // Boolean gate (stable across the host's per-render brief objects) so the // heavy assembly memo doesn't recompute on every parent render. const hasBrief = Boolean(issueBrief); const { items, settledRunIds, settledReplyRunIds } = useMemo<{ items: TaskChatItem[]; settledRunIds: Set; settledReplyRunIds: Set; }>(() => { // Runs whose settled turn made it into the assembled thread — the live tail // hands off to this as its "the settled turn has rendered" signal (PAP-462 // B4), so the transcript stays mounted through the settle gap without ever // double-rendering beside its own settled turn. const settledRunIds = new Set(); const settledReplyRunIds = new Set(); const entriesWithFailures = [...orderedEntries]; // Settled turns for every terminal run whose transcript we have. Provider // commentary, grouped activity (including reasoning summaries), and // terminal request receipts remain in transcript order. Final text is // owned by a posted completion comment when one exists. Yielded interaction // runs remain activity/waiting state and never occupy a final-response slot. const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; startMs: number; }[] = []; // Raw summary inputs per turn id, so back-to-back same-agent runs can // coalesce into one "Worked" row in the final pass (PAP-362). const turnMergeMetaById = new Map(); let previousExecutionWaitKey: string | null = null; for (const source of runs) { if (!isTerminalRunStatus(source.status)) continue; if (liveRun && source.id === liveRun.id) continue; const entries = transcriptByRun.get(source.id) ?? []; const meta = linkedRunMetaById.get(source.id); // A workspace admission attempt never started provider work. Its live // successor owns the waiting indicator; retain this attempt in the run log. if (source.status === "cancelled" && meta?.errorCode === "workspace_busy") { settledRunIds.add(source.id); continue; } // /new is represented by its durable comment boundary, not an empty // model response or a completed-run notice. if (meta?.resultJson?.conversationReset === true) { settledRunIds.add(source.id); continue; } // A queued continuation can become unnecessary while another turn finishes // the task. Keep that cancellation in the run log, not the conversation. // Apply this before native stop markers are assembled as well. if ( source.status === "cancelled" && entries.length === 0 && (meta?.errorCode === "issue_not_in_progress" || (meta?.errorCode === "issue_terminal_status" && !meta.startedAt)) ) { settledRunIds.add(source.id); continue; } // Historical pre-admission cancellations describe a wait, not failed // work. Collapse repeated observations of that hold, retaining real // execution and any transcript/comment content between wait episodes. const executionWait = source.status === "cancelled" && !meta?.startedAt && meta?.errorCode === "execution_reconciliation_required" && entries.length === 0 && !lastCommentIdByRun.has(source.id); if (executionWait) { const wait = meta?.resultJson?.executionWait; const waitKey = wait && typeof wait === "object" && "recoveryActionId" in wait ? String(wait.recoveryActionId) : "execution_reconciliation_required"; settledRunIds.add(source.id); if (previousExecutionWaitKey !== waitKey) { const id = `${source.id}:execution-wait`; entriesWithFailures.push({ ms: toMs(meta?.finishedAt ?? meta?.createdAt), order: 3, id, item: { id, kind: "marker", variant: "interrupted", tone: "neutral", label: "Waiting to resume", detail: "The previous execution needs to be checked before work can continue. See the task’s execution hold for the next action. Individual checks remain in the run history.", }, }); } previousExecutionWaitKey = waitKey; continue; } previousExecutionWaitKey = null; const acceptedSummary = acceptedSemanticResultSummary(meta?.resultJson); const parsedSource = transcriptToTaskChatItems(entries, { runId: source.id, agentName: meta?.agentName, running: false, }); const sourceHasPendingAttention = (interactions ?? []).some( (interaction) => interaction.sourceRunId === source.id && interaction.status === "pending", ); const sourceAcceptedResponseWake = source.status === "succeeded" && !sourceHasPendingAttention && paperclipRunnerAcceptedResponseWake(parsedSource, source.id); const sourceYielded = (acceptedSemanticResultDisposition(meta?.resultJson) === "yielded" || entries.some( (entry) => entry.kind === "run_result" && entry.disposition === "yielded", )) && !sourceAcceptedResponseWake; const sourceIsPaperclipRunner = isNativePaperclipRunnerRun(source); const decidedCommentId = presentationDecisionCommentId(meta?.resultJson); const progressCommentIds = semanticProgressCommentIds(meta?.resultJson); const sourcePresentationCommentId = decidedCommentId === undefined ? ([...comments] .reverse() .find( (comment) => !comment.deletedAt && comment.runId === source.id && !progressCommentIds.has(comment.id), )?.id ?? null) : decidedCommentId !== null && comments.some( (comment) => !comment.deletedAt && comment.runId === source.id && comment.id === decidedCommentId, ) ? decidedCommentId : null; const sourceHasPresentationComment = sourcePresentationCommentId !== null; const sourcePresentationText = sourcePresentationCommentId ? (comments.find( (comment) => comment.id === sourcePresentationCommentId, )?.body ?? null) : null; const sourceHasNativeResponse = sourceIsPaperclipRunner && !sourceYielded && (sourceHasPresentationComment || Boolean(acceptedSummary) || entries.some( (entry) => (entry.kind === "assistant" && entry.channel !== "progress" && Boolean(entry.text.trim())) || (entry.kind === "run_result" && Boolean(entry.summary.trim())), )); const sourceHasNativeStop = sourceIsPaperclipRunner && (source.status === "failed" || source.status === "timed_out" || source.status === "cancelled" || source.status === "interrupted"); if (sourceHasNativeStop) { settledRunIds.add(source.id); const code = meta?.errorCode ?? (source.status === "timed_out" ? "native_runner_timed_out" : "native_runner_process_exited"); const label = code === "native_provider_approval_required" && source.status === "failed" ? "Approval required" : code === "native_provider_usage_limit" && source.status === "failed" ? "Usage limit reached" : source.status === "cancelled" ? "Run cancelled" : source.status === "interrupted" ? "Run interrupted" : source.status === "timed_out" ? "Run timed out" : "Run failed"; const responseBoundary = sourceHasNativeResponse ? "after returning a final response" : "before returning an answer"; const detail = source.status === "cancelled" ? `The run was cancelled ${responseBoundary}.` : source.status === "interrupted" ? `The run was interrupted ${responseBoundary}.` : code === "native_provider_approval_required" ? "This operation requires approval, but this runner has no interactive approval handler. Review the operation and update the agent's permission setting before retrying." : code === "native_provider_model_rejected" ? "The provider rejected the selected model. Check the model ID and your account's access, save the agent configuration, then retry. View the run for the provider's full error." : code === "native_provider_usage_limit" && source.status === "failed" ? "The model provider has reached its current usage limit. Try again after the limit resets." : code === "provider_frame_too_large" ? "Provider output exceeded the safe limit." : source.status === "timed_out" ? `The runner timed out ${responseBoundary} (${code}).` : `The runner stopped ${responseBoundary} (${code}).`; const id = `${source.id}:failure`; const runAgent = meta?.agentId ? agentMap?.get(meta.agentId) : undefined; const finishedAt = meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt; entriesWithFailures.push({ ms: toMs(finishedAt), order: 3, id, item: { id, kind: "marker", variant: "interrupted", tone: source.status === "cancelled" ? "neutral" : "error", label, detail, collapsible: true, runId: source.id, createdAtIso: finishedAt ? new Date(finishedAt).toISOString() : undefined, runHref: runAgent ? `/agents/${encodeURIComponent(runAgent.urlKey)}/runs/${encodeURIComponent(source.id)}` : undefined, }, }); } // A failed run still needs its own retry target when it produced output // or a final comment. Otherwise the thread keeps retrying an older run. const sourceHasLegacyStop = !sourceIsPaperclipRunner && ( source.status === "failed" || source.status === "timed_out" || (source.status === "cancelled" && entries.length === 0) ); if (sourceHasLegacyStop) { settledRunIds.add(source.id); const code = meta?.errorCode ?? "native_runner_process_exited"; const retryDetail = meta?.scheduledRetryAt ? "Retry scheduled automatically." : canRetryFailedRun ? "You can retry this message now." : "Your message is preserved."; const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id); const detail = aiRequest ? aiRequest.status === "pending" ? "The selected AI account is unavailable. Fix it in the connection card." : "This run stopped because its AI account was unavailable." : source.status === "cancelled" ? code === "execution_reconciliation_required" ? "The previous execution must be checked before this task can continue. Your message is preserved. View the stopped run for details." : "Execution was stopped before returning an answer." : code === "provider_frame_too_large" ? `Provider output exceeded the safe limit. ${retryDetail}` : code.startsWith("workspace_git_scan_") ? `Workspace setup failed before the agent started. ${retryDetail}` : `The runner stopped before returning an answer (${code}). ${retryDetail}`; const id = `${source.id}:failure`; entriesWithFailures.push({ ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt), order: 3, id, item: { id, kind: "marker", variant: "interrupted", label: source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed", runId: source.status === "cancelled" ? undefined : source.id, tone: source.status === "cancelled" ? "neutral" : "error", detail, }, }); } if (entries.length === 0) { if (sourceIsPaperclipRunner && sourceYielded) { settledRunIds.add(source.id); continue; } if ( isNativePaperclipRunnerRun(source) && acceptedSummary && !sourceHasPresentationComment ) { const startedAtMs = toMs(meta?.startedAt ?? meta?.createdAt); const finishedAtMs = toMs(meta?.finishedAt); const durationMs = startedAtMs > 0 && finishedAtMs > 0 ? Math.max(0, finishedAtMs - startedAtMs) : undefined; const failed = source.status !== "succeeded"; const turnId = `${source.id}:turn`; turnMergeMetaById.set(turnId, { agentKey: meta?.agentId ?? "", agentName: meta?.agentName, parts: [{ entries, durationMs, failed }], }); settledTurns.push({ turn: { id: turnId, kind: "turn", settled: true, agentName: meta?.agentName ?? (meta?.agentId ? agentMap?.get(meta.agentId)?.name : undefined), agent: meta?.agentId ? agentMap?.get(meta.agentId) ?? { id: meta.agentId } : undefined, agentIcon: meta?.agentId ? agentMap?.get(meta.agentId)?.icon : undefined, standaloneHeader: true, animateFold: liveSeenRef.current.has(source.id), items: [], finalResponse: paperclipRunnerFinalResponse([], { runId: source.id, agentName: meta?.agentName, fallbackSummary: acceptedSummary, }), summary: buildTurnSummary(entries, { durationMs, failed }), }, anchorCommentId: null, startMs: startedAtMs > 0 ? startedAtMs : Number.POSITIVE_INFINITY, }); settledRunIds.add(source.id); settledReplyRunIds.add(source.id); } else if (!sourceHasNativeStop && !sourceHasLegacyStop && !lastCommentIdByRun.has(source.id)) { settledRunIds.add(source.id); const id = `${source.id}:terminal-notice`; entriesWithFailures.push({ ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt), order: 3, id, item: { id, kind: "marker", variant: "turn_boundary", label: source.status === "cancelled" ? "Stopped" : "Run completed", detail: source.status === "cancelled" ? "This turn was cancelled before it returned a response." : "The runner returned no user-facing response.", }, }); } continue; } const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN; const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN; const durationMs = Number.isFinite(started) && Number.isFinite(finished) ? Math.max(0, finished - started) : undefined; if ( source.status === "succeeded" && !sourceHasPresentationComment && resolvedWithoutUserFacingResponse(meta?.resultJson) && !sourceHasNativeResponse ) { const id = `${source.id}:terminal-notice`; entriesWithFailures.push({ ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt), order: 3, id, item: { id, kind: "marker", variant: "turn_boundary", label: "Run completed", detail: "The runner returned no user-facing response.", }, }); } const failed = source.status !== "succeeded"; const startSlotRaw = meta?.startedAt ?? meta?.createdAt; const startSlotMs = startSlotRaw ? toMs(startSlotRaw) : Number.POSITIVE_INFINITY; const timelineAnchors = sourceIsPaperclipRunner ? (steeringAnchorsByRun.get(source.id) ?? []) : (legacyTimelineAnchorsByRun.get(source.id) ?? []); const segments = splitTranscriptAtAnchors( entries, startSlotMs, timelineAnchors, ); const projectedSegments = segments.map((segment) => { const parsedTranscript = transcriptToTaskChatItems(segment.entries, { runId: source.id, agentName: meta?.agentName, running: false, }); const parsed = source.id === planDocumentSourceRunId && planTurnItem ? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem) : parsedTranscript; return { segment, parsed, timelineItems: sourceIsPaperclipRunner ? paperclipRunnerTimelineItems(parsed) : parsed, }; }); const sourceResponseText = sourceIsPaperclipRunner && !sourceYielded ? (sourcePresentationText ?? paperclipRunnerFinalResponse(parsedSource, { runId: source.id, agentName: meta?.agentName, fallbackSummary: acceptedSummary, })?.text) : undefined; const timelineItemsBySegment = sourceIsPaperclipRunner ? omitProgressRepeatedByResponseAcrossSegments( projectedSegments.map(({ timelineItems }) => timelineItems), sourceResponseText, ) : projectedSegments.map(({ timelineItems }) => timelineItems); let lastPopulatedSegmentIndex = -1; for (let index = projectedSegments.length - 1; index >= 0; index -= 1) { if ((projectedSegments[index]?.segment.entries.length ?? 0) > 0) { lastPopulatedSegmentIndex = index; break; } } for (const [segmentIndex, projected] of projectedSegments.entries()) { const { segment, parsed } = projected; if (segment.entries.length === 0) continue; const finalResponse = sourceIsPaperclipRunner && !sourceYielded && !sourceHasPresentationComment ? sourceAcceptedResponseWake ? // A steering anchor may split acceptance from its terminal. // Keep the whole-run proof and render its answer exactly once. segmentIndex === lastPopulatedSegmentIndex ? paperclipRunnerFinalResponse(parsedSource, { runId: source.id, agentName: meta?.agentName, }) : undefined : paperclipRunnerFinalResponse(parsed, { runId: source.id, agentName: meta?.agentName, fallbackSummary: segmentIndex === lastPopulatedSegmentIndex ? acceptedSummary : undefined, }) : undefined; const children = settledRunChildren( timelineItemsBySegment[segmentIndex] ?? [], ); if (children.length === 0 && !finalResponse && !sourceIsPaperclipRunner) continue; settledRunIds.add(source.id); if (finalResponse) settledReplyRunIds.add(source.id); const segmented = timelineAnchors.length > 0; const turnId = segmented ? `${source.id}:turn:${segmentIndex}` : `${source.id}:turn`; const segmentFinishedMs = Number.isFinite(segment.endMs) ? segment.endMs : finished; const segmentDurationMs = Number.isFinite(segment.startMs) && Number.isFinite(segmentFinishedMs) ? Math.max(0, segmentFinishedMs - segment.startMs) : durationMs; turnMergeMetaById.set(turnId, { agentKey: meta?.agentId ?? "", agentName: meta?.agentName, parts: [ { entries: segment.entries, durationMs: segmentDurationMs, failed }, ], }); settledTurns.push({ turn: { id: turnId, kind: "turn", settled: true, agentName: meta?.agentName ?? (meta?.agentId ? agentMap?.get(meta.agentId)?.name : undefined), agent: meta?.agentId ? agentMap?.get(meta.agentId) ?? { id: meta.agentId } : undefined, agentIcon: meta?.agentId ? agentMap?.get(meta.agentId)?.icon : undefined, standaloneHeader: sourceIsPaperclipRunner, continuedAfterSteering: sourceIsPaperclipRunner && segmentIndex > 0, animateFold: liveSeenRef.current.has(source.id), items: children, finalResponse, summary: buildTurnSummary(segment.entries, { durationMs: segmentDurationMs, failed, }), }, anchorCommentId: segmented ? null : sourceIsPaperclipRunner ? sourceHasPresentationComment ? sourcePresentationCommentId : null : (lastCommentIdByRun.get(source.id) ?? null), // A post-steering segment ties the acknowledgement-backed human // bubble and is therefore inserted immediately after it. The first // segment retains the normal run-start slot. startMs: segment.startMs, }); } } // Same-turn steering is visible before a live run settles. Freeze every // completed pre-steer interval into the main chronology immediately, then // leave only the last interval to the streaming tail below. Reusing the // settled ids and projection makes the terminal handoff structurally // identical instead of moving one unsplit live turn around the steer. if (liveRun) { const timelineAnchors = isNativePaperclipRunnerRun(liveRun) ? (steeringAnchorsByRun.get(liveRun.id) ?? []) : (legacyTimelineAnchorsByRun.get(liveRun.id) ?? []); if (timelineAnchors.length > 0) { const entries = transcriptByRun.get(liveRun.id) ?? []; const startSlotMs = (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt); const segments = splitTranscriptAtAnchors( entries, startSlotMs, timelineAnchors, ); for (const [segmentIndex, segment] of segments.slice(0, -1).entries()) { if (segment.entries.length === 0) continue; const parsedTranscript = transcriptToTaskChatItems(segment.entries, { runId: liveRun.id, agentName: liveRun.agentName, running: false, }); const parsed = liveRun.id === planDocumentSourceRunId && planTurnItem ? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem) : parsedTranscript; const children = settledRunChildren( isNativePaperclipRunnerRun(liveRun) ? paperclipRunnerTimelineItems(parsed) : parsed, ); if (children.length === 0) continue; const turnId = `${liveRun.id}:turn:${segmentIndex}`; const segmentDurationMs = Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs) ? Math.max(0, segment.endMs - segment.startMs) : undefined; turnMergeMetaById.set(turnId, { agentKey: liveRun.agentId ?? "", agentName: liveRun.agentName, parts: [ { entries: segment.entries, durationMs: segmentDurationMs, failed: false, }, ], }); settledTurns.push({ turn: { id: turnId, kind: "turn", settled: true, agentName: liveRun.agentName ?? (liveRun.agentId ? agentMap?.get(liveRun.agentId)?.name : undefined), agent: liveRun.agentId ? agentMap?.get(liveRun.agentId) ?? { id: liveRun.agentId } : undefined, agentIcon: liveRun.agentId ? agentMap?.get(liveRun.agentId)?.icon : undefined, standaloneHeader: isNativePaperclipRunnerRun(liveRun), continuedAfterSteering: isNativePaperclipRunnerRun(liveRun) && segmentIndex > 0, items: children, summary: buildTurnSummary(segment.entries, { durationMs: segmentDurationMs, }), }, anchorCommentId: null, startMs: segment.startMs, }); } } } settledTurns.sort((a, b) => a.startMs < b.startMs ? -1 : a.startMs > b.startMs ? 1 : 0, ); const turnsByAnchor = new Map(); const unanchored: { turn: TaskChatTurnItem; startMs: number }[] = []; for (const { turn, anchorCommentId, startMs } of settledTurns) { if (anchorCommentId) { const list = turnsByAnchor.get(anchorCommentId) ?? []; list.push(turn); turnsByAnchor.set(anchorCommentId, list); } else { unanchored.push({ turn, startMs }); } } // Anchored turns follow their run's reply comment; comment-less turns // (stopped runs, or a reply not yet fetched) interleave chronologically at // their run's start time instead of piling up under the newest message // (PAP-367). entriesWithFailures.sort( (a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id), ); const out = assembleThreadItems( entriesWithFailures, turnsByAnchor, unanchored, ); // PAP-362: two runs replying back-to-back (same agent, nothing but the // agent's own bubbles between) fold into ONE "Worked" row below the last // reply; a user message or interaction keeps them apart. // Round 9: a settled turn directly following its own agent's reply bubble // then attaches to that bubble — the "Worked · …" summary renders on the // bubble's always-visible timestamp line instead of as a standalone row. // PAP-375: the description-as-first-bubble placeholder prepends LAST, after // every assembly/merge pass, so nothing can ever sort above it. return { items: prependIssueBrief( attachSettledTurns( coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById, ), hasBrief, ), settledRunIds, settledReplyRunIds, }; }, [ orderedEntries, canRetryFailedRun, interactions, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, comments, steeringAnchorsByRun, legacyTimelineAnchorsByRun, hasBrief, planDocumentSourceRunId, planTurnItem, agentMap, ]); // Hand off once the settled turn or its reply comment is in the thread; a // stopped run that yields neither is released by the backstop timeout so the // tail never lingers indefinitely. const settlingIsPaperclipRunner = settlingRun != null && isNativePaperclipRunnerRun(runs.find((run) => run.id === settlingRun.id)); const settlingHasComment = settlingRun != null && comments.some((comment) => settlingIsPaperclipRunner ? isRunnerResponseComment({ comment, runId: settlingRun.id, runStartedAtMs: settlingRun.startedAtMs, finalText: heldPaperclipRunnerFinalText, }) : comment.runId === settlingRun.id && !comment.deletedAt, ); const settlingHasDurableInteraction = settlingRun != null && (interactions ?? []).some( (interaction) => interaction.sourceRunId === settlingRun.id && !isSuppressedThreadInteraction(interaction) && !shouldHideInteractionCard(interaction), ); const settledRunRendered = settlingRun != null && (settlingIsPaperclipRunner ? settledRunIds.has(settlingRun.id) && (settlingHasComment || settlingHasDurableInteraction || // A projected terminal answer can already own the response while // the canonical comment request is still in flight. Do not show a // second activity tail just because that fetch is slower. settledReplyRunIds.has(settlingRun.id)) : settlingHasComment || settledRunIds.has(settlingRun.id)); useEffect(() => { if (!settlingRun) return; if (settledRunRendered) { setSettlingRun(null); return; } const timer = window.setTimeout( () => setSettlingRun(null), SETTLING_TAIL_MAX_MS, ); return () => window.clearTimeout(timer); }, [settlingRun, settledRunRendered]); // The tail streams the live run, or — through the settle gap — the last run's // now-static transcript until its settled form renders (PAP-462 B4). const showSettlingTail = !liveRun && settlingRun != null && !settledRunRendered && (transcriptByRun.get(settlingRun.id)?.length ?? 0) > 0; const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null; const tailStreaming = Boolean(liveRun); const tailRunSource = tailRunId ? runs.find((run) => run.id === tailRunId) : undefined; const paperclipRunnerTail = isNativePaperclipRunnerRun(tailRunSource); const suppressPaperclipRunnerTailFinal = Boolean( paperclipRunnerTail && (acceptedSemanticResultDisposition( tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null, ) === "yielded" || (interactions ?? []).some( (interaction) => interaction.sourceRunId === tailRunId && interaction.status === "pending", )), ); const tailAllEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : []; const tailActivityUnavailable = Boolean( tailRunId && nativeTranscriptErrorsByRun.has(tailRunId) && (logTranscriptByRun.get(tailRunId)?.length ?? 0) === 0 && !logsAreInitiallyHydrating, ); const tailTimelineAnchors = tailRunId ? paperclipRunnerTail ? (steeringAnchorsByRun.get(tailRunId) ?? []) : (legacyTimelineAnchorsByRun.get(tailRunId) ?? []) : []; const tailSegmentStartMs = tailTimelineAnchors.length > 0 ? tailTimelineAnchors[tailTimelineAnchors.length - 1] : null; const tailEntries = tailSegmentStartMs == null ? tailAllEntries : tailAllEntries.filter((entry) => toMs(entry.ts) >= tailSegmentStartMs); const tailPlanItem = tailRunId === planDocumentSourceRunId ? planTurnItem : null; // A fresh array is produced on every render. Track every presentation field // that can change without changing the entry count or text length so channel, // lifecycle, result status, and usage-only updates invalidate the projection. const tailEntryContentKey = tailEntries.reduce((key, entry) => { const textIdentity = "text" in entry ? entry.text : ""; const contentIdentity = "content" in entry ? entry.content : ""; const channelIdentity = "channel" in entry ? (entry.channel ?? "") : ""; const lifecycleIdentity = "lifecycle" in entry ? (entry.lifecycle ?? "") : ""; const statusIdentity = "isError" in entry ? (entry.isError ? "error" : "ok") : ""; const usageIdentity = entry.kind === "result" ? `${entry.subtype}:${entry.inputTokens}:${entry.outputTokens}:${entry.cachedTokens}:${entry.costUsd}` : ""; return `${key}|${entry.kind}:${channelIdentity}:${lifecycleIdentity}:${statusIdentity}:${usageIdentity}:${textIdentity}:${contentIdentity}`; }, String(tailEntries.length)); const tailContentKey = `${tailSegmentStartMs ?? "run-start"}:${tailEntryContentKey}`; const blockerContentKey = blockerLinks ? `${blockerLinks.directBlocker.id}:${blockerLinks.ultimateBlocker?.id ?? ""}` : liveWorkLinks ? `live:${liveWorkLinks.steps.map((step) => `${step.blocker.id}:${step.status}`).join(",")}:${liveWorkLinks.nowRunning.map((blocker) => blocker.id).join(",")}` : ""; const tailPlanContentKey = tailPlanItem ? `${tailPlanItem.id}:${tailPlanItem.document.body.length}` : ""; const threadContentKey = `${taskChatContentKey(items)}:${tailContentKey}:${tailPlanContentKey}:${blockerContentKey}`; const repeatBlockersAtBottom = !streamlinedUiEnabled || shouldRepeatTaskChatBlockers(items); const bottomBlockerLinks = repeatBlockersAtBottom ? ( liveWorkLinks ? ( ) : blockerLinks ? ( ) : null ) : null; // Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish // (once terminal), and the "called N tools" summary. Memoized on the // transcript content key so the O(n) tool count is not re-walked every render // while the pill's own second-tick keeps the elapsed readout moving. Through // the settle gap the run reads terminal, so the pill lands on its "Worked" // state instead of flipping back to a spinner. const settlingFinishedAt = settlingRun ? linkedRunMetaById.get(settlingRun.id)?.finishedAt : undefined; const tailStatus = liveRun ? liveRun.status : (runs.find((run) => run.id === settlingRun?.id)?.status ?? "succeeded"); const tailStartedAtMs = tailSegmentStartMs ?? (liveRun ? ((liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt)) : (settlingRun?.startedAtMs ?? null)); const tailFinishedAtMs = liveRun ? liveRun.finishedAt ? toMs(liveRun.finishedAt) : null : settlingFinishedAt ? toMs(settlingFinishedAt) : null; const tailToolSummary = useMemo( () => (tailRunId ? toolCountSummaryFromEntries(tailEntries) : null), // tailEntries is a fresh array each render; tailContentKey tracks its content. // eslint-disable-next-line react-hooks/exhaustive-deps [tailRunId, tailContentKey], ); // The tail's clean rows (PAP-463 C1): the streaming transcript parsed through // the SAME transcriptToTaskChatItems converter the settled turns use, which // drops the debug plumbing (init/stdout/stderr/system) so RunTranscriptView's // noise can never reach the thread. Memoized on the content key (tailEntries // is a fresh array each render) so the O(n) parse is not re-walked while the // pill's own second-tick keeps the elapsed readout moving. `running` follows // tailStreaming: true while live, false through the settle gap — so the tail // renders identically either side of the run finishing. const tailAgentName = liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName; const tailAgentId = liveRun?.agentId ?? linkedRunMetaById.get(tailRunId ?? "")?.agentId ?? issueAssigneeAgentId; const tailAgent = tailAgentId ? agentMap?.get(tailAgentId) : undefined; const visibleTailAgentName = tailAgentName ?? tailAgent?.name ?? null; const visibleTailAgentIcon = tailAgent?.icon ?? null; const tailItems = useMemo( () => { if (!tailRunId) return []; const parsed = transcriptToTaskChatItems(tailEntries, { runId: tailRunId, agentName: tailAgentName, running: tailStreaming, }); return tailPlanItem ? embedPlanDocumentAtWriteBoundary(parsed, tailPlanItem) : parsed; }, // tailEntries is a fresh array each render; tailContentKey tracks its content. // eslint-disable-next-line react-hooks/exhaustive-deps [ tailRunId, tailContentKey, tailStreaming, tailAgentName, tailPlanContentKey, ], ); const tailTurnStatus = useMemo( () => isTerminalRunStatus(tailStatus) ? null : taskChatTurnStatusModel(tailItems), [tailItems, tailStatus], ); const pendingRuntimeRequest = latestPendingRuntimeRequest(tailItems); const handleRuntimeRequestDecision = useCallback( async ( item: TaskChatRuntimeRequestItem, decision: TaskChatRuntimeRequestDecision, ) => { const projected = (interactions ?? []).find((interaction) => interaction.kind === "ask_user_questions" && interaction.sourceRunId === item.runId && interaction.payload.runtimeRequestId === item.requestId); if (projected?.kind === "ask_user_questions") { if (projected.status !== "pending") throw new Error("This question has already been answered or closed."); if (decision.action === "cancel") { if (!onCancelInteraction) throw new Error("Cancelling this question is unavailable."); await onCancelInteraction(projected); return; } if (decision.action !== "submit" || !("response" in decision) || !projected.payload.questionSet || !onSubmitInteractionAnswers) { throw new Error("Submit the answer through the saved question card."); } const response = decision.response; await onSubmitInteractionAnswers(projected, projected.payload.questionSet.questions.map(question => { const answer = response.answers[question.id]; const otherText = question.answerMode === "text" ? answer?.text?.trim() : answer?.customText?.trim(); return { questionId: question.id, optionIds: question.answerMode === "text" ? [] : (answer?.selectedOptionIds ?? []), ...(otherText ? { otherText } : {}) }; })); return; } if (!item.turnId || !item.requestKind) { throw new Error( "This runtime request is missing the provider turn identity needed to resolve it.", ); } let resolution: RuntimeRequestResolution; if (decision.action !== "submit") { resolution = decision; } else if ("response" in decision) { resolution = { action: "submit", response: decision.response }; } else if (item.requestKind === "user_input") { resolution = { action: "submit", answers: Object.fromEntries( Object.entries(decision.values).map(([field, value]) => [ field, { answers: [value] }, ]), ), }; } else if (item.requestKind === "elicitation") { resolution = { action: "submit", content: decision.values }; } else { throw new Error( "This runtime permission does not accept submitted form data.", ); } await heartbeatsApi.resolveRuntimeRequest({ runId: item.runId, requestId: item.requestId, turnId: item.turnId, requestKind: item.requestKind, resolution, }); }, [interactions, onSubmitInteractionAnswers, onCancelInteraction], ); const pendingComposerInputs = useMemo(() => { const result: PendingComposerInput[] = []; const runtimeKey = pendingRuntimeRequest ? `runtime:${pendingRuntimeRequest.runId}:${pendingRuntimeRequest.requestId}` : null; // A projected card owns the durable answer and forwards it to the live // provider. Bypassing it leaves a pending card that blocks completion. const projectedRuntime = pendingRuntimeRequest && (interactions ?? []).some(interaction => interaction.kind === "ask_user_questions" && interaction.sourceRunId === pendingRuntimeRequest.runId && interaction.payload.runtimeRequestId === pendingRuntimeRequest.requestId); if (pendingRuntimeRequest && runtimeKey && !projectedRuntime) { result.push({ key: runtimeKey, kind: "runtime", item: pendingRuntimeRequest, label: pendingRuntimeRequest.questionSet?.title ?? (pendingRuntimeRequest.requestType === "permission" ? "Runtime permission" : "Runtime input"), }); } const durable = (interactions ?? []) .filter( (interaction) => interaction.status === "pending" && interaction.kind !== "connection_intent" && !shouldHideInteractionCard(interaction), ) .sort((left, right) => toMs(right.createdAt) - toMs(left.createdAt)); for (const interaction of durable) { const recoveredRuntimeKey = interaction.kind === "ask_user_questions" && interaction.sourceRunId && interaction.payload.runtimeRequestId ? `runtime:${interaction.sourceRunId}:${interaction.payload.runtimeRequestId}` : null; const key = recoveredRuntimeKey ?? `interaction:${interaction.id}`; result.push({ key, kind: "durable", interaction, label: durableInputLabel(interaction), }); } return result; }, [interactions, pendingRuntimeRequest]); const [takeoverMode, setTakeoverMode] = useState<"open" | "normal">("open"); const [selectedPendingKey, setSelectedPendingKey] = useState( null, ); const knownPendingKeysRef = useRef>(new Set()); useEffect(() => { const currentKeys = new Set( pendingComposerInputs.map((input) => input.key), ); const hasNewInput = pendingComposerInputs.some( (input) => !knownPendingKeysRef.current.has(input.key), ); knownPendingKeysRef.current = currentKeys; setSelectedPendingKey((current) => current && currentKeys.has(current) ? current : (pendingComposerInputs[0]?.key ?? null), ); if (hasNewInput) setTakeoverMode("open"); }, [pendingComposerInputs]); const selectedPendingInput = pendingComposerInputs.find((input) => input.key === selectedPendingKey) ?? pendingComposerInputs[0] ?? null; const interactionDraftKey = selectedPendingInput ? `paperclip:task-input:${issueId ?? "unknown"}:${selectedPendingInput.key}` : undefined; const openPendingTakeover = useCallback(() => { setTakeoverMode("open"); }, []); const showNextPendingInput = useCallback(() => { if (pendingComposerInputs.length < 2) return; const currentIndex = pendingComposerInputs.findIndex( (input) => input.key === selectedPendingInput?.key, ); setSelectedPendingKey( pendingComposerInputs[(currentIndex + 1) % pendingComposerInputs.length] ?.key ?? null, ); }, [pendingComposerInputs, selectedPendingInput?.key]); const skipPendingInput = useCallback( async (input: PendingComposerInput) => { if (input.kind === "runtime") { await handleRuntimeRequestDecision(input.item, { action: "cancel" }); } else { if (!onSkipInteraction) throw new Error("Skipping this interaction is unavailable."); await onSkipInteraction(input.interaction); } setTakeoverMode("normal"); }, [handleRuntimeRequestDecision, onSkipInteraction], ); const runtimeComposerDisabledReason = composerDisabledReason ?? undefined; const assigneeUsesPaperclipRunner = Boolean( issueAssigneeAgentId && agentMap?.get(issueAssigneeAgentId)?.adapterType === "paperclip_runner", ); const [runnerSubmissionPending, setRunnerSubmissionPending] = useState(false); useEffect(() => { if (tailRunId) setRunnerSubmissionPending(false); }, [tailRunId]); useEffect(() => { if (!runnerSubmissionPending || tailRunId) return; const timeout = window.setTimeout( () => setRunnerSubmissionPending(false), 10_000, ); return () => window.clearTimeout(timeout); }, [runnerSubmissionPending, tailRunId]); const handleThreadAdd = useCallback( async (...args: Parameters) => { if (assigneeUsesPaperclipRunner) setRunnerSubmissionPending(true); try { await onAdd(...args); } catch (error) { setRunnerSubmissionPending(false); throw error; } }, [assigneeUsesPaperclipRunner, onAdd], ); const optimisticRunnerStartup = assigneeUsesPaperclipRunner && !tailRunId && (runnerSubmissionPending || commentItems.some( (item) => item.kind === "message" && item.author === "human" && item.optimistic === "pending", )); // Feedback votes keyed by the comment they target (targetType // "issue_comment"), mirroring IssueChatThread — the redesign attaches the // 👍/👎 state to each agent bubble by its comment id (PAP-413). const feedbackVoteByTargetId = useMemo(() => { const map = new Map(); for (const feedbackVote of feedbackVotes ?? []) { if (feedbackVote.targetType !== "issue_comment") continue; map.set(feedbackVote.targetId, feedbackVote.vote); } return map; }, [feedbackVotes]); // copy · 👍 · 👎 cluster for an agent bubble's footer line (PAP-413). Human // and system bubbles get nothing; copy is always available, and the feedback // buttons render only when the host wired a vote handler. const renderMessageActions = useCallback( (item: TaskChatMessageItem) => { if (item.author !== "agent" || item.optimistic) return null; return ( onVote(item.id, vote, options), } : null } /> ); }, [ onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, ], ); const renderQueuedAction = useCallback( (item: TaskChatMessageItem) => { const runId = item.queueTargetRunId; if (composerPause || item.optimistic !== "queued" || !runId || !onInterruptQueued) return null; const isInterrupting = interruptingQueuedRunId === runId; return ( ); }, [composerPause, interruptingQueuedRunId, onInterruptQueued], ); const reopenToolReview = useCallback((interactionId: string) => { setSelectedPendingKey(`interaction:${interactionId}`); setTakeoverMode("open"); }, []); const renderInteraction = useCallback( (item: TaskChatInteractionItem) => ( ), [ agentMap, currentUserId, userLabelMap, onAcceptInteraction, onRejectInteraction, onSubmitInteractionAnswers, onCancelInteraction, onSubmitInteractionVerdicts, imageUploadHandler, mentions, externalReferences, planDocument, planDocumentSourceRunId, settledRunIds, tailRunId, reopenToolReview, ], ); const renderBrief = useCallback( () => issueBrief ? : null, [issueBrief], ); const assignedAgentForNotice = useMemo(() => { if (!currentAssigneeValue?.startsWith("agent:")) return null; const assigneeAgentId = currentAssigneeValue.slice("agent:".length); return agentMap?.get(assigneeAgentId) ?? null; }, [agentMap, currentAssigneeValue]); const takeoverContent = selectedPendingInput ? ( selectedPendingInput.kind === "runtime" ? ( ) : ( ) ) : null; const composerTakeover = takeoverMode === "open" && selectedPendingInput && takeoverContent ? { id: selectedPendingInput.key, label: selectedPendingInput.label, hideLabel: selectedPendingInput.kind === "durable" && selectedPendingInput.interaction.kind === "request_confirmation" && Boolean(selectedPendingInput.interaction.payload.toolAction), pendingCount: pendingComposerInputs.length, content: takeoverContent, onDismiss: () => setTakeoverMode("normal"), onSkip: () => skipPendingInput(selectedPendingInput), onShowNext: showNextPendingInput, inlineSkip: selectedPendingInput.kind === "durable" ? selectedPendingInput.interaction.kind === "ask_user_questions" || selectedPendingInput.interaction.kind === "request_item_verdicts" : selectedPendingInput.item.requestType === "input" && Boolean(selectedPendingInput.item.questionSet), hideSkip: selectedPendingInput.kind === "durable" ? interactionReplacesComposerSkip( selectedPendingInput.interaction, ) : runtimeRequestReplacesComposerSkip(selectedPendingInput.item), } : null; const expansionState = useRef(new Map()); const autoFollowContentKey = `${threadContentKey}:${composerTakeover?.id ?? "composer"}`; // Mobile (PAP-360): the app shell scrolls the DOCUMENT (Layout's main is // overflow-visible with auto height), so the desktop bounded h-dvh chain // collapses the absolute-inset transcript viewport to 0px. Render the thread // in document flow instead (the same scroll={false} path the previews use) // and track auto-follow against window scroll. Both paths include takeover // state so opening or closing composer input preserves bottom pinning. const { isMobile } = useSidebar(); const initialCommentWindow = useRef<{ oldestAt: number; ids: Set; } | null>(null); if (!initialCommentWindow.current && comments.length > 0) { initialCommentWindow.current = { oldestAt: Math.min(...comments.map((comment) => toMs(comment.createdAt))), ids: new Set(comments.map((comment) => comment.id)), }; } const initialCommentRunIds = new Set( comments .filter((comment) => initialCommentWindow.current?.ids.has(comment.id)) .flatMap((comment) => [ comment.runId, comment.createdByRunId, comment.derivedCreatedByRunId, ]), ); const initialRuns = runs.filter((run) => { if ( !initialCommentWindow.current || !isTerminalRunStatus(run.status) || initialCommentRunIds.has(run.id) ) return true; const metadata = linkedRunMetaById.get(run.id); return ( !metadata || toMs(metadata.finishedAt ?? metadata.startedAt ?? metadata.createdAt) >= initialCommentWindow.current.oldestAt ); }); const historyPending = initialHistoryPending || planLoading || initialRuns.some((run) => { // A scheduled retry has not started and has no log to hydrate yet. if (run.status === "scheduled_retry") return false; if ( run.runtimeMode === "native" && (hydratedNativeRunIds ? !hydratedNativeRunIds.has(run.id) : nativeEventsAreInitiallyHydrating) ) return true; if ( run.runtimeMode === "native" && (nativeTranscriptByRun.get(run.id)?.length ?? 0) > 0 ) return false; return run.status !== "queued" && hydratedLogRunIds ? !hydratedLogRunIds.has(run.id) : logsAreInitiallyHydrating; }); const historyError = initialHistoryError || planError || runs.some((run) => run.runtimeMode === "native" ? nativeTranscriptErrorsByRun.has(run.id) && (logTranscriptByRun.get(run.id)?.length ?? 0) === 0 : Boolean(logErrorsByRun?.has(run.id)), ); const [revealedIssue, setRevealedIssue] = useState( () => (historyPending ? undefined : issueId), ); const historyRevealed = revealedIssue === issueId; // Mount and measure the real thread while concealed, then reveal in one // commit. A frame also lets ancestor navigation scroll restoration finish. // Readiness is latched per issue: refetches never hide existing conversation. useEffect(() => { if (historyRevealed || historyPending) return; const frame = requestAnimationFrame(() => setRevealedIssue(issueId)); return () => cancelAnimationFrame(frame); }, [historyPending, historyRevealed, issueId]); const retryHistory = () => { onRetryInitialHistory?.(); retryLogs?.(); retryNativeEvents?.(); void retryPlan(); }; return (
{historyError ? (
Some task history could not be loaded.
) : null} {!historyRevealed ? (
{threadHeader}
) : null}
{items.length === 0 && !tailRunId ? (
{threadHeaderWithBlockers ? (
{threadHeaderWithBlockers}
) : null}
{emptyMessage}
{bottomBlockerLinks ? (
{bottomBlockerLinks}
) : null}
) : ( run.execution?.phase === "recovery_needed", ) ? onTryAgainNoLiveExecutionPath : undefined } tryAgainNoLiveExecutionPathPending={ tryAgainNoLiveExecutionPathPending } onRetryFailedRun={retryFailedRunHandler} retryFailedRunId={retryFailedRunId} onOpenSkill={onOpenSkill} tail={ tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? ( <> {tailRunId || optimisticRunnerStartup ? (
{paperclipRunnerTail || optimisticRunnerStartup ? ( 0 } onRuntimeRequestDecision={ handleRuntimeRequestDecision } /> ) : ( <> )}
) : null} {bottomBlockerLinks} ) : null } contentKey={`${autoFollowContentKey}:${historyRevealed}`} className={isMobile ? undefined : "pt-3"} scroll={!isMobile} /> )}
{assignedAgentForNotice?.status === "paused" ? (
) : null} {showComposer ? (
{composerAccessory} {tailTurnStatus ? ( ) : null}
{queuedMessageQueue && !composerPause ? ( { if (!onReorderQueuedComments) throw new Error("Queue reordering is unavailable."); await onReorderQueuedComments( orderedCommentIds, revision, ); }} onSteer={async (commentId, revision) => { if (!onSteerQueuedComment) throw new Error("Steering is unavailable."); await onSteerQueuedComment(commentId, revision); }} onInterrupt={ onInterruptQueued && queuedMessageQueue.queueId ? async () => { await onInterruptQueued( queuedMessageQueue.targetRunId, ); } : undefined } onDiscard={async (commentId, revision) => { if (commentId.startsWith("optimistic-")) { if (!onCancelQueued) throw new Error("Discard is unavailable."); onCancelQueued(commentId); return; } if (!onDiscardQueuedComment) throw new Error("Discard is unavailable."); await onDiscardQueuedComment(commentId, revision); if (queuedEdit?.commentId === commentId) setQueuedEdit(null); }} /> ) : null}
comment.authorUserId === currentUserId && comment.clientRequestId && !("clientStatus" in comment && comment.clientStatus) ).map((comment) => comment.clientRequestId!))} onReviewConversation={onReviewConversation} onStop={liveRun ? onCancelRun : undefined} stopPending={stopPending} stopScope={stopScope} workMode={issueWorkMode} onWorkModeChange={onWorkModeChange} disabled={Boolean(runtimeComposerDisabledReason)} disabledReason={runtimeComposerDisabledReason} onAttachImage={onAttachImage} onImageUpload={imageUploadHandler} mentions={mentions} enableReassign={enableReassign} conversationMode={conversationMode} reassignOptions={reassignOptions} agentMap={agentMap} userProfileMap={userProfileMap} currentAssigneeValue={currentAssigneeValue} onPendingAssigneeChange={setPendingComposerAssignee} issueStatus={issueStatus} mobile={isMobile} draftKey={draftKey} queuedEdit={queuedEdit} onSaveQueuedEdit={saveQueuedEdit} onCancelQueuedEdit={() => setQueuedEdit(null)} pause={composerPause} takeover={composerTakeover} runnerGoalCapability={runnerGoal.data?.capability ?? null} onRunnerGoalCommand={runnerGoal.executeComposerCommand} onRunnerGoalReassign={reassignForRunnerGoal} pendingTakeover={ pendingComposerInputs.length > 0 ? { count: pendingComposerInputs.length, label: `${pendingComposerInputs.length} pending input${pendingComposerInputs.length === 1 ? "" : "s"}`, onOpen: openPendingTakeover, } : null } />
{footer}
) : null}
); }