mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks combine saved messages, documents, run output, monitors, and
decisions.
> - Compact history makes long tasks load quickly.
> - The loading view still showed the real task header before the thread
scrolled to recent messages.
> - Different loading frames also changed the viewport, composer bounds,
and header typography.
> - This pull request uses one loading frame and reveals the prepared
conversation once.
> - The benefit is a stable first view without a material loading-speed
penalty.
## Linked Issues or Issue Description
**What happened?**
A cold task open could show a generic skeleton, then the real title and
monitor above placeholder messages, then jump to recent content. The
loading view changed width on desktop and height on mobile. Its title
line height differed from the real title.
**Expected behavior**
Keep the loading viewport and composer in place. Prepare the initial
reading position out of view. Reveal the recent conversation once its
data, fonts, scroll position, and composer bounds settle. Cap the font
wait at three seconds if the font host does not answer.
**Steps to reproduce**
1. Open a task with a long title, long saved replies, document cards, a
monitor, and a pending decision.
2. Reload it with CPU and network throttling.
3. Watch the header appear above placeholders, then move offscreen when
the recent conversation appears.
**Paperclip version or commit**
e122478368. The synthetic baseline recorded 10 animation frames with a
real header visible before the conversation was ready.
**Deployment mode**
Built from source. Local production previews used the same disposable
PostgreSQL fixture and API.
Related work: #17593, #16878, #18092, and #18461 preserve compact
startup and saved-card behavior. Older open PR #14667 addressed
transcript reveal and reading anchors; this change targets the remaining
loading-frame transition on current master.
## What Changed
- Prepare task data while the personal layout preference loads. Keep the
temporary page hidden until the correct layout is known. This removes a
12 px mobile header change.
- Share one matching loading frame across task metadata and history
preparation. Use the real composer surface and dock geometry for its
placeholder.
- Keep the real title and monitor concealed until the viewport, scroll
position, composer, and fonts settle. Bound a stalled font request to
three seconds.
- Reserve the final chat viewport and breadcrumb identifier position.
Mount task properties after the personal preference resolves.
- Match the loading title typography to the selected task interface,
including Classic.
- Add normal-CI browser coverage for rich tasks, Classic startup and
typography, mobile layout selection, and stalled fonts. Preserve the
outage recovery assertions.
- Add Design Guide examples and an optional retained fixture with 200
long messages, documents, a PR card, a monitor, and a pending
confirmation. Disable both interval and on-demand agent wakes in this
fixture.
## Verification
- Final UI source: `8398af3493`. The later commits change tests or merge
master. The recorded production build is `99867e2dc3`; its UI source is
identical to the final PR head.
- All 10,263 UI tests pass: `pnpm --filter @paperclipai/ui exec vitest
run`. Workspace typecheck and the full production build pass. Token
gates, module boundaries, map validation, and diff checks pass.
- Task browser checks pass 40 cases. Shared chat passes 22 cases. The
normal-CI loading and outage recovery suite passes all 13 cases on the
final head.
- The eight rich loading cases cover direct and list entry, delayed
fonts, and delayed personal preferences on desktop and mobile. At 4× CPU
slowdown and 80 ms latency, each records zero real-header frames before
reveal. Loading-frame, composer, and initial reading-position movement
are 0 px. Title typography matches.
- The normal browser suite also verifies that unresolved mobile layouts
stay hidden, the selected mobile viewport stays fixed, and a seeded
Classic loading title matches the resolved title after an uncached
reload.
- Cold speed: five fresh contexts per variant and viewport, alternating
before/after order. Same local API and synthetic fixture; 4× CPU
slowdown, 80 ms latency, 1.18 MB/s download. No interception,
recordings, builds, or test suites during measurement. Desktop median:
3578 ms before, 3572 ms after. Mobile median: 3500 ms before, 3476 ms
after. Sample ranges overlap.
- The unmodified baseline records 10 real-header frames before
readiness. The final eight-case matrix records zero. Local recordings
and failed prototype evidence are retained outside the repository. No
private staging content is published in this PR.
- CI initially counted hidden zero-size elements as visible frames. Its
saved data confirms the `(0, 0, 0, 0)` entries. Both browser samplers
now ignore those entries and retain all visible geometry checks. The
corrected local suite passes.
- The default `pnpm test:run` command stopped in the unchanged server
chat integration shard with `No test found ... in lines ...` and 1817
skipped tests. This is not a local full-suite pass. One unchanged
AgentActionButtons test also failed during a concurrent run; all 37
cases pass in isolation, and the full 10,263-test UI rerun passes
without source changes to that component.
- Final-head [CI
passes](https://github.com/paperclipai/paperclip/actions/runs/41320240728)
at `d1ef4a955f`. No checks are failed or pending. Greptile and Ponytail
Paperclip Review both report 5/5 for that exact head. All review threads
are resolved.
- The fix is not deployed to staging.
## Risks
- First reveal waits for stable geometry and fonts, with a three-second
font cap. A font that arrives after this degraded-mode cap can still
change text metrics. The existing history deadline still exposes usable
content if another dependency stalls.
- The loading frame cannot predict arbitrary conversation content. The
real content remains concealed until its initial position is ready.
- The shared thread also serves agent chat. Its browser startup and
scroll journeys are included in final verification.
## Model Used
OpenAI GPT-6 (Codex), with reasoning, tool use, code execution, and
browser inspection. The host did not expose a more specific model ID or
the context-window size.
## 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
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [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>
3543 lines
148 KiB
TypeScript
3543 lines
148 KiB
TypeScript
import type { ScrollingHistory } from "@/components/task-chat/TaskMessageScroller";
|
||
import { TaskChatShimmerScope } from "@/components/task-chat/TaskChatShimmer";
|
||
import { useRunnerSubmission } from "@/hooks/useRunnerSubmission";
|
||
import { TaskChatLoadingShell } from "@/components/task-chat/TaskChatLoadingShell";
|
||
import {
|
||
ISSUE_DETAIL_CONTENT_PAINT_MARK,
|
||
ISSUE_DETAIL_CONTENT_MEASURE,
|
||
scheduleIssueDetailPaintMeasure,
|
||
recordIssueDetailReadiness,
|
||
} from "@/lib/issue-detail-performance";
|
||
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
|
||
import { workspaceRestoreMarkerDetail } from "@/lib/workspace-restore-marker";
|
||
import { credentialAccessNotice } from "@/lib/credential-access-notice";
|
||
import type { ActivityEvent, Issue, IssueQueuedCommentQueue, TaskBrowser } from "@paperclipai/shared";
|
||
import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems";
|
||
import { skillCreatedItems } from "@/components/task-chat/skill-created-items";
|
||
import { taskCreatedItems } from "@/components/task-chat/task-created-items";
|
||
import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
|
||
import { CoordinatorChatHandoffStatus, CoordinatorChatTaskHandoff, standaloneChatHandoffs, useCoordinatorChatTaskHandoffs, useLiveChatHandoffIds } from "@/components/coordinator/CoordinatorChatTaskHandoffs";
|
||
import { CoordinatorChatReceiptStatus, useCoordinatorChatDecisionReceipts } from "@/components/coordinator/CoordinatorChatDecisionReceipts";
|
||
import { CoordinatorChatQuestionStatus, useCoordinatorChatPendingQuestions } from "@/components/coordinator/CoordinatorChatPendingQuestions";
|
||
import { requiresExecutionReconciliation } from "@paperclipai/shared";
|
||
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
|
||
import { TaskChatScrollReady, taskChatHashAnchor, useTaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation";
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ComponentProps,
|
||
type ReactNode,
|
||
} 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 { TaskChatRunnerHeader, TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||
import {
|
||
TaskChatTurnStatusIsland,
|
||
taskChatTurnStatusModel,
|
||
} from "@/components/task-chat/TaskChatTurnStatusIsland";
|
||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||
import {
|
||
taskChatStatusRelevance,
|
||
withoutHistoricalRunStatus,
|
||
} from "@/components/task-chat/status-relevance";
|
||
import {
|
||
assembleThreadItems,
|
||
attachSettledTurns,
|
||
buildTurnSummary,
|
||
coalesceSettledTurns,
|
||
isTerminalRunStatus,
|
||
embedPlanDocumentAtWriteBoundary,
|
||
omitProgressRepeatedByResponseAcrossSegments,
|
||
paperclipRunnerFinalResponse,
|
||
paperclipRunnerAcceptedResponseWake,
|
||
paperclipRunnerTimelineItems,
|
||
prependIssueBrief,
|
||
settledRunChildren,
|
||
splitTranscriptAtAnchors,
|
||
transcriptToTaskChatItems,
|
||
runtimeRequestSegmentContext,
|
||
reconcileSegmentRuntimeRequests,
|
||
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 { TaskChatRequestHistoryContext } from "@/components/task-chat/TaskChatComposerTakeoverContext";
|
||
import {
|
||
interactionThreadAnchorMs,
|
||
isSuppressedThreadInteraction,
|
||
} from "@/components/task-chat/interaction-thread-order";
|
||
import { orderTaskCreationItems, taskCreationSourceRunId } from "@/components/task-chat/task-creation-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 { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
|
||
import {
|
||
RunnerGoalWidget,
|
||
useRunnerGoalControl,
|
||
} from "@/components/task-chat/RunnerGoalWidget";
|
||
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
|
||
import { useSidebar } from "@/context/SidebarContext";
|
||
import { useStreamlinedUiEnabled } from "@/hooks/useStreamlinedUiEnabled";
|
||
import { useDismissedTaskQuestions } from "@/hooks/useDismissedTaskQuestions";
|
||
import { cn } from "@/lib/utils";
|
||
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";
|
||
|
||
const EMPTY_BROWSERS: TaskBrowser[] = [];
|
||
const COMPACT_RUN_LOG_READ_LIMIT_BYTES = 32_000;
|
||
|
||
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 INITIAL_HISTORY_REVEAL_TIMEOUT_MS = 15_000;
|
||
const INITIAL_FONT_REVEAL_TIMEOUT_MS = 3000;
|
||
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[];
|
||
createdTasks?: Issue[];
|
||
compactHistory?: boolean;
|
||
scrollingHistory?: ScrollingHistory;
|
||
historyControl?: ReactNode;
|
||
onExpandHistory?: () => void;
|
||
initialHistoryPending?: boolean;
|
||
initialHistoryError?: boolean;
|
||
onRetryInitialHistory?: () => void;
|
||
onOpenSkill?: (skillId: string, name: string) => void;
|
||
onDismissInteraction?: (interaction: IssueThreadInteraction) => Promise<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 coordinatorEnabled = useCoordinatorPresentation();
|
||
const { enabled: streamlinedPreference } = useStreamlinedUiEnabled();
|
||
const streamlinedUiEnabled = coordinatorEnabled || streamlinedPreference;
|
||
const {
|
||
initialHistoryPending = false,
|
||
initialHistoryError = false,
|
||
onRetryInitialHistory,
|
||
comments,
|
||
interactions,
|
||
documents = [],
|
||
workProducts = [],
|
||
attachments = [],
|
||
timelineEvents,
|
||
issueId = null,
|
||
agentMap,
|
||
userLabelMap,
|
||
userProfileMap,
|
||
currentUserId,
|
||
onAdd,
|
||
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,
|
||
suggestedAssigneeValue,
|
||
assigneeAdapterOverrides,
|
||
issueStatus,
|
||
issueAssigneeAgentId = null,
|
||
onAcceptInteraction,
|
||
onRejectInteraction,
|
||
onSubmitInteractionAnswers,
|
||
onCancelInteraction,
|
||
onSkipInteraction,
|
||
onSubmitInteractionVerdicts,
|
||
externalReferences,
|
||
threadHeader,
|
||
browsers = EMPTY_BROWSERS,
|
||
onOpenBrowser,
|
||
hasOlderComments = false,
|
||
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",
|
||
"waiting_for_provider",
|
||
"retry_scheduled",
|
||
"reconnecting",
|
||
"finishing",
|
||
"queued",
|
||
"recovery_needed",
|
||
].includes(run.execution?.phase ?? "");
|
||
})
|
||
? undefined
|
||
: onRetryFailedRun;
|
||
const canRetryFailedRun = Boolean(retryFailedRunHandler);
|
||
const queryClient = useQueryClient();
|
||
const decisionReceipts = useCoordinatorChatDecisionReceipts(issueId, conversationMode);
|
||
const pendingQuestions = useCoordinatorChatPendingQuestions(issueId, conversationMode);
|
||
const taskHandoffs = useCoordinatorChatTaskHandoffs(issueId, conversationMode === true);
|
||
const scrollingHistory: ScrollingHistory | undefined = props.scrollingHistory ? {
|
||
hasMore: props.scrollingHistory.hasMore || !!decisionReceipts.hasNextPage || !!taskHandoffs.hasNextPage,
|
||
loading: props.scrollingHistory.loading || decisionReceipts.isFetchingNextPage || taskHandoffs.isFetchingNextPage,
|
||
error: props.scrollingHistory.error,
|
||
load: () => Promise.all([
|
||
props.scrollingHistory?.hasMore ? props.scrollingHistory.load() : undefined,
|
||
!decisionReceipts.isError && decisionReceipts.hasNextPage ? decisionReceipts.fetchNextPage() : undefined,
|
||
!taskHandoffs.isError && taskHandoffs.hasNextPage ? taskHandoffs.fetchNextPage() : undefined,
|
||
]),
|
||
} : undefined;
|
||
const conversationWindowStart = props.scrollingHistory?.hasMore && comments.length
|
||
? Math.min(...comments.map(comment => toMs(comment.createdAt))) : -Infinity;
|
||
const liveHandoffIds = useLiveChatHandoffIds(`${currentUserId}:${issueId}`, taskHandoffs.handoffs, taskHandoffs.isSuccess);
|
||
const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId);
|
||
const createdSkillItems = useMemo(() => skillCreatedItems(props.creationActivity ?? []), [props.creationActivity]);
|
||
const createdTaskItems = useMemo(() => {
|
||
const representedTaskIds = new Set(taskHandoffs.handoffs.map((handoff) => handoff.issueId));
|
||
for (const interaction of interactions ?? []) {
|
||
if (interaction.kind !== "suggest_tasks" || interaction.status !== "accepted" || isSuppressedThreadInteraction(interaction)) continue;
|
||
for (const task of interaction.result?.createdTasks ?? []) representedTaskIds.add(task.issueId);
|
||
}
|
||
return taskCreatedItems(props.createdTasks ?? [], issueId, companyId, representedTaskIds);
|
||
}, [props.createdTasks, issueId, companyId, taskHandoffs.handoffs, interactions]);
|
||
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
|
||
string | null
|
||
>(null);
|
||
const composerSuggestedAssignee = currentUserId && currentAssigneeValue === `user:${currentUserId}`
|
||
? suggestedAssigneeValue
|
||
: undefined;
|
||
const effectiveComposerAssignee = pendingComposerAssignee ?? composerSuggestedAssignee;
|
||
const effectiveGoalAgentId = effectiveComposerAssignee === undefined
|
||
? issueAssigneeAgentId
|
||
: effectiveComposerAssignee.startsWith("agent:")
|
||
? effectiveComposerAssignee.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 emptyQueuedCommentQueue = useMemo<IssueQueuedCommentQueue>(
|
||
() => ({
|
||
issueId: issueId ?? "",
|
||
queueId: null,
|
||
state: null,
|
||
targetRunId: null,
|
||
revision: "empty",
|
||
protocol: "paperclip_runner_v1",
|
||
steeringDisposition: "temporarily_unavailable",
|
||
entries: [],
|
||
}),
|
||
[issueId],
|
||
);
|
||
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 statusRelevance = useMemo(
|
||
() => taskChatStatusRelevance(issueStatus, [
|
||
...(linkedRuns ?? []).map((run) => ({ ...run, id: run.runId })),
|
||
...(liveRuns ?? []),
|
||
...(activeRun ? [activeRun] : []),
|
||
], props.recoveryAction),
|
||
[issueStatus, linkedRuns, liveRuns, activeRun, props.recoveryAction],
|
||
);
|
||
|
||
const verificationCaveatsByRunId = useMemo(() => {
|
||
const map = new Map<
|
||
string,
|
||
ReturnType<typeof acceptedSemanticResultVerificationCaveats>
|
||
>();
|
||
for (const run of linkedRuns ?? []) {
|
||
const caveats = acceptedSemanticResultVerificationCaveats(run.resultJson);
|
||
if (caveats.length > 0) map.set(run.runId, caveats);
|
||
}
|
||
return map;
|
||
}, [linkedRuns]);
|
||
|
||
// Historical placeholder comments remain untouched in storage. Projection
|
||
// prefers the accepted semantic result so runs such as DOT-20 recover their
|
||
// real upstream response without a data migration.
|
||
const projectedComments = useMemo(
|
||
() =>
|
||
comments.flatMap((comment) => {
|
||
if (comment.authorType === "system" && comment.metadata?.recoveryResponsibility === "system") return [];
|
||
if (statusRelevance.isHistoricalNotice(comment)) return [];
|
||
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, statusRelevance],
|
||
);
|
||
|
||
const commentItems = useMemo(
|
||
() =>
|
||
commentsToTaskChatItems(projectedComments, {
|
||
agentMap,
|
||
userLabelMap,
|
||
userProfileMap,
|
||
currentUserId,
|
||
issueAssigneeAgentId,
|
||
verificationCaveatsByRunId,
|
||
}),
|
||
[
|
||
projectedComments,
|
||
agentMap,
|
||
userLabelMap,
|
||
userProfileMap,
|
||
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,
|
||
createdAt: r.createdAt,
|
||
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,
|
||
createdAt: r.createdAt,
|
||
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,
|
||
createdAt: activeRun.createdAt,
|
||
status: activeRun.status,
|
||
adapterType: activeRun.adapterType,
|
||
runtimeMode: activeRun.runtimeMode,
|
||
logBytes: activeRun.logBytes,
|
||
lastOutputBytes: activeRun.lastOutputBytes,
|
||
});
|
||
}
|
||
return [...map.values()];
|
||
}, [linkedRuns, liveRuns, activeRun]);
|
||
|
||
const openedAt = useMemo(() => Date.now(), [issueId]);
|
||
const scrollNavigation = useTaskChatScrollNavigation();
|
||
const requestedTranscripts = useRef({ issueId, ids: new Set<string>() });
|
||
if (requestedTranscripts.current.issueId !== issueId) {
|
||
requestedTranscripts.current = { issueId, ids: new Set<string>() };
|
||
}
|
||
const deferredTranscriptRunIds = useMemo(() => {
|
||
const deferred = new Set<string>();
|
||
if (!props.compactHistory || conversationMode || !props.onExpandHistory || scrollNavigation.hash) return deferred;
|
||
for (const run of runs) {
|
||
if (requestedTranscripts.current.ids.has(run.id) || run.status !== "succeeded" ||
|
||
!run.createdAt || toMs(run.createdAt) > openedAt) continue;
|
||
const meta = linkedRunMetaById.get(run.id);
|
||
if (acceptedSemanticResultDisposition(meta?.resultJson) === "yielded" ||
|
||
interactions?.some(interaction => interaction.sourceRunId === run.id && interaction.status === "pending")) continue;
|
||
const presentationCommentId = presentationDecisionCommentId(meta?.resultJson);
|
||
const progressCommentIds = semanticProgressCommentIds(meta?.resultJson);
|
||
if (projectedComments.some(comment =>
|
||
comment.authorType === "agent" && !comment.deletedAt && comment.body.trim() &&
|
||
comment.body !== LEGACY_WITHHELD_RUN_COMMENT && !progressCommentIds.has(comment.id) &&
|
||
(comment.runId ?? comment.createdByRunId ?? comment.derivedCreatedByRunId) === run.id &&
|
||
(presentationCommentId === undefined || presentationCommentId === comment.id),
|
||
)) deferred.add(run.id);
|
||
}
|
||
return deferred;
|
||
}, [issueId, runs, projectedComments, linkedRunMetaById, interactions, props.compactHistory, conversationMode, props.onExpandHistory, scrollNavigation.hash, openedAt]);
|
||
const transcriptRuns = useMemo(() => runs.filter(run =>
|
||
!deferredTranscriptRunIds.has(run.id) &&
|
||
!(props.compactHistory && !conversationMode && props.onExpandHistory && !scrollNavigation.hash &&
|
||
initialHistoryPending && isTerminalRunStatus(run.status) && !requestedTranscripts.current.ids.has(run.id)),
|
||
), [issueId, runs, deferredTranscriptRunIds, props.compactHistory, conversationMode, props.onExpandHistory, scrollNavigation.hash, initialHistoryPending]);
|
||
useLayoutEffect(() => {
|
||
for (const run of transcriptRuns) requestedTranscripts.current.ids.add(run.id);
|
||
}, [transcriptRuns]);
|
||
const nativeRuns = useMemo(
|
||
() => transcriptRuns.filter((run) => run.runtimeMode === "native").map((run) => ({
|
||
...run,
|
||
conversationOnly: Boolean(props.compactHistory && (!run.createdAt || new Date(run.createdAt).getTime() <= openedAt)),
|
||
})),
|
||
[transcriptRuns, props.compactHistory, openedAt],
|
||
);
|
||
const {
|
||
transcriptByRun: nativeTranscriptByRun,
|
||
errorsByRun: nativeTranscriptErrorsByRun,
|
||
isInitialHydrating: nativeEventsAreInitiallyHydrating,
|
||
hydratedRunIds: hydratedNativeRunIds,
|
||
terminalHydratedRunIds,
|
||
retry: retryNativeEvents,
|
||
} = useNativeRunTranscripts(nativeRuns, { companyId });
|
||
const logRuns = useMemo(() => transcriptRuns.filter((run) =>
|
||
(!nativeRuns.some((nativeRun) => nativeRun.id === run.id && nativeRun.conversationOnly) ||
|
||
nativeTranscriptErrorsByRun.has(run.id) ||
|
||
(hydratedNativeRunIds.has(run.id) && (nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0)) && (
|
||
run.runtimeMode !== "native" ||
|
||
// Active runs still need the websocket log stream. Only settled native
|
||
// history can skip the legacy transport when event history is available.
|
||
run.status === "running" || run.status === "queued" ||
|
||
nativeTranscriptErrorsByRun.has(run.id) ||
|
||
(hydratedNativeRunIds?.has(run.id) &&
|
||
(nativeTranscriptByRun.get(run.id)?.length ?? 0) === 0)
|
||
)), [transcriptRuns, nativeRuns, nativeTranscriptErrorsByRun, hydratedNativeRunIds, nativeTranscriptByRun]);
|
||
const {
|
||
transcriptByRun: logTranscriptByRun,
|
||
isInitialHydrating: logsAreInitiallyHydrating,
|
||
hydratedRunIds: hydratedLogRunIds,
|
||
truncatedRunIds,
|
||
errorsByRun: logErrorsByRun,
|
||
retry: retryLogs,
|
||
} = useLiveRunTranscripts({ runs: logRuns, companyId, ...(props.compactHistory ? { logReadLimitBytes: COMPACT_RUN_LOG_READ_LIMIT_BYTES } : {}) });
|
||
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 projectedComments) {
|
||
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 projectedComments) {
|
||
const runId = comment.steeredIntoRunId;
|
||
if (!comment.deletedAt && runId && comment.conversationAnchorAt) {
|
||
map.delete(runId);
|
||
}
|
||
}
|
||
return map;
|
||
}, [projectedComments]);
|
||
|
||
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 planDocumentOwnerRunId = useMemo(() => {
|
||
// Coordinator keeps saved plans in document chronology. A run may write
|
||
// several documents (or use shell tools with no semantic write boundary);
|
||
// attaching the plan to its last write can put it after the resulting work.
|
||
if (!planDocument || coordinatorEnabled) 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, coordinatorEnabled, interactions, linkedRuns, liveRuns, planDocument]);
|
||
|
||
const planDocumentSourceRunId = planDocumentOwnerRunId &&
|
||
!deferredTranscriptRunIds.has(planDocumentOwnerRunId) &&
|
||
((transcriptByRun.get(planDocumentOwnerRunId)?.length ?? 0) > 0 ||
|
||
!runs.some(run => run.id === planDocumentOwnerRunId && isTerminalRunStatus(run.status)))
|
||
? planDocumentOwnerRunId : null;
|
||
|
||
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 });
|
||
}
|
||
for (const item of createdTaskItems) {
|
||
entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 3 });
|
||
}
|
||
// Older sessions arrive with their surrounding messages when history is
|
||
// paged in; otherwise they would collect above the loaded conversation.
|
||
const historyStart = hasOlderComments
|
||
? Math.min(...visibleComments.map((comment) => toMs(comment.conversationAnchorAt ?? comment.createdAt)))
|
||
: -Infinity;
|
||
for (const [index, browser] of browsers.entries()) {
|
||
if (toMs(browser.createdAt) < historyStart) continue;
|
||
const id = `browser:${browser.sessionId}`;
|
||
entries.push({
|
||
id,
|
||
ms: toMs(browser.createdAt),
|
||
order: 2,
|
||
item: {
|
||
id,
|
||
kind: "browser",
|
||
browser,
|
||
label: browsers.length > 1 ? `Browser ${index + 1}` : "Browser",
|
||
timestamp: browser.createdAt,
|
||
},
|
||
});
|
||
}
|
||
return entries.sort(
|
||
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
|
||
);
|
||
}, [
|
||
createdProjectItems,
|
||
createdSkillItems,
|
||
createdTaskItems,
|
||
browsers,
|
||
hasOlderComments,
|
||
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 sourceRunByItemId = new Map<string, string>();
|
||
const commentsById = new Map(projectedComments.map(comment => [comment.id, comment]));
|
||
for (const { item } of orderedEntries) {
|
||
if (item.kind !== "message" || item.author !== "agent") continue;
|
||
const comment = commentsById.get(item.id);
|
||
const runId = comment?.createdByRunId ?? comment?.runId ?? comment?.derivedCreatedByRunId;
|
||
if (runId) sourceRunByItemId.set(item.id, runId);
|
||
}
|
||
const entriesWithFailures = [...orderedEntries];
|
||
for (const question of pendingQuestions.questions) {
|
||
const id = `pending-question:${question.subject.id}`;
|
||
entriesWithFailures.push({ id, ms: toMs(question.createdAt), order: 4, item: { id, kind: "pending_question", question } });
|
||
}
|
||
for (const receipt of decisionReceipts.receipts) {
|
||
if (toMs(receipt.resolvedAt) < conversationWindowStart) continue;
|
||
entriesWithFailures.push({ id: `decision-receipt:${receipt.id}`, ms: toMs(receipt.resolvedAt), order: 4,
|
||
item: { id: `decision-receipt:${receipt.id}`, kind: "decision_receipt", receipt } });
|
||
}
|
||
for (const handoff of standaloneChatHandoffs(taskHandoffs.handoffs, interactions ?? [])) {
|
||
if (toMs(handoff.createdAt) < conversationWindowStart) continue;
|
||
entriesWithFailures.push({ id: handoff.id, ms: toMs(handoff.createdAt), order: 4,
|
||
item: { id: handoff.id, kind: "task_handoff", handoff, animateEntry: liveHandoffIds.has(handoff.id) } });
|
||
}
|
||
// 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;
|
||
sourceRunId: string;
|
||
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);
|
||
const credentialAccess = credentialAccessNotice(meta ? {
|
||
...meta,
|
||
agentName: meta.agentName || agentMap?.get(meta.agentId)?.name,
|
||
} : undefined, currentUserId, userLabelMap);
|
||
const historical = statusRelevance.isHistoricalRun(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) {
|
||
settledRunIds.add(source.id);
|
||
if (historical || props.interruption) continue;
|
||
const wait = meta?.resultJson?.executionWait;
|
||
const waitKey = wait && typeof wait === "object" && "recoveryActionId" in wait
|
||
? String(wait.recoveryActionId)
|
||
: "execution_reconciliation_required";
|
||
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 sourceAllowSummaryFallback = !sourceIsPaperclipRunner ||
|
||
terminalHydratedRunIds.has(source.id) ||
|
||
entries.some((entry) => entry.kind === "run_terminal");
|
||
const decidedCommentId = presentationDecisionCommentId(meta?.resultJson);
|
||
const progressCommentIds = semanticProgressCommentIds(meta?.resultJson);
|
||
const sourcePresentationCommentId =
|
||
decidedCommentId === undefined
|
||
? ([...projectedComments]
|
||
.reverse()
|
||
.find(
|
||
(comment) =>
|
||
!comment.deletedAt &&
|
||
comment.runId === source.id &&
|
||
!progressCommentIds.has(comment.id),
|
||
)?.id ?? null)
|
||
: decidedCommentId !== null &&
|
||
projectedComments.some(
|
||
(comment) =>
|
||
!comment.deletedAt &&
|
||
comment.runId === source.id &&
|
||
comment.id === decidedCommentId,
|
||
)
|
||
? decidedCommentId
|
||
: null;
|
||
const sourceHasPresentationComment = sourcePresentationCommentId !== null;
|
||
const sourcePresentationText = sourcePresentationCommentId
|
||
? (projectedComments.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_runtime_missing" && source.status === "failed"
|
||
? "Runtime missing"
|
||
: code === "native_provider_model_rejected" && source.status === "failed"
|
||
? "Model unavailable"
|
||
: 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"
|
||
: "";
|
||
const detail =
|
||
source.status === "cancelled"
|
||
? `The run was cancelled${responseBoundary}.`
|
||
: source.status === "interrupted"
|
||
? `The run was interrupted${responseBoundary}.`
|
||
: code === "native_provider_runtime_missing"
|
||
? "The selected provider runtime is missing a required package. Reinstall or rebuild Paperclip with its bundled provider dependencies, restart the server, then test the agent's runtime before retrying."
|
||
: 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"
|
||
? `${meta?.error || "The provider rejected the selected model."} Choose a supported model or clear the task's model override, then retry.`
|
||
: 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;
|
||
if (!historical && (!props.interruption || credentialAccess ||
|
||
(props.interruption.responsibility === "user" &&
|
||
["native_provider_model_rejected", "native_provider_approval_required"].includes(code)))) entriesWithFailures.push({
|
||
ms: toMs(finishedAt),
|
||
order: 3,
|
||
id,
|
||
item: {
|
||
id,
|
||
kind: "marker",
|
||
variant: "interrupted",
|
||
tone: source.status === "cancelled" ? "neutral" : "error",
|
||
label,
|
||
detail,
|
||
retryable: code !== "native_session_cleanup_quarantined",
|
||
...(credentialAccess ? { credentialAccess, retryable: false } : {}),
|
||
collapsible: true,
|
||
runId: source.id,
|
||
createdAtIso: finishedAt
|
||
? new Date(finishedAt).toISOString()
|
||
: undefined,
|
||
runHref: runAgent
|
||
? `/agents/${encodeURIComponent(runAgent.urlKey)}/runs/${encodeURIComponent(source.id)}`
|
||
: credentialAccess && meta?.agentId
|
||
? `/agents/${encodeURIComponent(meta.agentId)}/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 restoreFailed = hasWorkspaceRestoreFailure(meta?.resultJson);
|
||
const branchUnavailable = (meta?.resultJson?.configurationIncomplete as Record<string, unknown> | undefined)?.reason === "workspace_base_ref_unresolved";
|
||
const savedPlan = Boolean(planDocument && (meta?.resultJson?.savedPlanRevisionId === planDocument.latestRevisionId || interactions?.some((interaction) =>
|
||
interaction.sourceRunId === source.id && interactionTargetsPlanRevision(interaction, planDocument),
|
||
)));
|
||
const aiRequest = interactions?.find((interaction) => interaction.kind === "connection_intent" && interaction.payload.purpose === "ai" && interaction.sourceRunId === source.id);
|
||
const detail = branchUnavailable
|
||
? "Repair the starting branch below before retrying."
|
||
: restoreFailed
|
||
? workspaceRestoreMarkerDetail({ result: meta?.resultJson, savedPlan, hasResponse: sourceHasPresentationComment || Boolean(acceptedSummary) })
|
||
: aiRequest
|
||
? aiRequest.status === "pending"
|
||
? "Use the connection card below to continue."
|
||
: "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."
|
||
: 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 run failed (${code}). ${retryDetail}`;
|
||
const id = `${source.id}:failure`;
|
||
if (!historical && (!props.interruption || credentialAccess)) entriesWithFailures.push({
|
||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||
order: 3,
|
||
id,
|
||
item: {
|
||
id,
|
||
kind: "marker",
|
||
variant: "interrupted",
|
||
label: branchUnavailable ? "Starting branch unavailable" : restoreFailed ? "Workspace restore failed" : aiRequest?.status === "pending" ? "AI connection needed" : source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
|
||
runId: source.status === "cancelled" ? undefined : source.id,
|
||
...(branchUnavailable || aiRequest?.status === "pending" ? { retryable: false } : {}),
|
||
...(restoreFailed ? {
|
||
retryable: meta?.resultJson?.workspaceRestoreFailure !== "restore_unsafe_archive"
|
||
&& meta?.resultJson?.workspaceRestoreFailure !== "restore_invalid_metadata",
|
||
collapsible: true,
|
||
runHref: meta?.agentId ? `/agents/${encodeURIComponent(agentMap?.get(meta.agentId)?.urlKey ?? meta.agentId)}/runs/${encodeURIComponent(source.id)}` : undefined,
|
||
planHref: savedPlan ? "#document-plan" : undefined,
|
||
} : {}),
|
||
...(credentialAccess ? {
|
||
credentialAccess,
|
||
retryable: false,
|
||
runHref: `/agents/${encodeURIComponent(meta?.agentId ?? "")}/runs/${encodeURIComponent(source.id)}`,
|
||
} : {}),
|
||
tone: source.status === "cancelled" || aiRequest?.status === "pending" ? "neutral" : "error",
|
||
detail,
|
||
},
|
||
});
|
||
}
|
||
if (entries.length === 0) {
|
||
if (sourceIsPaperclipRunner && sourceYielded) {
|
||
settledRunIds.add(source.id);
|
||
continue;
|
||
}
|
||
if (sourceIsPaperclipRunner && !sourceAllowSummaryFallback) continue;
|
||
if (
|
||
isNativePaperclipRunnerRun(source) &&
|
||
sourceAllowSummaryFallback &&
|
||
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({
|
||
sourceRunId: source.id,
|
||
turn: {
|
||
id: turnId,
|
||
kind: "turn",
|
||
settled: true,
|
||
historical,
|
||
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`;
|
||
if (!historical && (!props.interruption || credentialAccess)) 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" &&
|
||
sourceAllowSummaryFallback &&
|
||
!sourceHasPresentationComment &&
|
||
resolvedWithoutUserFacingResponse(meta?.resultJson) &&
|
||
!sourceHasNativeResponse
|
||
) {
|
||
const id = `${source.id}:terminal-notice`;
|
||
if (!historical && (!props.interruption || credentialAccess)) 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 runtimeRequests = runtimeRequestSegmentContext(entries, {
|
||
runId: source.id, agentName: meta?.agentName, running: false,
|
||
});
|
||
const projectedSegments = segments.map((segment) => {
|
||
const parsedTranscript = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(segment.entries, {
|
||
runId: source.id,
|
||
agentName: meta?.agentName,
|
||
running: false,
|
||
}), segment.entries, runtimeRequests);
|
||
const parsed =
|
||
source.id === planDocumentSourceRunId && planTurnItem
|
||
? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem)
|
||
: parsedTranscript;
|
||
const visibleActivity = historical ? withoutHistoricalRunStatus(parsed) : parsed;
|
||
return {
|
||
segment,
|
||
parsed,
|
||
timelineItems: sourceIsPaperclipRunner
|
||
? paperclipRunnerTimelineItems(visibleActivity)
|
||
: visibleActivity,
|
||
};
|
||
});
|
||
const sourceResponseText =
|
||
sourceIsPaperclipRunner && !sourceYielded
|
||
? (sourcePresentationText ??
|
||
paperclipRunnerFinalResponse(parsedSource, {
|
||
runId: source.id,
|
||
agentName: meta?.agentName,
|
||
fallbackSummary: acceptedSummary,
|
||
allowFallback: sourceAllowSummaryFallback,
|
||
})?.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,
|
||
allowFallback: sourceAllowSummaryFallback,
|
||
})
|
||
: 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({
|
||
sourceRunId: source.id,
|
||
turn: {
|
||
id: turnId,
|
||
kind: "turn",
|
||
settled: true,
|
||
historical,
|
||
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,
|
||
);
|
||
const runtimeRequests = runtimeRequestSegmentContext(entries, {
|
||
runId: liveRun.id, agentName: liveRun.agentName, running: true,
|
||
});
|
||
for (const [segmentIndex, segment] of segments.slice(0, -1).entries()) {
|
||
if (segment.entries.length === 0) continue;
|
||
const parsedTranscript = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(segment.entries, {
|
||
runId: liveRun.id,
|
||
agentName: liveRun.agentName,
|
||
running: false,
|
||
}), segment.entries, runtimeRequests);
|
||
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({
|
||
sourceRunId: liveRun.id,
|
||
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, sourceRunId, anchorCommentId, startMs } of settledTurns) {
|
||
sourceRunByItemId.set(turn.id, sourceRunId);
|
||
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(
|
||
coordinatorEnabled ? orderTaskCreationItems(out, sourceRunByItemId) : out,
|
||
turnMergeMetaById,
|
||
),
|
||
turnMergeMetaById,
|
||
),
|
||
hasBrief,
|
||
),
|
||
settledRunIds,
|
||
settledReplyRunIds,
|
||
};
|
||
}, [
|
||
orderedEntries,
|
||
coordinatorEnabled,
|
||
conversationWindowStart,
|
||
decisionReceipts.receipts,
|
||
pendingQuestions.questions,
|
||
taskHandoffs.handoffs,
|
||
liveHandoffIds,
|
||
props.interruption,
|
||
statusRelevance,
|
||
canRetryFailedRun,
|
||
interactions,
|
||
runs,
|
||
liveRun,
|
||
transcriptByRun,
|
||
terminalHydratedRunIds,
|
||
linkedRunMetaById,
|
||
lastCommentIdByRun,
|
||
comments,
|
||
projectedComments,
|
||
steeringAnchorsByRun,
|
||
legacyTimelineAnchorsByRun,
|
||
hasBrief,
|
||
planDocument,
|
||
planDocumentSourceRunId,
|
||
planTurnItem,
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
]);
|
||
|
||
// 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;
|
||
// A running (or still settling) turn lives outside the chronological body.
|
||
// Its task cards follow that same tail until the complete turn takes over.
|
||
const [threadItems, tailTaskCreationItems] = useMemo<[TaskChatItem[], TaskChatItem[]]>(() => {
|
||
if (!coordinatorEnabled || !tailRunId) return [items, []];
|
||
const body: TaskChatItem[] = [];
|
||
const tail: TaskChatItem[] = [];
|
||
for (const item of items) {
|
||
(taskCreationSourceRunId(item) === tailRunId ? tail : body).push(item);
|
||
}
|
||
return [body, tail];
|
||
}, [items, coordinatorEnabled, tailRunId]);
|
||
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 = tailAllEntries.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}`
|
||
: "";
|
||
const requestIdentity = entry.kind === "runtime_request" ? JSON.stringify(entry) : "";
|
||
return `${key}|${entry.kind}:${channelIdentity}:${lifecycleIdentity}:${statusIdentity}:${usageIdentity}:${textIdentity}:${contentIdentity}:${requestIdentity}`;
|
||
}, String(tailAllEntries.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 {
|
||
pending: runnerSubmissionPending,
|
||
agentId: submissionAgentId,
|
||
takingLonger: submissionTakingLonger,
|
||
begin: beginSubmission,
|
||
reject: rejectSubmission,
|
||
blocked: startupBlocked,
|
||
} = useRunnerSubmission({
|
||
issueId: issueId ?? null,
|
||
agentId: issueAssigneeAgentId,
|
||
agent: agentMap?.get(issueAssigneeAgentId ?? ""),
|
||
blocked: Boolean(composerPause),
|
||
comments,
|
||
issueStatus,
|
||
runId: liveRun?.id ?? null,
|
||
linkedRuns,
|
||
interruption: props.interruption,
|
||
onRefresh: props.onRefreshLatestComments,
|
||
});
|
||
const tailAgentName =
|
||
liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName;
|
||
const tailAgentId =
|
||
liveRun?.agentId ??
|
||
linkedRunMetaById.get(tailRunId ?? "")?.agentId ??
|
||
submissionAgentId ?? 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 runtimeRequests = runtimeRequestSegmentContext(tailAllEntries, {
|
||
runId: tailRunId, agentName: tailAgentName, running: tailStreaming,
|
||
});
|
||
const parsed = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(tailEntries, {
|
||
runId: tailRunId,
|
||
agentName: tailAgentName,
|
||
running: tailStreaming,
|
||
}), tailEntries, runtimeRequests, 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" &&
|
||
!(coordinatorEnabled && interaction.kind === "suggest_tasks") &&
|
||
!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, coordinatorEnabled]);
|
||
const { dismissedQuestionIds: dismissedInputIds, dismissQuestion: dismissInput } = useDismissedTaskQuestions(issueId, currentUserId);
|
||
const isInputDismissed = useCallback((input: PendingComposerInput) =>
|
||
input.kind === "durable"
|
||
? Boolean(input.interaction.dismissedAt) || dismissedInputIds.has(input.interaction.id) || dismissedInputIds.has(input.key)
|
||
: dismissedInputIds.has(input.key), [dismissedInputIds]);
|
||
const pendingReminderInputs = useMemo(() => pendingComposerInputs.filter(input =>
|
||
!isInputDismissed(input) && !(input.kind === "durable" && input.interaction.kind === "ask_user_questions")),
|
||
[pendingComposerInputs, isInputDismissed]);
|
||
const [dismissError, setDismissError] = useState<string | null>(null);
|
||
const latestUserComment = useMemo(() => comments
|
||
.filter(comment => !comment.deletedAt && comment.authorUserId && !comment.authorAgentId && !comment.createdByRunId)
|
||
.reduce<(typeof comments)[number] | null>((latest, comment) =>
|
||
!latest || toMs(comment.createdAt) >= toMs(latest.createdAt) ? comment : latest, null), [comments]);
|
||
const latestUserCommentId = latestUserComment?.id ?? null;
|
||
// A later message leaves a question answerable in history, without reopening
|
||
// its form on every render or reload. Explicit selection can still reopen it.
|
||
const currentPendingInputs = useMemo(() => pendingComposerInputs.filter(input =>
|
||
!isInputDismissed(input) && !(input.kind === "durable" && input.interaction.kind === "ask_user_questions"
|
||
&& latestUserComment && toMs(input.interaction.createdAt) < toMs(latestUserComment.createdAt))),
|
||
[pendingComposerInputs, isInputDismissed, latestUserComment]);
|
||
const currentPendingKeys = useMemo(() => new Set(currentPendingInputs.map(input => input.key)), [currentPendingInputs]);
|
||
const [takeoverMode, setTakeoverMode] = useState<"open" | "normal">("open");
|
||
const [selectedPendingKey, setSelectedPendingKey] = useState<string | null>(
|
||
null,
|
||
);
|
||
const explicitlyOpenedInputRef = useRef<string | null>(null);
|
||
const knownPendingKeysRef = useRef<Set<string>>(new Set());
|
||
const lastInputUserCommentRef = useRef(latestUserCommentId);
|
||
useEffect(() => {
|
||
const currentKeys = new Set(
|
||
pendingComposerInputs.map((input) => input.key),
|
||
);
|
||
const hasNewInput = pendingComposerInputs.some(
|
||
(input) => currentPendingKeys.has(input.key) && !knownPendingKeysRef.current.has(input.key),
|
||
);
|
||
const movedOn = lastInputUserCommentRef.current !== latestUserCommentId;
|
||
lastInputUserCommentRef.current = latestUserCommentId;
|
||
knownPendingKeysRef.current = currentKeys;
|
||
setSelectedPendingKey((current) => {
|
||
const currentInput = pendingComposerInputs.find(input => input.key === current);
|
||
const questionMovedOn = movedOn && currentInput?.kind === "durable"
|
||
&& currentInput.interaction.kind === "ask_user_questions" && !currentPendingKeys.has(currentInput.key);
|
||
return current && currentKeys.has(current)
|
||
&& (currentPendingKeys.has(current) || explicitlyOpenedInputRef.current === current)
|
||
&& !questionMovedOn
|
||
? current
|
||
: (currentPendingInputs[0]?.key ?? null);
|
||
});
|
||
if (hasNewInput) setTakeoverMode("open");
|
||
}, [pendingComposerInputs, latestUserCommentId, currentPendingInputs, currentPendingKeys]);
|
||
const selectedPendingInput =
|
||
pendingComposerInputs.find((input) => input.key === selectedPendingKey) ??
|
||
currentPendingInputs[0] ??
|
||
null;
|
||
const selectedPendingInputKeyRef = useRef<string | null>(null);
|
||
useLayoutEffect(() => {
|
||
selectedPendingInputKeyRef.current = selectedPendingInput?.key ?? null;
|
||
}, [selectedPendingInput?.key]);
|
||
const interactionDraftKey = selectedPendingInput
|
||
? `paperclip:task-input:${issueId ?? "unknown"}:${selectedPendingInput.key}`
|
||
: undefined;
|
||
const openPendingTakeover = useCallback(() => {
|
||
if (!pendingReminderInputs.some(input => input.key === selectedPendingInput?.key)) {
|
||
setSelectedPendingKey(pendingReminderInputs[0]?.key ?? null);
|
||
}
|
||
setTakeoverMode("open");
|
||
}, [pendingReminderInputs, selectedPendingInput]);
|
||
const showNextPendingInput = useCallback(() => {
|
||
if (pendingReminderInputs.length < 2) return;
|
||
const currentIndex = pendingReminderInputs.findIndex(
|
||
(input) => input.key === selectedPendingInput?.key,
|
||
);
|
||
setSelectedPendingKey(
|
||
pendingReminderInputs[(currentIndex + 1) % pendingReminderInputs.length]
|
||
?.key ?? null,
|
||
);
|
||
}, [pendingReminderInputs, 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 hasAgentAssignee = Boolean(submissionAgentId ?? issueAssigneeAgentId);
|
||
const handleThreadAdd = useCallback(
|
||
async (...args: Parameters<typeof onAdd>) => {
|
||
args[4] ??= crypto.randomUUID();
|
||
const attempt = beginSubmission(args[4], args[2] ? {
|
||
agentId: args[2].assigneeAgentId,
|
||
agent: agentMap?.get(args[2].assigneeAgentId ?? ""),
|
||
} : undefined);
|
||
try {
|
||
await onAdd(...args);
|
||
if (selectedPendingInput?.kind === "durable" && selectedPendingInput.interaction.kind === "ask_user_questions") {
|
||
dismissInput(selectedPendingInput.interaction.id);
|
||
setSelectedPendingKey(null);
|
||
setTakeoverMode("normal");
|
||
}
|
||
} catch (error) {
|
||
rejectSubmission(attempt, error);
|
||
throw error;
|
||
}
|
||
},
|
||
[beginSubmission, rejectSubmission, onAdd, selectedPendingInput, dismissInput, agentMap],
|
||
);
|
||
const optimisticRunnerStartup =
|
||
hasAgentAssignee &&
|
||
!startupBlocked &&
|
||
!tailRunId &&
|
||
(props.retryPreparing || props.interruption?.state === "working" || 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}
|
||
fastResponse={item.origin === "fast_response"}
|
||
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) => {
|
||
const key = pendingComposerInputs.find(input => input.kind === "durable" && input.interaction.id === interactionId)?.key ?? `interaction:${interactionId}`;
|
||
explicitlyOpenedInputRef.current = key;
|
||
setSelectedPendingKey(key);
|
||
setTakeoverMode("open");
|
||
}, [pendingComposerInputs]);
|
||
|
||
const renderInteraction = useCallback(
|
||
(item: TaskChatInteractionItem) => (
|
||
<TaskChatInteractionCard
|
||
item={item}
|
||
onReviewRequest={reopenToolReview}
|
||
showUnansweredQuestion
|
||
showPendingRequest
|
||
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 historyControl = props.historyControl ?? (
|
||
props.compactHistory && props.onExpandHistory
|
||
&& (deferredTranscriptRunIds.size > 0 || logRuns.some((run) => truncatedRunIds?.has(run.id)))
|
||
? <Button type="button" variant="ghost" size="sm" onClick={props.onExpandHistory}>More run output</Button>
|
||
: null
|
||
);
|
||
const renderBrief = useCallback(
|
||
() => issueBrief ? <><TaskChatDescriptionBubble brief={issueBrief} />{historyControl}</> : null,
|
||
[issueBrief, historyControl],
|
||
);
|
||
|
||
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: Math.max(1, pendingReminderInputs.length),
|
||
content: takeoverContent,
|
||
onDismiss: () => {
|
||
void (async () => {
|
||
setDismissError(null);
|
||
try {
|
||
if (selectedPendingInput.kind === "durable") {
|
||
await props.onDismissInteraction?.(selectedPendingInput.interaction);
|
||
dismissInput(selectedPendingInput.interaction.id);
|
||
} else {
|
||
dismissInput(selectedPendingInput.key);
|
||
}
|
||
if (selectedPendingInputKeyRef.current === selectedPendingInput.key) {
|
||
explicitlyOpenedInputRef.current = null;
|
||
setSelectedPendingKey(null);
|
||
setTakeoverMode("normal");
|
||
}
|
||
} catch (error) {
|
||
setDismissError(error instanceof Error ? error.message : "Unable to dismiss this request. Try again.");
|
||
}
|
||
})();
|
||
},
|
||
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 = transcriptRuns.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 transcriptHistoryPending = 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;
|
||
});
|
||
// Hydrating a run inserts activity around its saved reply and can move the
|
||
// latest viewport substantially. Compact opens hydrate only their bounded
|
||
// recent window, but its saved cards and latest turn must arrive together so
|
||
// the first readable frame already has its final structure.
|
||
const chatMetadataPending = Boolean(issueId && conversationMode && (
|
||
(pendingQuestions.enabled && pendingQuestions.isPending) ||
|
||
(decisionReceipts.enabled && decisionReceipts.isPending) ||
|
||
(taskHandoffs.enabled && taskHandoffs.isPending)
|
||
));
|
||
const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending || chatMetadataPending;
|
||
useEffect(() => {
|
||
recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending });
|
||
}, [issueId, planLoading, transcriptHistoryPending, historyPending]);
|
||
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 historyKey = conversationMode && draftKey ? draftKey : issueId;
|
||
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(undefined);
|
||
const historyRevealed = revealedHistoryKey === historyKey;
|
||
const historyShellRef = useRef<HTMLDivElement>(null);
|
||
const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null);
|
||
const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey;
|
||
const scrollTarget = taskChatHashAnchor(scrollNavigation.hash ?? "");
|
||
const scrollCommentId = scrollTarget?.startsWith("comment-") ? scrollTarget.slice("comment-".length) : null;
|
||
const scrollWaitKey = scrollCommentId ? JSON.stringify([issueId, scrollNavigation.key, scrollNavigation.hash]) : "";
|
||
const [expiredScrollWaitKey, setExpiredScrollWaitKey] = useState<string | null>(null);
|
||
const scrollTargetPending = Boolean(scrollCommentId && initialHistoryPending &&
|
||
!comments.some(comment => comment.id === scrollCommentId) && expiredScrollWaitKey !== scrollWaitKey);
|
||
useEffect(() => {
|
||
if (!scrollTargetPending) return;
|
||
const timer = window.setTimeout(() => setExpiredScrollWaitKey(scrollWaitKey), INITIAL_HISTORY_REVEAL_TIMEOUT_MS);
|
||
return () => window.clearTimeout(timer);
|
||
}, [scrollTargetPending, scrollWaitKey]);
|
||
const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired;
|
||
// Supporting requests can stall without rejecting. Bound the first reveal
|
||
// independently of their pending states so saved conversation and the
|
||
// composer stay accessible, with an explicit incomplete-history notice.
|
||
useEffect(() => {
|
||
if (historyRevealed) return;
|
||
const timer = window.setTimeout(() => {
|
||
setExpiredHistoryWait({ historyKey });
|
||
setRevealedHistoryKey(historyKey);
|
||
}, INITIAL_HISTORY_REVEAL_TIMEOUT_MS);
|
||
return () => window.clearTimeout(timer);
|
||
}, [historyRevealed, historyKey]);
|
||
// Mount and measure the real thread while concealed. The properties pane
|
||
// can still be opening when data becomes ready; revealing during its width
|
||
// transition rewraps cards and races initial anchor/focus scrolling.
|
||
// Readiness is latched per issue: refetches never hide existing conversation.
|
||
useEffect(() => {
|
||
if (historyRevealed || historyPending) return;
|
||
const fontWaitStarted = performance.now();
|
||
let previousGeometry: string | undefined;
|
||
let stableFrames = 0;
|
||
let frame: number;
|
||
const revealWhenSettled = () => {
|
||
const shell = historyShellRef.current;
|
||
const bounds = shell?.getBoundingClientRect();
|
||
const scroller = shell?.querySelector('[data-testid="task-chat-scroller"]');
|
||
const composer = shell?.querySelector('[data-testid="task-chat-composer-dock"]');
|
||
const geometry = JSON.stringify([bounds?.x, bounds?.y, bounds?.width, bounds?.height,
|
||
scroller?.clientHeight, scroller?.scrollHeight, scroller?.scrollTop, composer?.getBoundingClientRect().height]);
|
||
const fontsPending = document.fonts?.status === "loading" && performance.now() - fontWaitStarted < INITIAL_FONT_REVEAL_TIMEOUT_MS;
|
||
stableFrames = geometry === previousGeometry && !fontsPending ? stableFrames + 1 : 0;
|
||
previousGeometry = geometry;
|
||
if (stableFrames >= 2) setRevealedHistoryKey(historyKey);
|
||
else frame = requestAnimationFrame(revealWhenSettled);
|
||
};
|
||
frame = requestAnimationFrame(revealWhenSettled);
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [historyPending, historyRevealed, historyKey]);
|
||
useEffect(() => {
|
||
if (!historyRevealed || !issueId) return;
|
||
scheduleIssueDetailPaintMeasure(ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_CONTENT_MEASURE);
|
||
}, [historyRevealed, issueId]);
|
||
const retryHistory = () => {
|
||
onRetryInitialHistory?.();
|
||
retryLogs?.();
|
||
retryNativeEvents?.();
|
||
void retryPlan();
|
||
};
|
||
|
||
return (
|
||
<TaskChatRequestHistoryContext.Provider value={issueId ?? "unknown"}>
|
||
<TaskChatExpansionState.Provider value={expansionState.current}>
|
||
<TaskChatScrollReady.Provider value={(historyRevealed || !historyPending || historyWaitExpired) && !scrollTargetPending}>
|
||
<TaskChatPresentationProvider
|
||
mode={streamlinedUiEnabled ? "streamlined" : "production"}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"relative flex min-h-0 flex-1 flex-col",
|
||
isMobile && !historyRevealed && "task-chat-history-pending",
|
||
)}
|
||
data-testid="task-chat-thread"
|
||
ref={historyShellRef}
|
||
>
|
||
{!historyRevealed ? (
|
||
<div className="absolute inset-0 z-10 overflow-hidden bg-background" data-testid="task-chat-history-loading">
|
||
<TaskChatLoadingShell mobile={isMobile} streamlined={streamlinedUiEnabled} coordinator={coordinatorEnabled} />
|
||
</div>
|
||
) : null}
|
||
<div
|
||
className={cn(
|
||
"relative flex min-h-0 flex-1 flex-col",
|
||
)}
|
||
aria-busy={!historyRevealed}
|
||
>
|
||
{historyError || (historyPending && historyWaitExpired) ? (
|
||
<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"
|
||
>
|
||
{historyError ? "Some task history could not be loaded." : "Some task history is still loading."}
|
||
{historyError ? (
|
||
<Button variant="ghost" size="sm" onClick={retryHistory}>
|
||
Retry
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div
|
||
data-testid="task-chat-history-content"
|
||
className={cn(
|
||
"flex min-h-0 flex-1 flex-col",
|
||
!historyRevealed && "invisible",
|
||
)}
|
||
inert={!historyRevealed}
|
||
>
|
||
{prepareInitialContent ? (
|
||
<>
|
||
{items.length === 0 && !tailRunId && !optimisticRunnerStartup && !scrollingHistory?.hasMore ? (
|
||
<div
|
||
className="min-h-0 flex-1 overflow-y-auto"
|
||
>
|
||
{threadHeaderWithBlockers || pendingQuestions.isError || decisionReceipts.isError || decisionReceipts.hasNextPage || taskHandoffs.isError || taskHandoffs.hasNextPage ? (
|
||
<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}
|
||
<CoordinatorChatQuestionStatus query={pendingQuestions} /><CoordinatorChatReceiptStatus query={decisionReceipts} scroll={!!scrollingHistory} />
|
||
<CoordinatorChatHandoffStatus query={taskHandoffs} scroll={!!scrollingHistory} />
|
||
</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={threadItems}
|
||
scrollingHistory={scrollingHistory}
|
||
attachments={attachments}
|
||
header={<>{threadHeaderWithBlockers}{!issueBrief ? historyControl : null}<CoordinatorChatQuestionStatus query={pendingQuestions} /><CoordinatorChatReceiptStatus query={decisionReceipts} scroll={!!scrollingHistory} /><CoordinatorChatHandoffStatus query={taskHandoffs} scroll={!!scrollingHistory} /></>}
|
||
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}
|
||
onOpenBrowser={onOpenBrowser}
|
||
tail={
|
||
tailRunId ||
|
||
optimisticRunnerStartup ||
|
||
bottomBlockerLinks ? (
|
||
<>
|
||
{tailRunId || optimisticRunnerStartup ? (
|
||
<div data-testid="task-chat-live-transcript">
|
||
<TaskChatShimmerScope items={tailItems}>
|
||
<TaskChatRunnerHeader
|
||
runId={tailRunId}
|
||
agentName={visibleTailAgentName}
|
||
agentIcon={visibleTailAgentIcon}
|
||
agent={tailAgent ?? (tailAgentId ? { id: tailAgentId } : undefined)}
|
||
status={optimisticRunnerStartup ? "queued" : tailStatus}
|
||
execution={liveRun?.id === tailRunId ? liveRun.execution : null}
|
||
preparationLabel={optimisticRunnerStartup && submissionTakingLonger ? "Taking longer than usual…" : undefined}
|
||
startedAtMs={tailStartedAtMs}
|
||
finishedAtMs={tailFinishedAtMs}
|
||
continuedAfterSteering={paperclipRunnerTail && tailTimelineAnchors.length > 0}
|
||
statusContent={tailRunSource?.adapterType !== "paperclip_runner" && !optimisticRunnerStartup ? (
|
||
<TaskChatLiveRunPill status={tailStatus} execution={liveRun?.id === tailRunId ? liveRun.execution : null}
|
||
startedAtMs={tailStartedAtMs} finishedAtMs={tailFinishedAtMs} toolSummary={tailToolSummary} />
|
||
) : undefined}
|
||
/>
|
||
{tailRunSource?.adapterType === "paperclip_runner" ||
|
||
optimisticRunnerStartup ? (
|
||
<TaskChatRunnerTurn
|
||
showHeader={false}
|
||
runId={tailRunId}
|
||
execution={
|
||
liveRun?.id === tailRunId
|
||
? liveRun.execution
|
||
: null
|
||
}
|
||
agentName={visibleTailAgentName}
|
||
agentIcon={visibleTailAgentIcon}
|
||
agent={tailAgent ?? (tailAgentId ? { id: tailAgentId } : undefined)}
|
||
items={tailItems}
|
||
preparationLabel={optimisticRunnerStartup && submissionTakingLonger ? "Taking longer than usual…" : undefined}
|
||
allowSummaryFallback={!paperclipRunnerTail ||
|
||
terminalHydratedRunIds.has(tailRunId ?? "") ||
|
||
tailEntries.some((entry) => entry.kind === "run_terminal")}
|
||
status={
|
||
optimisticRunnerStartup
|
||
? "queued"
|
||
: tailStatus
|
||
}
|
||
startedAtMs={tailStartedAtMs}
|
||
finishedAtMs={tailFinishedAtMs}
|
||
activityUnavailable={tailActivityUnavailable}
|
||
suppressFinal={
|
||
suppressPaperclipRunnerTailFinal
|
||
}
|
||
continuedAfterSteering={
|
||
paperclipRunnerTail &&
|
||
tailTimelineAnchors.length > 0
|
||
}
|
||
onRuntimeRequestDecision={
|
||
handleRuntimeRequestDecision
|
||
}
|
||
/>
|
||
) : (
|
||
<TaskChatShimmerScope items={tailItems}>
|
||
<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...")
|
||
}
|
||
/>
|
||
</TaskChatShimmerScope>
|
||
)}
|
||
</TaskChatShimmerScope>
|
||
</div>
|
||
) : null}
|
||
{tailTaskCreationItems.map(item => (
|
||
<div key={item.id} data-thread-anchor={item.id} data-thread-item-kind={item.kind} className="mt-4">
|
||
{item.kind === "task_handoff"
|
||
? <CoordinatorChatTaskHandoff handoff={item.handoff} animateEntry={item.animateEntry} />
|
||
: item.kind === "interaction" ? renderInteraction(item) : null}
|
||
</div>
|
||
))}
|
||
{bottomBlockerLinks}
|
||
</>
|
||
) : null
|
||
}
|
||
contentKey={`${autoFollowContentKey}:${historyRevealed}`}
|
||
className={isMobile ? undefined : "pt-3"}
|
||
/>
|
||
)}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{assignedAgentForNotice?.status === "paused" && !isTerminalIssueStatus(issueStatus) ? (
|
||
<div className="mx-auto w-full max-w-(--tc-shell-max-w) px-4 pt-2">
|
||
<IssueAssigneePausedNotice
|
||
agent={assignedAgentForNotice}
|
||
issueStatus={issueStatus}
|
||
onResume={onResumeAssignee}
|
||
resuming={resumeAssigneePending}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{showComposer ? (
|
||
<TaskChatComposerDock mobile={isMobile} streamlined={streamlinedUiEnabled} concealed={!historyRevealed}>
|
||
{prepareInitialContent ? (
|
||
<>
|
||
{composerAccessory}
|
||
{dismissError ? <p role="alert" className="text-sm text-destructive">{dismissError}</p> : null}
|
||
{tailTurnStatus ? (
|
||
<TaskChatTurnStatusIsland model={tailTurnStatus} />
|
||
) : null}
|
||
<RunnerGoalWidget control={runnerGoal} />
|
||
<div
|
||
className="relative isolate flex flex-col"
|
||
data-testid="task-chat-composer-stack"
|
||
>
|
||
{!composerPause ? (
|
||
<TaskChatQueuedMessages
|
||
key={issueId}
|
||
queue={queuedMessageQueue ?? emptyQueuedCommentQueue}
|
||
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 () => {
|
||
return 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!))}
|
||
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}
|
||
modelAgents={agentMap}
|
||
userProfileMap={userProfileMap}
|
||
currentAssigneeValue={currentAssigneeValue}
|
||
suggestedAssigneeValue={composerSuggestedAssignee}
|
||
companyId={companyId}
|
||
assigneeAdapterOverrides={assigneeAdapterOverrides}
|
||
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={
|
||
pendingReminderInputs.length > 0
|
||
? {
|
||
count: pendingReminderInputs.length,
|
||
label: `${pendingReminderInputs.length} pending input${pendingReminderInputs.length === 1 ? "" : "s"}`,
|
||
onOpen: openPendingTakeover,
|
||
}
|
||
: null
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{footer}
|
||
</>
|
||
) : null}
|
||
</TaskChatComposerDock>
|
||
) : null}
|
||
</div>
|
||
</TaskChatPresentationProvider>
|
||
</TaskChatScrollReady.Provider>
|
||
</TaskChatExpansionState.Provider>
|
||
</TaskChatRequestHistoryContext.Provider>
|
||
);
|
||
}
|