mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
## Thinking Path > - Paperclip manages work done by AI agents. > - The task thread lets an operator retry a failed run. > - Legacy runs with transcript output did not get a failure marker. > - The thread could therefore offer Try again for an older failure. > - The server correctly reused that failure's existing retry, even when it had already failed. > - This change keeps the latest failure actionable and reports stopped retry responses to the operator. ## Linked Issues or Issue Description **What happened?** Try again could return success without starting work. An initial setup failure had an empty transcript. Its later retry produced output and failed. Only the initial failure had a retry marker, so the button kept requesting the initial failure's already-failed successor. **Expected behavior** Try again targets the latest failed attempt. An already-stopped retry response shows an error and refreshes the task's run state. **Steps to reproduce** 1. Start a legacy adapter task that fails before producing transcript output. 2. Retry it. Let this attempt produce output and a final failure comment before it fails. 3. Click Try again in the task thread. 4. Before this fix, the click targets the original failure and replays the stopped successor. **Paperclip version or commit** Reproduced against `1483bb8bcf`; the regression is also present on the branch base `8813a50105`. **Deployment mode** Authenticated server with a legacy adapter. The bug is in the shared task UI and retry API client. Related: #11650 adds a different recovery-notice action. This change fixes failed-run markers and retry response handling. Searches found no duplicate of this failure case. ## What Changed - Render legacy failure markers even when the run has a transcript or final comment. - Keep later cancelled automatic retries from replacing the failed run's retry action. - Reject already-stopped retry responses in the API client so existing error feedback appears. - Refresh task run queries after both successful and failed retry requests. - Add regression coverage for failed and timed-out attempts, execution gates with output, and retry response states. ## Verification - Before the fix, the new regression tests failed: two selected the original failure, and four accepted a stopped successor as success. - Targeted task-thread, retry API, marker, and issue-page tests: 279 passed. - `pnpm --filter @paperclipai/ui typecheck`: passed. - `pnpm --filter @paperclipai/ui build`: passed. - `pnpm check:token-gates`: passed. - Full UI suite: 6,529 tests passed across 626 files. - [CI run 35650385023](https://github.com/paperclipai/paperclip/actions/runs/35650385023): all 53 checks passed, including full workspace build, typecheck, unit/integration suites, and browser tests. The redundant local full-workspace test run was stopped after CI passed; it is not counted as a completed local pass. - Greptile: 5/5 on `608ee58c99`, with no review threads or unresolved comments. The branch is mergeable. - `pnpm -r typecheck` and `pnpm build` were attempted. Both stop at the Runner's Rust checks because this host has no `cargo`. The full workspace checks passed in CI. ## Risks Low risk. This changes UI presentation and response handling only. The server's exact-retry idempotency, authorization, execution ownership, and recovery gates remain in place. No schema changes or live task mutations. Existing documentation describes this retry action; the fix restores that behavior. ## Model Used OpenAI GPT-6 (Codex), with reasoning, repository tools, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (targeted checks; full-workspace limits described above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (existing behavior restored; no documentation change needed) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
3101 lines
118 KiB
TypeScript
3101 lines
118 KiB
TypeScript
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<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> & {
|
||
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 ? (
|
||
<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 (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<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,
|
||
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<string, NonNullable<ReturnType<typeof logTranscriptByRun.get>>>(),
|
||
);
|
||
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<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,
|
||
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<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,
|
||
},
|
||
});
|
||
}
|
||
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<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, settledReplyRunIds } = useMemo<{
|
||
items: TaskChatItem[];
|
||
settledRunIds: Set<string>;
|
||
settledReplyRunIds: 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 settledReplyRunIds = 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>();
|
||
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<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,
|
||
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 ? (
|
||
<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,
|
||
) => {
|
||
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<PendingComposerInput[]>(() => {
|
||
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<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 (composerPause || 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>
|
||
);
|
||
},
|
||
[composerPause, interruptingQueuedRunId, onInterruptQueued],
|
||
);
|
||
|
||
const reopenToolReview = useCallback((interactionId: string) => {
|
||
setSelectedPendingKey(`interaction:${interactionId}`);
|
||
setTakeoverMode("open");
|
||
}, []);
|
||
|
||
const renderInteraction = useCallback(
|
||
(item: TaskChatInteractionItem) => (
|
||
<TaskChatInteractionCard
|
||
item={item}
|
||
onReviewRequest={reopenToolReview}
|
||
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,
|
||
reopenToolReview,
|
||
],
|
||
);
|
||
|
||
const renderBrief = useCallback(
|
||
() => issueBrief ? <TaskChatDescriptionBubble brief={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" ? (
|
||
<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,
|
||
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<string, boolean>());
|
||
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<string>;
|
||
} | 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<string | null | undefined>(
|
||
() => (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 (
|
||
<TaskChatExpansionState.Provider value={expansionState.current}>
|
||
<TaskChatScrollReady.Provider value={!historyPending}>
|
||
<TaskChatWindowScroll
|
||
contentKey={isMobile ? autoFollowContentKey : 0}
|
||
enabled={isMobile && historyRevealed}
|
||
/>
|
||
<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(
|
||
"relative flex flex-col",
|
||
!isMobile && "min-h-0 flex-1",
|
||
)}
|
||
aria-busy={!historyRevealed}
|
||
>
|
||
{historyError ? (
|
||
<div
|
||
role="status"
|
||
className="absolute inset-x-0 top-0 z-20 mx-auto flex w-full max-w-(--tc-shell-max-w) items-center gap-2 border border-border bg-background px-4 py-2 text-sm text-muted-foreground"
|
||
>
|
||
Some task history could not be loaded.
|
||
<Button variant="ghost" size="sm" onClick={retryHistory}>
|
||
Retry
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
{!historyRevealed ? (
|
||
<div
|
||
className="absolute inset-0 z-10 overflow-hidden bg-background"
|
||
data-testid="task-chat-history-loading"
|
||
role="status"
|
||
aria-label="Loading conversation"
|
||
>
|
||
<div className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-4 px-4 py-3">
|
||
{threadHeader}
|
||
<Skeleton className="h-16 w-3/4 animate-none" />
|
||
<Skeleton className="h-24 w-4/5 self-end animate-none" />
|
||
<Skeleton className="h-16 w-3/4 animate-none" />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<div
|
||
className={cn(
|
||
"flex flex-col",
|
||
!isMobile && "min-h-0 flex-1",
|
||
!historyRevealed && "invisible",
|
||
)}
|
||
inert={!historyRevealed}
|
||
>
|
||
{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={renderBrief}
|
||
renderMessageActions={renderMessageActions}
|
||
renderQueuedAction={renderQueuedAction}
|
||
onTryAgainNoLiveExecutionPath={
|
||
issueStatus === "blocked" &&
|
||
!requiresExecutionReconciliation(
|
||
props.recoveryAction?.cause,
|
||
) &&
|
||
!linkedRuns?.some(
|
||
(run) => run.execution?.phase === "recovery_needed",
|
||
)
|
||
? onTryAgainNoLiveExecutionPath
|
||
: undefined
|
||
}
|
||
tryAgainNoLiveExecutionPathPending={
|
||
tryAgainNoLiveExecutionPathPending
|
||
}
|
||
onRetryFailedRun={retryFailedRunHandler}
|
||
retryFailedRunId={retryFailedRunId}
|
||
onOpenSkill={onOpenSkill}
|
||
tail={
|
||
tailRunId ||
|
||
optimisticRunnerStartup ||
|
||
bottomBlockerLinks ? (
|
||
<>
|
||
{tailRunId || optimisticRunnerStartup ? (
|
||
<div data-testid="task-chat-live-transcript">
|
||
{paperclipRunnerTail ||
|
||
optimisticRunnerStartup ? (
|
||
<TaskChatRunnerTurn
|
||
runId={tailRunId}
|
||
execution={
|
||
liveRun?.id === tailRunId
|
||
? liveRun.execution
|
||
: null
|
||
}
|
||
agentName={visibleTailAgentName}
|
||
agentIcon={visibleTailAgentIcon}
|
||
agent={tailAgent ?? (tailAgentId ? { id: tailAgentId } : undefined)}
|
||
items={tailItems}
|
||
status={
|
||
optimisticRunnerStartup
|
||
? "queued"
|
||
: tailStatus
|
||
}
|
||
startedAtMs={tailStartedAtMs}
|
||
finishedAtMs={tailFinishedAtMs}
|
||
activityUnavailable={tailActivityUnavailable}
|
||
suppressFinal={
|
||
suppressPaperclipRunnerTailFinal
|
||
}
|
||
continuedAfterSteering={
|
||
paperclipRunnerTail &&
|
||
tailTimelineAnchors.length > 0
|
||
}
|
||
onRuntimeRequestDecision={
|
||
handleRuntimeRequestDecision
|
||
}
|
||
/>
|
||
) : (
|
||
<>
|
||
<TaskChatLiveRunPill
|
||
status={tailStatus}
|
||
execution={
|
||
liveRun?.id === tailRunId
|
||
? liveRun.execution
|
||
: null
|
||
}
|
||
startedAtMs={tailStartedAtMs}
|
||
finishedAtMs={tailFinishedAtMs}
|
||
toolSummary={tailToolSummary}
|
||
/>
|
||
<TaskChatLiveTail
|
||
items={tailItems}
|
||
emptyMessage={
|
||
tailStatus === "queued"
|
||
? "Waiting to start..."
|
||
: (liveRun && liveRun.id === tailRunId
|
||
? liveRun.currentStatusMessage
|
||
: null) ||
|
||
(tailStatus === "failed"
|
||
? linkedRunMetaById.get(tailRunId ?? "")?.errorCode?.startsWith("workspace_git_scan_")
|
||
? "Workspace setup failed before the agent started."
|
||
: "This run stopped before a response was available. Review the task’s connection or recovery action below."
|
||
: "Waiting for transcript...")
|
||
}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
{bottomBlockerLinks}
|
||
</>
|
||
) : null
|
||
}
|
||
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
|
||
className={isMobile ? undefined : "pt-3"}
|
||
scroll={!isMobile}
|
||
/>
|
||
)}
|
||
</div>
|
||
</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-1 pb-1 md:px-4 md:pb-2",
|
||
streamlinedUiEnabled && "md:px-0 md:pb-0",
|
||
(!streamlinedUiEnabled || isMobile) &&
|
||
"bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 dark:bg-transparent dark:backdrop-blur-none dark:supports-[backdrop-filter]:bg-transparent",
|
||
)}
|
||
>
|
||
{composerAccessory}
|
||
{tailTurnStatus ? (
|
||
<TaskChatTurnStatusIsland model={tailTurnStatus} />
|
||
) : null}
|
||
<RunnerGoalWidget control={runnerGoal} />
|
||
<div
|
||
className="relative isolate flex flex-col"
|
||
data-testid="task-chat-composer-stack"
|
||
>
|
||
{queuedMessageQueue && !composerPause ? (
|
||
<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.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}
|
||
<div className="relative z-10">
|
||
<TaskChatComposer
|
||
onAdd={handleThreadAdd}
|
||
confirmedSubmissionIds={new Set(comments.filter((comment) =>
|
||
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
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{footer}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</TaskChatPresentationProvider>
|
||
</TaskChatScrollReady.Provider>
|
||
</TaskChatExpansionState.Provider>
|
||
);
|
||
}
|