Files
PaperClipAI/ui/src/components/TaskChatThread.tsx
T
DottaandPaperclip 7ed122911b Add end-to-end session goals to Paperclip Runner
Add capability-aware slash-goal controls, durable provider goal state, PRP v2 negotiation, autonomous goal execution, and safe local session recovery. Integrate with current master, preserve provider session identity, and verify the browser goal/chat/replacement/clear workflow and unsupported-agent rejection.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-08 16:18:47 -05:00

2678 lines
97 KiB
TypeScript

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,
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 { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
import { useSidebar } from "@/context/SidebarContext";
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { 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<string>,
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<string>,
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<string> = new Set<string>();
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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const resultJson = value as Record<string, unknown>;
const semanticResult = resultJson.semanticResult;
const candidates = [
resultJson.nativeResult,
resultJson.acceptedResult,
semanticResult &&
typeof semanticResult === "object" &&
!Array.isArray(semanticResult)
? (semanticResult as Record<string, unknown>).result
: null,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
continue;
const result = candidate as Record<string, unknown>;
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<string, unknown>).presentationDecision;
if (!decision || typeof decision !== "object" || Array.isArray(decision))
return undefined;
const record = decision as Record<string, unknown>;
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<string> {
if (!value || typeof value !== "object" || Array.isArray(value))
return new Set();
const receipts = (value as Record<string, unknown>).semanticToolReceipts;
if (!receipts || typeof receipts !== "object" || Array.isArray(receipts))
return new Set();
const ids = new Set<string>();
for (const receipt of Object.values(receipts)) {
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
continue;
const record = receipt as Record<string, unknown>;
if (record.operationId !== "report_progress") continue;
const result = record.result;
if (!result || typeof result !== "object" || Array.isArray(result))
continue;
const commentId = (result as Record<string, unknown>).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<string, unknown>)
: {};
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<string, unknown>;
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<string, unknown>;
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<string, unknown>).presentationDecision;
return Boolean(
decision &&
typeof decision === "object" &&
!Array.isArray(decision) &&
(decision as Record<string, unknown>).schema ===
"paperclip.run_presentation_decision.v1" &&
(decision as Record<string, unknown>).chosenSource === "none",
);
}
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
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 {
comments,
interactions,
documents = [],
workProducts = [],
attachments = [],
timelineEvents,
issueId = null,
agentMap,
userLabelMap,
userProfileMap,
currentUserId,
onAdd,
issueWorkMode = "standard",
onWorkModeChange,
composerAccessory,
footer,
showComposer = true,
composerDisabledReason,
emptyMessage = "No messages yet.",
companyId,
linkedRuns,
liveRuns,
activeRun,
onAttachImage,
imageUploadHandler,
mentions,
enableReassign,
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,
} = props;
const queryClient = useQueryClient();
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 ? (
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="top" />
) : blockerLinks ? (
<TaskChatBlockerLinks
directBlocker={blockerLinks.directBlocker}
ultimateBlocker={blockerLinks.ultimateBlocker}
placement="top"
/>
) : null}
</>
) : undefined;
const linkedRunMetaById = useMemo(() => {
const map = new Map<
string,
NonNullable<TaskChatThreadProps["linkedRuns"]>[number]
>();
for (const run of linkedRuns ?? []) map.set(run.runId, run);
return map;
}, [linkedRuns]);
const verificationCaveatsByRunId = useMemo(() => {
const map = new Map<
string,
ReturnType<typeof acceptedSemanticResultVerificationCaveats>
>();
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 (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, 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<RunTranscriptSource[]>(() => {
const map = new Map<string, RunTranscriptSource>();
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,
} = 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,
} = useNativeRunTranscripts(nativeRuns);
const transcriptByRun = useMemo(() => {
const next = new Map(logTranscriptByRun);
for (const run of nativeRuns) {
const logTranscript = logTranscriptByRun.get(run.id) ?? [];
const nativeTranscript = nativeTranscriptByRun.get(run.id) ?? [];
const nativeEventsUnavailable = nativeTranscriptErrorsByRun.has(run.id);
if (
nativeTranscript.length > 0 &&
(!nativeEventsUnavailable || logTranscript.length === 0)
) {
next.set(run.id, nativeTranscript);
}
}
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<typeof liveRun>(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<Set<string>>(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<string, string>();
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<string, number[]>();
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 } = 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<TaskChatPlanDocumentItem | null>(() => {
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,
},
});
}
return entries.sort(
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
);
}, [
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<string, number[]>();
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 } = useMemo<{
items: TaskChatItem[];
settledRunIds: Set<string>;
}>(() => {
// 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<string>();
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<string, SettledTurnMergeMeta>();
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);
const acceptedSummary = acceptedSemanticResultSummary(meta?.resultJson);
const sourceYielded =
acceptedSemanticResultDisposition(meta?.resultJson) === "yielded";
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 =
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 === "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",
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,
},
});
}
if (entries.length === 0) {
// A queued continuation cancelled after the task was completed or parked
// never produced a provider turn. Keep its record in the run log without
// presenting it as a completed chat response.
if (source.status === "cancelled" && meta?.errorCode === "issue_not_in_progress") {
settledRunIds.add(source.id);
continue;
}
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),
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);
} else if (
!sourceIsPaperclipRunner &&
(source.status === "failed" || source.status === "timed_out")
) {
settledRunIds.add(source.id);
const code = meta?.errorCode ?? "native_runner_process_exited";
const retryDetail = meta?.scheduledRetryAt
? "Retry scheduled automatically."
: "You can retry this message now.";
const detail =
code === "provider_frame_too_large"
? `Provider output exceeded the safe limit. ${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: "Run failed",
detail,
},
});
} else if (!sourceHasNativeStop && !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: "Run completed",
detail: "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(
transcriptToTaskChatItems(entries, {
runId: source.id,
agentName: meta?.agentName,
running: false,
}),
{
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
? 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);
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),
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),
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<string, TaskChatTurnItem[]>();
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,
};
}, [
orderedEntries,
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)
: 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 ? (
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" />
) : blockerLinks ? (
<TaskChatBlockerLinks
directBlocker={blockerLinks.directBlocker}
ultimateBlocker={blockerLinks.ultimateBlocker}
placement="bottom"
/>
) : 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,
) => {
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,
});
},
[],
);
const pendingComposerInputs = useMemo<PendingComposerInput[]>(() => {
const result: PendingComposerInput[] = [];
const runtimeKey = pendingRuntimeRequest
? `runtime:${pendingRuntimeRequest.runId}:${pendingRuntimeRequest.requestId}`
: null;
if (pendingRuntimeRequest && runtimeKey) {
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}`;
if (key === runtimeKey) continue;
result.push({
key,
kind: "durable",
interaction,
label: durableInputLabel(interaction),
});
}
return result;
}, [interactions, pendingRuntimeRequest]);
const [takeoverMode, setTakeoverMode] = useState<"open" | "normal">("open");
const [selectedPendingKey, setSelectedPendingKey] = useState<string | null>(
null,
);
const knownPendingKeysRef = useRef<Set<string>>(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<typeof onAdd>) => {
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<string, FeedbackVoteValue>();
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 (
<TaskChatBubbleActions
copyText={item.text}
feedback={
onVote
? {
activeVote: feedbackVoteByTargetId.get(item.id) ?? null,
sharingPreference: feedbackDataSharingPreference,
termsUrl: feedbackTermsUrl,
onVote: (vote, options) => onVote(item.id, vote, options),
}
: null
}
/>
);
},
[
onVote,
feedbackVoteByTargetId,
feedbackDataSharingPreference,
feedbackTermsUrl,
],
);
const renderQueuedAction = useCallback(
(item: TaskChatMessageItem) => {
const runId = item.queueTargetRunId;
if (item.optimistic !== "queued" || !runId || !onInterruptQueued)
return null;
const isInterrupting = interruptingQueuedRunId === runId;
return (
<Button
type="button"
variant="link"
className="h-auto p-0 text-(length:--text-micro)"
disabled={isInterrupting}
onClick={() => void onInterruptQueued(runId)}
>
{isInterrupting ? "Interrupting…" : "Interrupt"}
</Button>
);
},
[interruptingQueuedRunId, onInterruptQueued],
);
const renderInteraction = useCallback(
(item: TaskChatInteractionItem) => (
<TaskChatInteractionCard
item={item}
planDocument={planDocument}
showPlanPreview={
!threadOwnsPlanPreview(
item.interaction,
planDocument,
planDocumentSourceRunId,
settledRunIds,
tailRunId,
)
}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
onUploadImage={imageUploadHandler}
mentions={mentions}
externalReferences={externalReferences}
/>
),
[
agentMap,
currentUserId,
userLabelMap,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
onSubmitInteractionVerdicts,
imageUploadHandler,
mentions,
externalReferences,
planDocument,
planDocumentSourceRunId,
settledRunIds,
tailRunId,
],
);
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" ? (
<TaskChatProtocolCard
item={selectedPendingInput.item}
presentation="takeover"
draftKey={interactionDraftKey}
imageUploadHandler={imageUploadHandler}
mentions={mentions}
onRuntimeRequestDecision={handleRuntimeRequestDecision}
/>
) : (
<TaskChatInteractionCard
item={{
id: `interaction:${selectedPendingInput.interaction.id}`,
kind: "interaction",
interaction: selectedPendingInput.interaction,
}}
presentation="takeover"
draftKey={interactionDraftKey}
planDocument={planDocument}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
onUploadImage={imageUploadHandler}
mentions={mentions}
externalReferences={externalReferences}
/>
)
) : null;
const composerTakeover =
takeoverMode === "open" && selectedPendingInput && takeoverContent
? {
id: selectedPendingInput.key,
label: selectedPendingInput.label,
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 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();
useWindowAutoFollow(isMobile ? autoFollowContentKey : 0, isMobile);
return (
<TaskChatPresentationProvider
mode={streamlinedUiEnabled ? "streamlined" : "production"}
>
<div
className={cn(
"flex flex-col",
!isMobile && "min-h-0 flex-1",
)}
data-testid="task-chat-thread"
>
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
{items.length === 0 && !tailRunId ? (
<div
className={
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
}
>
{threadHeaderWithBlockers ? (
<div
className={cn(
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4",
isMobile ? "pt-4" : "pt-3",
streamlinedUiEnabled && "md:px-0",
)}
data-testid="task-chat-thread-header"
>
{threadHeaderWithBlockers}
</div>
) : null}
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
{bottomBlockerLinks ? (
<div
className={cn(
"mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4",
streamlinedUiEnabled && "md:px-0",
)}
>
{bottomBlockerLinks}
</div>
) : null}
</div>
) : (
<TaskChatThreadView
items={items}
attachments={attachments}
header={threadHeaderWithBlockers}
renderInteraction={renderInteraction}
renderBrief={
issueBrief
? () => <TaskChatDescriptionBubble brief={issueBrief} />
: undefined
}
renderMessageActions={renderMessageActions}
renderQueuedAction={renderQueuedAction}
onTryAgainNoLiveExecutionPath={
issueStatus === "blocked"
? onTryAgainNoLiveExecutionPath
: undefined
}
tryAgainNoLiveExecutionPathPending={
tryAgainNoLiveExecutionPathPending
}
onRetryFailedRun={onRetryFailedRun}
retryFailedRunId={retryFailedRunId}
tail={
tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? (
<>
{tailRunId || optimisticRunnerStartup ? (
<div data-testid="task-chat-live-transcript">
{paperclipRunnerTail || optimisticRunnerStartup ? (
<TaskChatRunnerTurn
runId={tailRunId}
agentName={visibleTailAgentName}
agentIcon={visibleTailAgentIcon}
items={tailItems}
status={
optimisticRunnerStartup ? "queued" : tailStatus
}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
activityUnavailable={tailActivityUnavailable}
suppressFinal={suppressPaperclipRunnerTailFinal}
continuedAfterSteering={
paperclipRunnerTail &&
tailTimelineAnchors.length > 0
}
onRuntimeRequestDecision={
handleRuntimeRequestDecision
}
/>
) : (
<>
<TaskChatLiveRunPill
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
<TaskChatLiveTail
items={tailItems}
emptyMessage={
tailStatus === "queued"
? "Waiting to start..."
: (liveRun && liveRun.id === tailRunId
? liveRun.currentStatusMessage
: null) || "Waiting for transcript..."
}
/>
</>
)}
</div>
) : null}
{bottomBlockerLinks}
</>
) : null
}
contentKey={autoFollowContentKey}
className={isMobile ? undefined : "pt-3"}
scroll={!isMobile}
/>
)}
</div>
{assignedAgentForNotice?.status === "paused" ? (
<div className="mx-auto w-full max-w-(--tc-shell-max-w) px-4 pt-2">
<IssueAssigneePausedNotice
agent={assignedAgentForNotice}
onResume={onResumeAssignee}
resuming={resumeAssigneePending}
/>
</div>
) : null}
{showComposer ? (
<div
data-testid="task-chat-composer-dock"
className={cn(
"sticky",
// Mobile mirrors the flag-off thread's dock: lifted above the
// safe-area inset and clear of the auto-hiding bottom nav, above
// page content in the document-flow stacking context. The bottom
// offset (--tc-composer-bottom) tracks the nav: Layout raises it to
// the nav height while the nav is visible so the composer's action
// row is never occluded, and drops it back to the safe-area dock
// when the nav auto-hides (PAP-495). transition-[bottom] rides the
// nav's own 200ms slide; the offset only changes on nav toggles, so
// it never animates mid-scroll.
isMobile
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
: "bottom-0 z-10",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-4 pb-2",
streamlinedUiEnabled && "md:px-0 md:pb-0",
(!streamlinedUiEnabled || isMobile) &&
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
)}
>
{composerAccessory}
{tailTurnStatus ? (
<TaskChatTurnStatusIsland model={tailTurnStatus} />
) : null}
<RunnerGoalWidget control={runnerGoal} />
<div
className="relative isolate flex flex-col"
data-testid="task-chat-composer-stack"
>
{queuedMessageQueue ? (
<TaskChatQueuedMessages
queue={queuedMessageQueue}
onEdit={beginQueuedEdit}
onReorder={async (orderedCommentIds, revision) => {
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.targetRunId
? 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}
<div className="relative z-10">
<TaskChatComposer
onAdd={handleThreadAdd}
workMode={issueWorkMode}
onWorkModeChange={onWorkModeChange}
disabled={Boolean(runtimeComposerDisabledReason)}
disabledReason={runtimeComposerDisabledReason}
onAttachImage={onAttachImage}
onImageUpload={imageUploadHandler}
mentions={mentions}
enableReassign={enableReassign}
reassignOptions={reassignOptions}
agentMap={agentMap}
userProfileMap={userProfileMap}
currentAssigneeValue={currentAssigneeValue}
onPendingAssigneeChange={setPendingComposerAssignee}
issueStatus={issueStatus}
mobile={isMobile}
draftKey={draftKey}
queuedEdit={queuedEdit}
onSaveQueuedEdit={saveQueuedEdit}
onCancelQueuedEdit={() => setQueuedEdit(null)}
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
}
/>
</div>
</div>
{footer}
</div>
) : null}
</div>
</TaskChatPresentationProvider>
);
}