mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip manages AI agents doing work for people. > - Agent verification must finish before an accepted task can start. > - A missing AI account default can prevent verification before a provider starts. > - Lifecycle policy treated that owned configuration refusal as a temporary failure and retried each minute. > - The accepted turn remained Queued without explaining the prerequisite. > - Park account configuration failures and reuse the task's existing inline connection card. > - Successful repair rechecks verification and releases the original queued turn under its original authority. ## Linked Issues or Issue Description **What happened?** An OpenCode agent selected the responsible user's OpenRouter default, but that default did not exist. Verification retried unchanged configuration indefinitely and its accepted task stayed Queued. **Expected behavior:** Preserve accepted work, explain the setup prerequisite, and let the appropriate person repair it using the existing task connection UI. Completion must verify the agent before starting the same queued turn. Genuine pause, approval and access gates remain effective. **Reproduction:** Create an OpenCode Runner agent with a responsible-user OpenRouter binding and no personal default; assign a task while verification is pending. The base retries verification and shows an unexplained queue. Related: #18702 handles pending task connection recovery after account repair. This PR handles the earlier agent-verification boundary. #20330 addresses a separate Codex governed-wait timeout and is already merged. ## What Changed - Share owned account-selection failure evidence through a pure library contract. Lifecycle policy parks those configuration failures with fixed, redacted guidance; temporary infrastructure errors retain their retries. - Batch setup-state reads for unstarted queued turns. Distinguish manual repair from automatic setup retries and preserve Stop, started/history state and lifecycle holds. - Reuse the existing `connection_intent` card, account selection and completion APIs. Its controller-only `agentSetup` marker accepts only never-started legacy turns; runtime tokens still require a running run. - Address the card to the lifecycle verifier's account owner, preserving the task's execution principal and private-task visibility. Attach missing cards on admission, verification, startup and full recovery, including retained retries and continuations. Repeated delivery and publication remain bounded. A new queued turn supersedes the setup card of a stopped turn; generic connection cards retain cross-run reuse. - Recheck lifecycle verification after usable-account completion, then release the existing turn through the normal readiness path. Preserve decline, Stop, reassignment and changed setup-owner gates. Fence an in-flight verification failure from overwriting a repair; add no provider wake or recurring worker. - Park owned account refusals during resume as well as initial verification. Bind setup cards to the lifecycle operation, settle obsolete deliveries, and keep explicit Retry guidance accurate. - Reuse the router’s existing lifecycle pin for setup repair without rerouting the task, advancing the pool cursor, or replacing its saved binding. Legacy direct-account cards remain readable. - Hide the incorrect Resume banner during setup, retaining genuine manual/budget pause controls. Document the execution and retry contract. ## Verification - Red: eight original behavior assertions failed on the base, including the real selector's retry decision and the real batched queue projection. Six owned selection cases failed classification. Temporary-error presentation, different task/verifier users, retained retry/continuation recovery and the setup Resume banner also have failing-before/passing-after regressions. - Green: 430 affected backend, lifecycle, shared-contract and UI tests passed before the final review corrections. Subsequent focused runs cover one inline card, real account selection/completion, duplicate delivery/publication, verification before readiness, unchanged run authority, private tasks, no extra provider wake, decline/Stop/reassignment, company isolation, invalid runtime tokens, transient retries and redacted diagnostics. - Final setup/card and generic connection-service suites: 49 tests passed. The stopped-turn case failed before the correction, then passed with real account completion, the stopped source remaining cancelled, and the replacement source remaining queued. - After rebase, all 129 focused assertions passed. The local queue suite’s 88 execution assertions passed repeatedly, but its database-fixture teardown exceeded both 30-second and 120-second hooks. An isolated queue case completed, and the setup/connection suites fully passed. The original queue test and cleanup remain unchanged; broad CI supplies the full-suite result. - Server/UI typechecks, module boundaries, token gates and diff checks passed. `pnpm verify --explain` requested broad CI because the execution-contract document changed; no redundant full local suite was substituted. - Real source acceptance: macOS arm64, Node 26.4.0, isolated embedded PostgreSQL and local board identity. Created an OpenCode/OpenRouter agent and task through the API with no default. Admission retained one unstarted turn and produced the existing inline Connect OpenRouter card. The browser opened its credential form and closed it without entering credentials. The same card/run persisted after server restart and reload, with the setup label and no incorrect Resume. The browser also reproduced Stop followed by a new queued turn retaining the stale card on the prior head. On `12f609c98372fd1e50644028db75cfcc8deb210d`, startup superseded the stale card and retained the same replacement turn, with the original turn still cancelled and no provider execution. - The earlier CI case-search failure also reproduces on exact base `0e3fdebea8257bdff11573081c76b306f12db230` without this PR. Its repair is now in master via #20371; this branch is rebased onto it and contains no API-search changes. - Staging's actual Runtime test returned “Connect an account and choose your personal default” before provider startup. The original turn later started independently on deployed code, then hit a separate workspace-export failure. Automatic file recovery subsequently marked that run successful; its task remains Blocked and the trial’s useful output is unverified. This PR is not deployed; provider authentication through its card and downstream publication remain unverified. No account, credential, default, permission or binding was changed during diagnosis. - Previous revision review: Greptile 5/5 on `12f609c98372fd1e50644028db75cfcc8deb210d`, with all four inline findings addressed and resolved. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42713494508) on that revision: 59 successful checks, one neutral check and three intentional skips, with no pending or failed checks. - Latest revision `976bcae993805a8a2222395bbff9394f6791b424`: resume, router and Retry-copy regressions failed before repair. Green: 92 lifecycle/setup/shared tests, 83 setup/generic-connection/router tests, and 137 UI/shared tests. Server/UI typechecks, module boundaries and token gates passed. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42720731137) on this exact revision: 59 successful checks and three intentional skips, with no pending or failed checks. Greptile reviewed this head at 5/5; all six review findings are addressed and resolved. Real Stop/retry acceptance retained the same replacement turn and current-operation card through two actual server restarts, with no provider execution. ## Risks - Known account configuration failures now require repair or explicit Retry. Temporary failures keep automatic retries; accepted work and authority remain intact. - The projection adds one company-scoped batch read only for unstarted queued turns. Card attachment examines at most 100 active candidates and adds no timer, worker, schema or migration. - The optional stored setup-card marker requires an aware reader during rollback. No run/session/checkpoint identity format changes. Setup presentation grants no access and cannot bypass verification or other gates. ## Model Used - OpenAI GPT-6 (Codex), with reasoning, tool use, code editing and test execution. The exact serving model identifier and context-window size are not exposed in this session. ## 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>
6947 lines
236 KiB
TypeScript
6947 lines
236 KiB
TypeScript
import { useCurrentAccountGuard } from "../hooks/useAccountBoundMutation";
|
||
import { resolveIssueChatHumanAuthor } from "@/lib/issue-chat-human-author";
|
||
import { useCurrentWorkspaceRetry, useWorkspaceBaseRefRecovery, useWorkspaceRetry } from "./WorkspaceBaseRefRecovery";
|
||
import { WorkspaceBaseRefRecoveryNotice } from "./WorkspaceBaseRefRecoveryNotice";
|
||
import { DispositionRecoveryNotice, useDispositionRecoverySnapshot } from "./DispositionRecoveryNotice";
|
||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||
import { TaskChatRunnerTurn } from "./task-chat/TaskChatRunnerTurn";
|
||
import { useRunnerSubmission } from "@/hooks/useRunnerSubmission";
|
||
import type { ComposerRunSettings } from "./task-chat/composer-run-settings";
|
||
import { ComposerRunSettingsPicker } from "./task-chat/ComposerRunSettingsPicker";
|
||
import { ComposerAddMenu, ComposerModeChip } from "./task-chat/ComposerAddMenu";
|
||
import { TaskChatPausedTakeover, type TaskComposerPause } from "./task-chat/TaskChatPausedTakeover";
|
||
import { useEmailComment } from "./EmailMessageCard";
|
||
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
||
import type {
|
||
ReasoningMessagePart,
|
||
TextMessagePart,
|
||
ThreadMessage,
|
||
ToolCallMessagePart,
|
||
} from "@assistant-ui/react";
|
||
import {
|
||
createContext,
|
||
Component,
|
||
forwardRef,
|
||
memo,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useId,
|
||
useImperativeHandle,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ChangeEvent,
|
||
type DragEvent as ReactDragEvent,
|
||
type ErrorInfo,
|
||
type KeyboardEvent as ReactKeyboardEvent,
|
||
type Ref,
|
||
type ReactNode,
|
||
} from "react";
|
||
import { Link, useLocation } from "@/lib/router";
|
||
import type {
|
||
Agent,
|
||
FeedbackDataSharingPreference,
|
||
FeedbackVote,
|
||
FeedbackVoteValue,
|
||
IssueAttachment,
|
||
IssueDocumentSummary,
|
||
IssueBlockerAttention,
|
||
IssueBlockerStalledWakeContext,
|
||
IssueRecoveryAction,
|
||
IssueQueuedCommentQueue,
|
||
IssueRelationIssueSummary,
|
||
IssueScheduledRetry,
|
||
SuccessfulRunHandoffState,
|
||
IssueWorkMode,
|
||
IssueWorkProduct,
|
||
IssueAssigneeAdapterOverrides,
|
||
} from "@paperclipai/shared";
|
||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||
import { findUIAdapter } from "../adapters/registry";
|
||
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||
import { useSecondTick } from "../hooks/useSecondTick";
|
||
import {
|
||
usePaperclipIssueRuntime,
|
||
type PaperclipIssueRuntimeReassignment,
|
||
} from "../hooks/usePaperclipIssueRuntime";
|
||
import { useOptionalToastActions } from "../context/ToastContext";
|
||
import { copyTextToClipboard } from "../lib/clipboard";
|
||
import {
|
||
loadDraft,
|
||
loadDraftIfAvailable,
|
||
loadDraftRecoveryKey,
|
||
preserveDraftInTab,
|
||
saveDraft,
|
||
clearDraft,
|
||
loadDraftAttachments,
|
||
saveDraftAttachments,
|
||
loadDraftSubmission,
|
||
replayableRequest,
|
||
saveDraftSubmission,
|
||
clearDraftSubmission,
|
||
settleDraftSubmission,
|
||
type ComposerDraftSubmission,
|
||
type ComposerSendRequest,
|
||
} from "../lib/composer-draft";
|
||
import { CommentSubmissionUnknownError } from "../lib/comment-submit-result";
|
||
import { classifySendFailure, type SendOutcome } from "../lib/pending-send";
|
||
import { useAutoResend } from "../hooks/useDurableSubmit";
|
||
import { useDraftOwner } from "../hooks/useDraftOwner";
|
||
import { useRequestOwnerBinding } from "../hooks/useRequestOwnerBinding";
|
||
import { PendingSendNotice } from "./PendingSendNotice";
|
||
import { describeError } from "../api/errors";
|
||
import {
|
||
buildIssueChatMessages,
|
||
formatDurationWords,
|
||
isCoTSegmentActive,
|
||
stabilizeThreadMessages,
|
||
type IssueChatComment,
|
||
type IssueChatLinkedRun,
|
||
type StableThreadMessageCacheEntry,
|
||
type IssueChatTranscriptEntry,
|
||
type SegmentTiming,
|
||
} from "../lib/issue-chat-messages";
|
||
import type {
|
||
AskUserQuestionsAnswer,
|
||
AskUserQuestionsInteraction,
|
||
IssueThreadInteraction,
|
||
RequestCheckboxConfirmationInteraction,
|
||
RequestConfirmationInteraction,
|
||
RequestItemVerdictsInteraction,
|
||
RequestItemVerdictValue,
|
||
SuggestTasksInteraction,
|
||
} from "../lib/issue-thread-interactions";
|
||
import {
|
||
buildIssueThreadInteractionSummary,
|
||
isIssueThreadInteraction,
|
||
} from "../lib/issue-thread-interactions";
|
||
import { isLiveIssueRun } from "../lib/liveIssueIds";
|
||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||
import {
|
||
formatTimelineWorkspaceLabel,
|
||
type IssueTimelineAssignee,
|
||
type IssueTimelineEvent,
|
||
type IssueTimelineWorkspace,
|
||
type IssueWorkModeChange,
|
||
} from "../lib/issue-timeline-events";
|
||
import { Button } from "@/components/ui/button";
|
||
import { InlineBanner } from "@/components/InlineBanner";
|
||
import { Card } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
MarkdownBody,
|
||
type MarkdownExternalReferenceMap,
|
||
} from "./MarkdownBody";
|
||
import type { TaskChatIssueBrief } from "./task-chat/TaskChatDescriptionBubble";
|
||
import { WorkspaceFileMarkdownBody } from "./WorkspaceFileMarkdownBody";
|
||
import {
|
||
MarkdownEditor,
|
||
type MentionOption,
|
||
type MarkdownEditorRef,
|
||
} from "./MarkdownEditor";
|
||
import { Identity } from "./Identity";
|
||
import {
|
||
InlineEntitySelector,
|
||
type InlineEntityOption,
|
||
} from "./InlineEntitySelector";
|
||
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||
import {
|
||
AssigneeChip,
|
||
ComposerHandoffPreviewRow,
|
||
ComposerMentionCoach,
|
||
HandoffWakeRow,
|
||
RunStatusBadge,
|
||
type HandoffChipResolvers,
|
||
} from "./interrupt-handoff/InterruptHandoffViews";
|
||
import {
|
||
computeComposerHandoffPreview,
|
||
extractAgentMentionIds,
|
||
findPlainAgentNameCandidate,
|
||
type ComposerHandoffPreview,
|
||
type HandoffAgentMention,
|
||
} from "../lib/interrupt-handoff";
|
||
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||
import {
|
||
captureComposerViewportSnapshot,
|
||
restoreComposerViewportSnapshot,
|
||
shouldPreserveComposerViewport,
|
||
} from "../lib/issue-chat-scroll";
|
||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||
import type { CompanyUserProfile } from "../lib/company-members";
|
||
import { timeAgo } from "../lib/timeAgo";
|
||
import {
|
||
isSuccessfulRunHandoffComment,
|
||
isSuccessfulRunHandoffEscalationComment,
|
||
} from "../lib/successful-run-handoff";
|
||
import {
|
||
SystemNotice,
|
||
type SystemNoticeMetadataRow,
|
||
type SystemNoticeMetadataSection,
|
||
type SystemNoticeProps,
|
||
type SystemNoticeTone,
|
||
} from "./SystemNotice";
|
||
import {
|
||
buildSystemNoticeProps,
|
||
mapCommentMetadataToSystemNoticeSections,
|
||
systemNoticeLabelForTone,
|
||
} from "../lib/system-notice-comment";
|
||
import type {
|
||
IssueCommentMetadata,
|
||
IssueCommentPresentation,
|
||
SourceTrustMetadata,
|
||
} from "@paperclipai/shared";
|
||
import {
|
||
describeToolInput,
|
||
displayToolName,
|
||
formatToolPayload,
|
||
isCommandTool,
|
||
parseToolPayload,
|
||
summarizeToolInput,
|
||
summarizeToolResult,
|
||
} from "../lib/transcriptPresentation";
|
||
import { buildAgentMentionHref } from "@paperclipai/shared";
|
||
import { useComposerStop } from "@/hooks/useComposerStop";
|
||
import { useComposerSubmitKey } from "@/hooks/useComposerSubmitKey";
|
||
import { agentUrl, cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||
import { liveBlueBadge } from "../lib/status-colors";
|
||
import {
|
||
nextWorkMode,
|
||
workModeMetaFor,
|
||
} from "../lib/work-mode-meta";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
RotateCw,
|
||
AlertTriangle,
|
||
ArrowRight,
|
||
Brain,
|
||
Check,
|
||
ChevronDown,
|
||
ClipboardList,
|
||
Copy,
|
||
Hammer,
|
||
Loader2,
|
||
MoreHorizontal,
|
||
Paperclip,
|
||
PauseCircle,
|
||
Search,
|
||
Square,
|
||
ThumbsDown,
|
||
ThumbsUp,
|
||
Trash2,
|
||
X,
|
||
} from "lucide-react";
|
||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||
import { StalledDependencyNotice } from "./StalledDependencyNotice";
|
||
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
|
||
import {
|
||
IssueRecoveryActionCard,
|
||
type RecoveryReissueRequest,
|
||
type RecoveryResolveOutcome,
|
||
} from "./IssueRecoveryActionCard";
|
||
import { SourceTrustBadge } from "./SourceTrustBadge";
|
||
import { CommentAttributionChip } from "./CommentAttributionChip";
|
||
import { resolveCommentAttribution } from "../lib/comment-attribution";
|
||
|
||
interface IssueChatMessageContext {
|
||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||
feedbackTermsUrl: string | null;
|
||
agentMap?: Map<string, Agent>;
|
||
currentUserId?: string | null;
|
||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||
onVote?: (
|
||
commentId: string,
|
||
vote: FeedbackVoteValue,
|
||
options?: { allowSharing?: boolean; reason?: string },
|
||
) => Promise<void>;
|
||
onStopRun?: (runId: string) => Promise<void>;
|
||
stopRunLabel?: string;
|
||
stoppingRunLabel?: string;
|
||
stopRunVariant?: "stop" | "pause";
|
||
runFinalizationActions?: readonly IssueChatRunFinalizationAction[];
|
||
onInterruptQueued?: (runId: string | null) => Promise<IssueQueuedCommentQueue | void>;
|
||
onCancelQueued?: (commentId: string) => void;
|
||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||
onImageClick?: (src: string) => void;
|
||
onAcceptInteraction?: (
|
||
interaction:
|
||
| SuggestTasksInteraction
|
||
| RequestConfirmationInteraction
|
||
| RequestCheckboxConfirmationInteraction,
|
||
selectedClientKeys?: string[],
|
||
selectedOptionIds?: string[],
|
||
rememberAction?: boolean,
|
||
) => Promise<void> | void;
|
||
onRejectInteraction?: (
|
||
interaction:
|
||
| SuggestTasksInteraction
|
||
| RequestConfirmationInteraction
|
||
| RequestCheckboxConfirmationInteraction,
|
||
reason?: string,
|
||
) => Promise<void> | void;
|
||
onSubmitInteractionAnswers?: (
|
||
interaction: AskUserQuestionsInteraction,
|
||
answers: AskUserQuestionsAnswer[],
|
||
) => Promise<void> | void;
|
||
onCancelInteraction?: (
|
||
interaction: AskUserQuestionsInteraction,
|
||
) => Promise<void> | void;
|
||
/** New task-view composer takeover action. The classic thread does not render it. */
|
||
onSkipInteraction?: (
|
||
interaction: IssueThreadInteraction,
|
||
) => Promise<void> | void;
|
||
onSubmitInteractionVerdicts?: (
|
||
interaction: RequestItemVerdictsInteraction,
|
||
verdicts: {
|
||
id: string;
|
||
verdict: RequestItemVerdictValue;
|
||
reason?: string;
|
||
}[],
|
||
) => Promise<void> | void;
|
||
onUploadImage?: (file: File) => Promise<string>;
|
||
issueStatus?: string;
|
||
/**
|
||
* Current assignee. Agent comments from anyone else are cross-issue writes, so
|
||
* they carry a "for {user}" attribution chip (the open cross-task write design (attribution)).
|
||
*/
|
||
issueAssigneeAgentId?: string | null;
|
||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||
externalReferences?: MarkdownExternalReferenceMap;
|
||
/** Linkify `PAP-C7` case chips in comment bodies (experimental Cases flag). */
|
||
linkCaseReferences?: boolean;
|
||
}
|
||
|
||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||
feedbackDataSharingPreference: "prompt",
|
||
feedbackTermsUrl: null,
|
||
issueStatus: undefined,
|
||
successfulRunHandoff: null,
|
||
});
|
||
|
||
const AGENT_COMMENT_BUBBLE_WIDTH_CLASS =
|
||
"max-w-(--sz-calc-7) sm:max-w-(--pct-85)";
|
||
|
||
export type IssueChatRunFinalizationAction = {
|
||
id: "cancel" | "done";
|
||
label: string;
|
||
pendingLabel: string;
|
||
onSelect: (runId: string) => Promise<void> | void;
|
||
isPending?: boolean;
|
||
disabled?: boolean;
|
||
};
|
||
|
||
export function resolveAssistantMessageFoldedState(args: {
|
||
messageId: string;
|
||
currentFolded: boolean;
|
||
isFoldable: boolean;
|
||
previousMessageId: string | null;
|
||
previousIsFoldable: boolean;
|
||
}) {
|
||
const {
|
||
messageId,
|
||
currentFolded,
|
||
isFoldable,
|
||
previousMessageId,
|
||
previousIsFoldable,
|
||
} = args;
|
||
|
||
if (messageId !== previousMessageId) return isFoldable;
|
||
if (!isFoldable) return false;
|
||
if (!previousIsFoldable) return true;
|
||
return currentFolded;
|
||
}
|
||
|
||
export function canStopIssueChatRun(args: {
|
||
runId: string | null;
|
||
runStatus: string | null;
|
||
activeRunIds: ReadonlySet<string>;
|
||
}) {
|
||
const { runId, runStatus, activeRunIds } = args;
|
||
if (!runId) return false;
|
||
if (activeRunIds.has(runId)) return true;
|
||
return runStatus === "queued" || runStatus === "running";
|
||
}
|
||
|
||
function findCoTSegmentIndex(
|
||
messageParts: ReadonlyArray<{ type: string }>,
|
||
cotParts: ReadonlyArray<{ type: string }>,
|
||
): number {
|
||
if (cotParts.length === 0) return -1;
|
||
const firstPart = cotParts[0];
|
||
let segIdx = -1;
|
||
let inCoT = false;
|
||
for (const part of messageParts) {
|
||
if (part.type === "reasoning" || part.type === "tool-call") {
|
||
if (!inCoT) {
|
||
segIdx++;
|
||
inCoT = true;
|
||
}
|
||
if (part === firstPart) return segIdx;
|
||
} else {
|
||
inCoT = false;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
function useLiveElapsed(
|
||
startMs: number | null | undefined,
|
||
active: boolean,
|
||
): string | null {
|
||
// Drive the 1s refresh from the shared page-wide ticker instead of a
|
||
// per-instance setInterval, so a thread with many live elements uses one
|
||
// timer rather than one per element.
|
||
useSecondTick(Boolean(active && startMs));
|
||
if (!active || !startMs) return null;
|
||
return formatDurationWords(Date.now() - startMs);
|
||
}
|
||
|
||
function readCustomString(
|
||
custom: Record<string, unknown>,
|
||
key: string,
|
||
): string {
|
||
return typeof custom[key] === "string" ? custom[key].trim() : "";
|
||
}
|
||
|
||
function toTimestampOrNull(value: string): number | null {
|
||
if (!value) return null;
|
||
const timestamp = new Date(value).getTime();
|
||
return Number.isFinite(timestamp) ? timestamp : null;
|
||
}
|
||
|
||
function IssueChatLiveRunStatusLine({
|
||
custom,
|
||
active,
|
||
className,
|
||
}: {
|
||
custom: Record<string, unknown>;
|
||
active: boolean;
|
||
className?: string;
|
||
}) {
|
||
const currentStatusMessage = readCustomString(custom, "currentStatusMessage");
|
||
const currentToolName = readCustomString(custom, "currentToolName");
|
||
const lastAssistantSnippet = readCustomString(custom, "lastAssistantSnippet");
|
||
const lastEventAt = readCustomString(custom, "lastEventAt");
|
||
const lastEventAtMs = toTimestampOrNull(lastEventAt);
|
||
const lastActivityElapsed = useLiveElapsed(lastEventAtMs, active);
|
||
const lastActivityAgeMs = lastEventAtMs ? Date.now() - lastEventAtMs : null;
|
||
|
||
if (!active) return null;
|
||
|
||
const primary = currentToolName
|
||
? `Using ${currentToolName}`
|
||
: lastAssistantSnippet
|
||
? lastAssistantSnippet
|
||
: currentStatusMessage;
|
||
const activityText = lastActivityElapsed
|
||
? lastActivityAgeMs !== null && lastActivityAgeMs >= 15_000
|
||
? `no output for ${lastActivityElapsed} - still running`
|
||
: `${lastActivityElapsed} ago`
|
||
: "";
|
||
const text = [primary, activityText].filter(Boolean).join(" · ");
|
||
if (!text) return null;
|
||
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"mt-0.5 block truncate text-xs leading-4 text-muted-foreground/70",
|
||
className,
|
||
)}
|
||
title={text}
|
||
>
|
||
{text}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function useStableEvent<T extends (...args: never[]) => unknown>(
|
||
callback: T | undefined,
|
||
): T | undefined {
|
||
const callbackRef = useRef(callback);
|
||
useLayoutEffect(() => {
|
||
callbackRef.current = callback;
|
||
}, [callback]);
|
||
|
||
return useMemo(() => {
|
||
if (!callback) return undefined;
|
||
return ((...args: Parameters<T>) => callbackRef.current?.(...args)) as T;
|
||
// Keep the wrapper stable while the callback identity changes; the ref above
|
||
// carries the current callback implementation.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [Boolean(callback)]);
|
||
}
|
||
|
||
interface CommentReassignment {
|
||
assigneeAgentId: string | null;
|
||
assigneeUserId: string | null;
|
||
}
|
||
|
||
export function shouldRenderComposerHandoffPreview(
|
||
body: string,
|
||
preview: ComposerHandoffPreview,
|
||
): boolean {
|
||
return Boolean(body.trim()) && preview.kind !== "none";
|
||
}
|
||
|
||
export interface IssueChatComposerHandle {
|
||
focus: () => void;
|
||
restoreDraft: (submittedBody: string) => void;
|
||
}
|
||
|
||
interface IssueChatComposerProps {
|
||
onSend: IssueChatThreadProps["onAdd"];
|
||
confirmedSubmissionIds: ReadonlySet<string>;
|
||
onStop?: () => Promise<void>;
|
||
stopPending?: boolean;
|
||
stopScope?: "leaf" | "subtree";
|
||
onImageUpload?: (file: File) => Promise<string>;
|
||
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||
draftKey?: string;
|
||
enableReassign?: boolean;
|
||
reassignOptions?: InlineEntityOption[];
|
||
currentAssigneeValue?: string;
|
||
companyId?: string | null;
|
||
assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null;
|
||
suggestedAssigneeValue?: string;
|
||
mentions?: MentionOption[];
|
||
agentMap?: Map<string, Agent>;
|
||
/** Whether an agent run is currently in flight, so the composer can preview an interrupt. */
|
||
hasActiveRun?: boolean;
|
||
currentUserId?: string | null;
|
||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||
composerPause?: TaskComposerPause | null;
|
||
composerDisabledReason?: string | null;
|
||
composerHint?: string | null;
|
||
issueStatus?: string;
|
||
issueWorkMode?: IssueWorkMode;
|
||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
||
}
|
||
|
||
interface IssueChatThreadProps {
|
||
executionBlocked?: boolean;
|
||
interruption?: import("@paperclipai/shared").TaskInterruptionPresentation | null;
|
||
retryPreparing?: boolean;
|
||
/** Browser sessions are placed chronologically by the default task thread. */
|
||
browsers?: import("@paperclipai/shared").TaskBrowser[];
|
||
onOpenBrowser?: (browserId: string) => void;
|
||
hasOlderComments?: boolean;
|
||
comments: IssueChatComment[];
|
||
interactions?: IssueThreadInteraction[];
|
||
/** App-authoritative resources interleaved by the default task thread. */
|
||
documents?: IssueDocumentSummary[];
|
||
workProducts?: IssueWorkProduct[];
|
||
attachments?: IssueAttachment[];
|
||
feedbackVotes?: FeedbackVote[];
|
||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||
feedbackTermsUrl?: string | null;
|
||
linkedRuns?: IssueChatLinkedRun[];
|
||
timelineEvents?: IssueTimelineEvent[];
|
||
/**
|
||
* Work-mode switch history from the activity feed. Only the chat-style
|
||
* TaskChatThread consumes this to tag each agent reply with the mode its
|
||
* request ran under; this thread — the classic task view behind
|
||
* enableClassicTaskInterface — ignores it.
|
||
*/
|
||
workModeChanges?: IssueWorkModeChange[];
|
||
liveRuns?: LiveRunForIssue[];
|
||
activeRun?: ActiveRunForIssue | null;
|
||
issueId?: string | null;
|
||
blockedBy?: IssueRelationIssueSummary[];
|
||
/** Company-wide set of issue ids with a live (queued/running) run. */
|
||
liveIssueIds?: ReadonlySet<string>;
|
||
blockerAttention?: IssueBlockerAttention | null;
|
||
/** Children and blockers stalled on the board (`stalledDependencies` on the issue). */
|
||
stalledDependencies?: IssueBlockerStalledWakeContext[];
|
||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||
scheduledRetry?: IssueScheduledRetry | null;
|
||
recoveryAction?: IssueRecoveryAction | null;
|
||
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
|
||
onReissueIsolatedRecoveryAction?: (request: RecoveryReissueRequest) => void;
|
||
reissueIsolatedRecoveryActionPending?: boolean;
|
||
onReconcileForwardRecoveryAction?: () => void;
|
||
onBreakGlassOverrideRecoveryAction?: (reason: string) => void;
|
||
onQuarantineRestoreRecoveryAction?: () => void;
|
||
quarantineRestoreRecoveryActionPending?: boolean;
|
||
canBreakGlassRecoveryAction?: boolean;
|
||
reconcileRecoveryActionPending?: boolean;
|
||
canFalsePositiveRecoveryAction?: boolean;
|
||
legacyRecoverySourceIssue?: {
|
||
identifier: string | null;
|
||
href: string;
|
||
title?: string | null;
|
||
} | null;
|
||
assigneeUserId?: string | null;
|
||
/** Current assignee agent, used to mark cross-issue agent comments (the open cross-task write design (attribution)). */
|
||
issueAssigneeAgentId?: string | null;
|
||
onResumeFromBacklog?: () => Promise<void> | void;
|
||
resumeFromBacklogPending?: boolean;
|
||
/** Resume a paused assignee agent so runs can start again. */
|
||
onResumeAssignee?: () => Promise<void> | void;
|
||
resumeAssigneePending?: boolean;
|
||
/** Requeues a blocked task after its no-live-execution-path recovery notice. */
|
||
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
|
||
tryAgainNoLiveExecutionPathPending?: boolean;
|
||
/** Starts a fresh on-demand run for the selected failed run. */
|
||
onRetryFailedRun?: (runId: string) => Promise<void> | void;
|
||
retryFailedRunId?: string | null;
|
||
companyId?: string | null;
|
||
projectId?: string | null;
|
||
issueStatus?: string;
|
||
agentMap?: Map<string, Agent>;
|
||
currentUserId?: string | null;
|
||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||
onVote?: (
|
||
commentId: string,
|
||
vote: FeedbackVoteValue,
|
||
options?: { allowSharing?: boolean; reason?: string },
|
||
) => Promise<void>;
|
||
onAdd: (
|
||
body: string,
|
||
reopen?: boolean,
|
||
reassignment?: CommentReassignment,
|
||
attachmentIds?: string[],
|
||
clientRequestId?: string,
|
||
runSettings?: ComposerRunSettings,
|
||
) => Promise<void>;
|
||
onCancelRun?: () => Promise<void>;
|
||
stopPending?: boolean;
|
||
stopScope?: "leaf" | "subtree";
|
||
onStopRun?: (runId: string) => Promise<void>;
|
||
stopRunLabel?: string;
|
||
stoppingRunLabel?: string;
|
||
stopRunVariant?: "stop" | "pause";
|
||
runFinalizationActions?: readonly IssueChatRunFinalizationAction[];
|
||
imageUploadHandler?: (file: File) => Promise<string>;
|
||
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||
draftKey?: string;
|
||
enableReassign?: boolean;
|
||
reassignOptions?: InlineEntityOption[];
|
||
currentAssigneeValue?: string;
|
||
assigneeAdapterOverrides?: IssueAssigneeAdapterOverrides | null;
|
||
suggestedAssigneeValue?: string;
|
||
mentions?: MentionOption[];
|
||
composerPause?: TaskComposerPause | null;
|
||
composerDisabledReason?: string | null;
|
||
composerHint?: string | null;
|
||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
||
showComposer?: boolean;
|
||
showJumpToLatest?: boolean;
|
||
autoScrollToLatestOnInitialLoad?: boolean;
|
||
autoScrollToHashOnInitialLoad?: boolean;
|
||
emptyMessage?: string;
|
||
footer?: ReactNode;
|
||
/**
|
||
* Issue header content (title row, badges, plugin toolbars) rendered INSIDE
|
||
* the thread's scroll viewport so it scrolls away with the messages. Only
|
||
* the chat-style TaskChatThread consumes this; this thread ignores it — its
|
||
* header stays in the page flow.
|
||
*/
|
||
threadHeader?: ReactNode;
|
||
/**
|
||
* The task description rendered as the requester's first chat bubble
|
||
* (PAP-375). Only the chat-style TaskChatThread consumes it; this thread
|
||
* ignores it — its description stays in the page header via InlineEditor.
|
||
*/
|
||
issueBrief?: TaskChatIssueBrief;
|
||
variant?: "full" | "embedded";
|
||
enableLiveTranscriptPolling?: boolean;
|
||
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
|
||
hasOutputForRun?: (runId: string) => boolean;
|
||
includeSucceededRunsWithoutOutput?: boolean;
|
||
onInterruptQueued?: (runId: string | null) => Promise<IssueQueuedCommentQueue | void>;
|
||
onCancelQueued?: (commentId: string) => void;
|
||
/** Authoritative PRP queue. The classic thread intentionally ignores it. */
|
||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||
onEditQueuedComment?: (
|
||
commentId: string,
|
||
body: string,
|
||
revision: string,
|
||
) => Promise<void>;
|
||
onReorderQueuedComments?: (
|
||
orderedCommentIds: string[],
|
||
revision: string,
|
||
) => Promise<void>;
|
||
onSteerQueuedComment?: (commentId: string, revision: string) => Promise<void>;
|
||
/** Delay before an eligible queued message is steered automatically; null keeps every message queued. */
|
||
queuedMessageAutoSteerDelayMs?: number | null;
|
||
onDiscardQueuedComment?: (
|
||
commentId: string,
|
||
revision: string,
|
||
) => Promise<void>;
|
||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||
interruptingQueuedRunId?: string | null;
|
||
stoppingRunId?: string | null;
|
||
onImageClick?: (src: string) => void;
|
||
onAcceptInteraction?: (
|
||
interaction:
|
||
| SuggestTasksInteraction
|
||
| RequestConfirmationInteraction
|
||
| RequestCheckboxConfirmationInteraction,
|
||
selectedClientKeys?: string[],
|
||
selectedOptionIds?: string[],
|
||
rememberAction?: boolean,
|
||
) => Promise<void> | void;
|
||
onRejectInteraction?: (
|
||
interaction:
|
||
| SuggestTasksInteraction
|
||
| RequestConfirmationInteraction
|
||
| RequestCheckboxConfirmationInteraction,
|
||
reason?: string,
|
||
) => Promise<void> | void;
|
||
onSubmitInteractionAnswers?: (
|
||
interaction: AskUserQuestionsInteraction,
|
||
answers: AskUserQuestionsAnswer[],
|
||
) => Promise<void> | void;
|
||
onCancelInteraction?: (
|
||
interaction: AskUserQuestionsInteraction,
|
||
) => Promise<void> | void;
|
||
/** New task-view composer takeover action. The classic thread does not render it. */
|
||
onSkipInteraction?: (
|
||
interaction: IssueThreadInteraction,
|
||
) => Promise<void> | void;
|
||
onSubmitInteractionVerdicts?: (
|
||
interaction: RequestItemVerdictsInteraction,
|
||
verdicts: {
|
||
id: string;
|
||
verdict: RequestItemVerdictValue;
|
||
reason?: string;
|
||
}[],
|
||
) => Promise<void> | void;
|
||
composerRef?: Ref<IssueChatComposerHandle>;
|
||
/** Optional node rendered inline directly above the sticky composer dock (e.g. the monitor strip). */
|
||
composerAccessory?: ReactNode;
|
||
issueWorkMode?: IssueWorkMode;
|
||
/**
|
||
* Hook for the parent to refetch comments when the user explicitly asks
|
||
* to jump to the latest comment. Used to make sure the absolute newest
|
||
* comment is in the loaded set before we scroll to it.
|
||
*/
|
||
onRefreshLatestComments?: () => Promise<unknown> | void;
|
||
externalReferences?: MarkdownExternalReferenceMap;
|
||
/** Linkify `PAP-C7` case chips in comment bodies (experimental Cases flag). */
|
||
linkCaseReferences?: boolean;
|
||
}
|
||
|
||
type IssueChatErrorBoundaryProps = {
|
||
resetKey: string;
|
||
messages: readonly ThreadMessage[];
|
||
emptyMessage: string;
|
||
variant: "full" | "embedded";
|
||
externalReferences?: MarkdownExternalReferenceMap;
|
||
children: ReactNode;
|
||
};
|
||
|
||
type IssueChatErrorBoundaryState = {
|
||
hasError: boolean;
|
||
};
|
||
|
||
class IssueChatErrorBoundary extends Component<
|
||
IssueChatErrorBoundaryProps,
|
||
IssueChatErrorBoundaryState
|
||
> {
|
||
override state: IssueChatErrorBoundaryState = { hasError: false };
|
||
|
||
static getDerivedStateFromError(): IssueChatErrorBoundaryState {
|
||
return { hasError: true };
|
||
}
|
||
|
||
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
||
console.error(
|
||
"Issue chat renderer failed; falling back to safe transcript view",
|
||
{
|
||
error,
|
||
info: info.componentStack,
|
||
},
|
||
);
|
||
}
|
||
|
||
override componentDidUpdate(prevProps: IssueChatErrorBoundaryProps): void {
|
||
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||
this.setState({ hasError: false });
|
||
}
|
||
}
|
||
|
||
override render() {
|
||
if (this.state.hasError) {
|
||
return (
|
||
<IssueChatFallbackThread
|
||
messages={this.props.messages}
|
||
emptyMessage={this.props.emptyMessage}
|
||
variant={this.props.variant}
|
||
externalReferences={this.props.externalReferences}
|
||
/>
|
||
);
|
||
}
|
||
return this.props.children;
|
||
}
|
||
}
|
||
|
||
export function IssueAssigneePausedNotice({
|
||
agent,
|
||
issueStatus,
|
||
onResume,
|
||
resuming,
|
||
}: {
|
||
agent: Agent | null;
|
||
issueStatus: string | undefined;
|
||
onResume?: () => Promise<void> | void;
|
||
resuming?: boolean;
|
||
}) {
|
||
if (!agent || agent.status !== "paused" || issueStatusIsTerminalDisposition(issueStatus)) return null;
|
||
|
||
// Lifecycle setup is shown by the queued turn and its inline connection card.
|
||
// Resume changes operator holds; it cannot complete verification.
|
||
if (!agent.pauseReason && ["preparing", "verifying", "resuming"].includes(agent.lifecycleState ?? "")) return null;
|
||
|
||
// A freshly created agent is stored as paused with pauseReason "setup" until
|
||
// its setup finishes. That is not an operator pause, so say what is actually
|
||
// happening and point at the real recovery path instead of "Resume agent".
|
||
if (agent.pauseReason === "setup") {
|
||
// Only computer preparation advances on its own (matches NewAgentSetup).
|
||
// A "verifying" or parked draft waits for the user to finish the wizard.
|
||
const stillPreparing = ["preparing", "pausing"].includes(agent.lifecycleState ?? "");
|
||
return (
|
||
<div data-testid="issue-assignee-paused-notice" className="mb-3">
|
||
<InlineBanner
|
||
tone="info"
|
||
compact
|
||
title={
|
||
<>
|
||
<span className="font-medium">{agent.name}</span> is still being set up.
|
||
</>
|
||
}
|
||
actions={
|
||
stillPreparing ? undefined : (
|
||
<Button size="sm" variant="outline" asChild>
|
||
<Link to={agentUrl(agent)} data-testid="issue-assignee-finish-setup">
|
||
Finish setup
|
||
</Link>
|
||
</Button>
|
||
)
|
||
}
|
||
>
|
||
{stillPreparing
|
||
? "Its computer is being prepared. Runs start automatically when setup finishes."
|
||
: "Its setup isn't finished yet. Finish setup to let it start runs."}
|
||
</InlineBanner>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const pauseDetail =
|
||
agent.pauseReason === "budget"
|
||
? "It was paused by a budget hard stop."
|
||
: agent.pauseReason === "import"
|
||
? "It arrived paused from an organization import — imported agents stay parked until you resume them."
|
||
: agent.pauseReason === "system"
|
||
? "It was paused by the system."
|
||
: agent.pauseReason === "company_archived"
|
||
? "It was paused because its company is archived."
|
||
: agent.pauseReason === "manual"
|
||
? "It was paused manually."
|
||
: "";
|
||
// Budget pauses clear on their own when the budget resets and archive pauses
|
||
// clear when the company is restored; resuming by hand would fight either, so
|
||
// the action is only offered for the rest.
|
||
const canResume = Boolean(onResume) && agent.pauseReason !== "budget" && agent.pauseReason !== "company_archived";
|
||
|
||
return (
|
||
<div data-testid="issue-assignee-paused-notice" className="mb-3">
|
||
<InlineBanner
|
||
tone="warning"
|
||
icon={PauseCircle}
|
||
compact
|
||
title={
|
||
<>
|
||
<span className="font-medium">{agent.name}</span> is paused.
|
||
</>
|
||
}
|
||
actions={
|
||
canResume ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={onResume}
|
||
disabled={resuming}
|
||
data-testid="issue-assignee-paused-resume"
|
||
>
|
||
{resuming ? "Resuming…" : "Resume agent"}
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
>
|
||
New runs will not start until the agent is resumed.{pauseDetail ? ` ${pauseDetail}` : ""}
|
||
</InlineBanner>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function fallbackAuthorLabel(message: ThreadMessage) {
|
||
const custom = message.metadata?.custom as
|
||
Record<string, unknown> | undefined;
|
||
if (typeof custom?.["authorName"] === "string") return custom["authorName"];
|
||
if (typeof custom?.["runAgentName"] === "string")
|
||
return custom["runAgentName"];
|
||
if (message.role === "assistant") return "Agent";
|
||
if (message.role === "user") return "You";
|
||
return "System";
|
||
}
|
||
|
||
function fallbackTextParts(message: ThreadMessage) {
|
||
const contentLines: string[] = [];
|
||
for (const part of message.content) {
|
||
if (part.type === "text" || part.type === "reasoning") {
|
||
if (part.text.trim().length > 0) contentLines.push(part.text);
|
||
continue;
|
||
}
|
||
if (part.type === "tool-call") {
|
||
const lines = [`Tool: ${part.toolName}`];
|
||
if (part.argsText?.trim()) lines.push(`Args:\n${part.argsText}`);
|
||
if (typeof part.result === "string" && part.result.trim())
|
||
lines.push(`Result:\n${part.result}`);
|
||
contentLines.push(lines.join("\n\n"));
|
||
}
|
||
}
|
||
|
||
const custom = message.metadata?.custom as
|
||
Record<string, unknown> | undefined;
|
||
if (
|
||
contentLines.length === 0 &&
|
||
typeof custom?.["waitingText"] === "string" &&
|
||
custom["waitingText"].trim()
|
||
) {
|
||
contentLines.push(custom["waitingText"]);
|
||
}
|
||
return contentLines;
|
||
}
|
||
|
||
function IssueChatFallbackThread({
|
||
messages,
|
||
emptyMessage,
|
||
variant,
|
||
externalReferences,
|
||
}: {
|
||
messages: readonly ThreadMessage[];
|
||
emptyMessage: string;
|
||
variant: "full" | "embedded";
|
||
externalReferences?: MarkdownExternalReferenceMap;
|
||
}) {
|
||
return (
|
||
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||
<div className="rounded-xl border border-amber-300/60 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||
<div className="space-y-1">
|
||
<p className="font-medium">
|
||
Chat renderer hit an internal state error.
|
||
</p>
|
||
<p className="text-xs opacity-80">
|
||
Showing a safe fallback transcript instead of crashing the tasks
|
||
page.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{messages.length === 0 ? (
|
||
<Card
|
||
className={cn(
|
||
"block shadow-none text-center text-sm text-muted-foreground",
|
||
variant === "embedded"
|
||
? "border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||
: "border-dashed px-6 py-10",
|
||
)}
|
||
>
|
||
{emptyMessage}
|
||
</Card>
|
||
) : (
|
||
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||
{messages.map((message) => {
|
||
const lines = fallbackTextParts(message);
|
||
return (
|
||
<Card
|
||
key={message.id}
|
||
className="block border-border/60 bg-card/70 px-4 py-3"
|
||
>
|
||
<div className="mb-2 flex items-center gap-2 text-sm">
|
||
<span className="font-medium text-foreground">
|
||
{fallbackAuthorLabel(message)}
|
||
</span>
|
||
{message.createdAt ? (
|
||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||
{commentDateLabel(message.createdAt)}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="space-y-2">
|
||
{lines.length > 0 ? (
|
||
lines.map((line, index) => (
|
||
<MarkdownBody
|
||
key={`${message.id}:fallback:${index}`}
|
||
externalReferences={externalReferences}
|
||
>
|
||
{line}
|
||
</MarkdownBody>
|
||
))
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">
|
||
No message content.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const DRAFT_DEBOUNCE_MS = 800;
|
||
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
||
const SUBMIT_SCROLL_RESERVE_VH = 0.4;
|
||
|
||
type ComposerAttachmentItem = {
|
||
id: string;
|
||
attachmentId?: string;
|
||
name: string;
|
||
size: number;
|
||
status: "uploading" | "attached" | "error";
|
||
inline: boolean;
|
||
contentPath?: string;
|
||
error?: string;
|
||
/**
|
||
* The picked file, kept after a failed upload so the chip can offer Retry.
|
||
* Uploads are not deduped by the server, so they never resend on their own.
|
||
*/
|
||
file?: File;
|
||
};
|
||
|
||
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
|
||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||
}
|
||
|
||
function formatAttachmentSize(bytes: number) {
|
||
if (!Number.isFinite(bytes) || bytes <= 0) return "";
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function toIsoString(value: string | Date | null | undefined): string | null {
|
||
if (!value) return null;
|
||
return typeof value === "string" ? value : value.toISOString();
|
||
}
|
||
|
||
/**
|
||
* ISO timestamp for display, or undefined when the value does not parse as a
|
||
* real date. Comment timestamps can arrive malformed (e.g. a server
|
||
* serialization bug turning Dates into `{}`); formatting must degrade to "no
|
||
* timestamp" instead of throwing mid-render (PAP-16607).
|
||
*/
|
||
function toValidIsoString(
|
||
value: Date | string | number | undefined,
|
||
): string | undefined {
|
||
if (value === undefined || value === null) return undefined;
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||
}
|
||
|
||
function parseReassignment(
|
||
target: string,
|
||
): PaperclipIssueRuntimeReassignment | null {
|
||
if (!target || target === "__none__") {
|
||
return { assigneeAgentId: null, assigneeUserId: null };
|
||
}
|
||
if (target.startsWith("agent:")) {
|
||
const assigneeAgentId = target.slice("agent:".length);
|
||
return assigneeAgentId ? { assigneeAgentId, assigneeUserId: null } : null;
|
||
}
|
||
if (target.startsWith("user:")) {
|
||
const assigneeUserId = target.slice("user:".length);
|
||
return assigneeUserId ? { assigneeAgentId: null, assigneeUserId } : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function shouldImplicitlyReopenComment(
|
||
issueStatus: string | undefined,
|
||
assigneeValue: string,
|
||
) {
|
||
const resumesToTodo =
|
||
issueStatus === "done" ||
|
||
issueStatus === "cancelled" ||
|
||
issueStatus === "blocked";
|
||
return resumesToTodo && assigneeValue.startsWith("agent:");
|
||
}
|
||
|
||
function isUnassignedReassignValue(value: string): boolean {
|
||
return !value || value === "__none__";
|
||
}
|
||
|
||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||
|
||
function commentDateLabel(date: Date | string | undefined): string {
|
||
if (!date) return "";
|
||
const then = new Date(date).getTime();
|
||
if (Date.now() - then < WEEK_MS) return timeAgo(date);
|
||
return formatShortDate(date);
|
||
}
|
||
|
||
const IssueChatTextPart = memo(function IssueChatTextPart({
|
||
text,
|
||
recessed,
|
||
onAccent,
|
||
}: {
|
||
text: string;
|
||
recessed?: boolean;
|
||
onAccent?: boolean;
|
||
}) {
|
||
const { onImageClick, externalReferences, linkCaseReferences } =
|
||
useContext(IssueChatCtx);
|
||
if (isSuccessfulRunHandoffComment(text)) {
|
||
return (
|
||
<SuccessfulRunHandoffCommentCallout
|
||
text={text}
|
||
recessed={recessed}
|
||
onImageClick={onImageClick}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<WorkspaceFileMarkdownBody
|
||
className={cn(
|
||
"text-sm leading-6",
|
||
onAccent && "paperclip-markdown-on-accent",
|
||
)}
|
||
style={recessed ? { opacity: 0.55 } : undefined}
|
||
softBreaks
|
||
onImageClick={onImageClick}
|
||
externalReferences={externalReferences}
|
||
linkCaseReferences={linkCaseReferences}
|
||
>
|
||
{text}
|
||
</WorkspaceFileMarkdownBody>
|
||
);
|
||
});
|
||
|
||
export function SuccessfulRunHandoffCommentCallout({
|
||
text,
|
||
recessed,
|
||
onImageClick,
|
||
}: {
|
||
text: string;
|
||
recessed?: boolean;
|
||
onImageClick?: (src: string) => void;
|
||
}) {
|
||
const escalated = isSuccessfulRunHandoffEscalationComment(text);
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"rounded-md border px-3 py-2.5 text-sm shadow-sm",
|
||
escalated
|
||
? "border-red-500/35 bg-red-500/10 text-red-950 dark:text-red-100"
|
||
: "border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
|
||
)}
|
||
style={recessed ? { opacity: 0.55 } : undefined}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle
|
||
className={cn(
|
||
"mt-1 h-4 w-4 shrink-0",
|
||
escalated
|
||
? "text-red-600 dark:text-red-300"
|
||
: "text-amber-600 dark:text-amber-300",
|
||
)}
|
||
/>
|
||
<MarkdownBody
|
||
className="min-w-0 text-sm leading-6"
|
||
softBreaks
|
||
onImageClick={onImageClick}
|
||
>
|
||
{text}
|
||
</MarkdownBody>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function humanizeValue(value: string | null) {
|
||
if (!value) return "None";
|
||
return value.replace(/_/g, " ");
|
||
}
|
||
|
||
function initialsForName(name: string) {
|
||
const parts = name.trim().split(/\s+/);
|
||
if (parts.length >= 2) {
|
||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||
}
|
||
return name.slice(0, 2).toUpperCase();
|
||
}
|
||
|
||
function formatInteractionActorLabel(args: {
|
||
agentId?: string | null;
|
||
userId?: string | null;
|
||
agentMap?: Map<string, Agent>;
|
||
currentUserId?: string | null;
|
||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||
}) {
|
||
const { agentId, userId, agentMap, currentUserId, userLabelMap } = args;
|
||
if (agentId) return agentMap?.get(agentId)?.name ?? agentId.slice(0, 8);
|
||
if (userId) {
|
||
return (
|
||
userLabelMap?.get(userId) ??
|
||
formatAssigneeUserLabel(userId, currentUserId, userLabelMap) ??
|
||
"Board"
|
||
);
|
||
}
|
||
return "System";
|
||
}
|
||
|
||
export { resolveIssueChatHumanAuthor } from "@/lib/issue-chat-human-author";
|
||
|
||
function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null {
|
||
if (toolParts.length === 0) return null;
|
||
let commands = 0;
|
||
let other = 0;
|
||
for (const tool of toolParts) {
|
||
if (isCommandTool(tool.toolName, tool.args)) commands++;
|
||
else other++;
|
||
}
|
||
const parts: string[] = [];
|
||
if (commands > 0)
|
||
parts.push(`ran ${commands} command${commands === 1 ? "" : "s"}`);
|
||
if (other > 0) parts.push(`called ${other} tool${other === 1 ? "" : "s"}`);
|
||
return parts.join(", ");
|
||
}
|
||
|
||
function cleanToolDisplayText(tool: ToolCallMessagePart): string {
|
||
const name = displayToolName(tool.toolName, tool.args);
|
||
if (isCommandTool(tool.toolName, tool.args)) return name;
|
||
const summary =
|
||
tool.result === undefined
|
||
? summarizeToolInput(tool.toolName, tool.args)
|
||
: null;
|
||
return summary ? `${name} ${summary}` : name;
|
||
}
|
||
|
||
type IssueChatCoTPart = ReasoningMessagePart | ToolCallMessagePart;
|
||
|
||
function IssueChatChainOfThought({
|
||
message,
|
||
cotParts,
|
||
}: {
|
||
message: ThreadMessage;
|
||
cotParts: readonly IssueChatCoTPart[];
|
||
}) {
|
||
const { agentMap } = useContext(IssueChatCtx);
|
||
const custom = message.metadata.custom as Record<string, unknown>;
|
||
const runAgentId =
|
||
typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||
const authorAgentId =
|
||
typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
|
||
const agentId = authorAgentId ?? runAgentId;
|
||
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
||
// Adapters whose backends overwhelm the one-line reasoning ticker declare
|
||
// a scrollable live reasoning view via their UI adapter module
|
||
// (transcriptPresentation.liveReasoningView); resolved through the registry
|
||
// so this component never branches on adapter identities. Every adapter
|
||
// without a declaration keeps the existing ticker rendering.
|
||
const adapterType =
|
||
typeof custom.adapterType === "string" ? custom.adapterType : null;
|
||
const isVerboseStreamingBackend =
|
||
(adapterType
|
||
? findUIAdapter(adapterType)?.transcriptPresentation?.liveReasoningView
|
||
: undefined) === "scrollLog";
|
||
const isMessageRunning =
|
||
message.role === "assistant" && message.status?.type === "running";
|
||
|
||
const myIndex = useMemo(
|
||
() => findCoTSegmentIndex(message.content, cotParts),
|
||
[message.content, cotParts],
|
||
);
|
||
|
||
const allReasoningText = cotParts
|
||
.filter(
|
||
(p): p is { type: "reasoning"; text: string } =>
|
||
p.type === "reasoning" && !!p.text,
|
||
)
|
||
.map((p) => p.text)
|
||
.join("\n");
|
||
const toolParts = cotParts.filter(
|
||
(p): p is ToolCallMessagePart => p.type === "tool-call",
|
||
);
|
||
|
||
const rawSegments = Array.isArray(custom.chainOfThoughtSegments)
|
||
? (custom.chainOfThoughtSegments as SegmentTiming[])
|
||
: [];
|
||
const segmentTiming = myIndex >= 0 ? (rawSegments[myIndex] ?? null) : null;
|
||
const isActive = isCoTSegmentActive({
|
||
isMessageRunning,
|
||
segmentIndex: myIndex,
|
||
segmentCount: rawSegments.length,
|
||
});
|
||
const [expanded, setExpanded] = useState(isActive);
|
||
const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive);
|
||
|
||
useEffect(() => {
|
||
if (isActive) setExpanded(true);
|
||
}, [isActive]);
|
||
|
||
let headerVerb: string;
|
||
let headerSuffix: string | null = null;
|
||
if (isActive) {
|
||
headerVerb = "Working";
|
||
if (liveElapsed) headerSuffix = `for ${liveElapsed}`;
|
||
} else if (segmentTiming) {
|
||
const durationMs = segmentTiming.endMs - segmentTiming.startMs;
|
||
const durationText = formatDurationWords(durationMs);
|
||
headerVerb = "Worked";
|
||
if (durationText) headerSuffix = `for ${durationText}`;
|
||
} else {
|
||
headerVerb = "Worked";
|
||
}
|
||
|
||
const toolSummary = toolCountSummary(toolParts);
|
||
const hasContent = allReasoningText.trim().length > 0 || toolParts.length > 0;
|
||
|
||
return (
|
||
<div>
|
||
<button
|
||
type="button"
|
||
className="group flex w-full items-start gap-2.5 rounded-lg px-1 py-2 text-left transition-colors hover:bg-accent/5"
|
||
onClick={() => hasContent && setExpanded((v) => !v)}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||
{agentId ? (
|
||
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
|
||
) : isActive ? (
|
||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||
) : (
|
||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500/70" />
|
||
</span>
|
||
)}
|
||
{isActive ? (
|
||
<span className="shimmer-text">{headerVerb}</span>
|
||
) : (
|
||
headerVerb
|
||
)}
|
||
</span>
|
||
{headerSuffix ? (
|
||
<span className="text-xs text-muted-foreground/60">
|
||
{headerSuffix}
|
||
</span>
|
||
) : null}
|
||
{toolSummary ? (
|
||
<span className="text-xs text-muted-foreground/40">
|
||
· {toolSummary}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<IssueChatLiveRunStatusLine
|
||
custom={custom}
|
||
active={isActive}
|
||
className="pl-6"
|
||
/>
|
||
</div>
|
||
{hasContent ? (
|
||
<ChevronDown
|
||
className={cn(
|
||
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/50 transition-transform",
|
||
expanded && "rotate-180",
|
||
)}
|
||
/>
|
||
) : null}
|
||
</button>
|
||
{expanded && hasContent ? (
|
||
<div className="space-y-1 py-1">
|
||
{isActive && isVerboseStreamingBackend ? (
|
||
<>
|
||
{allReasoningText ? (
|
||
<IssueChatVerboseLiveReasoningPart text={allReasoningText} />
|
||
) : null}
|
||
{toolParts.map((tool) => (
|
||
<IssueChatToolPart
|
||
key={tool.toolCallId}
|
||
toolName={tool.toolName}
|
||
args={tool.args}
|
||
argsText={tool.argsText}
|
||
result={tool.result}
|
||
isError={false}
|
||
/>
|
||
))}
|
||
</>
|
||
) : isActive ? (
|
||
<>
|
||
{allReasoningText ? (
|
||
<IssueChatReasoningPart text={allReasoningText} />
|
||
) : null}
|
||
{toolParts.length > 0 ? (
|
||
<IssueChatRollingToolPart toolParts={toolParts} />
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<>
|
||
{allReasoningText ? (
|
||
<IssueChatReasoningPart text={allReasoningText} />
|
||
) : null}
|
||
{toolParts.map((tool) => (
|
||
<IssueChatToolPart
|
||
key={tool.toolCallId}
|
||
toolName={tool.toolName}
|
||
args={tool.args}
|
||
argsText={tool.argsText}
|
||
result={tool.result}
|
||
isError={false}
|
||
/>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Live reasoning for verbose streaming backends: the one-line
|
||
// ticker cannot keep up with token-level delta volume, so show the full
|
||
// reasoning in a scrollable box that auto-follows the newest line unless the
|
||
// reader has scrolled up to review earlier thinking. All other adapters keep
|
||
// the ticker (IssueChatReasoningPart below), which is unchanged.
|
||
function IssueChatVerboseLiveReasoningPart({ text }: { text: string }) {
|
||
const lines = text.split("\n").filter((l) => l.trim());
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const pinnedToBottomRef = useRef(true);
|
||
useEffect(() => {
|
||
const node = scrollRef.current;
|
||
if (!node) return;
|
||
if (pinnedToBottomRef.current) {
|
||
node.scrollTop = node.scrollHeight;
|
||
}
|
||
}, [text]);
|
||
|
||
if (lines.length <= 1) {
|
||
return <IssueChatReasoningPart text={text} />;
|
||
}
|
||
|
||
return (
|
||
<div className="flex gap-2 px-1">
|
||
<div className="flex flex-col items-center pt-0.5">
|
||
<Brain className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||
</div>
|
||
<div
|
||
ref={scrollRef}
|
||
onScroll={() => {
|
||
const node = scrollRef.current;
|
||
if (!node) return;
|
||
pinnedToBottomRef.current =
|
||
node.scrollHeight - node.scrollTop - node.clientHeight < 24;
|
||
}}
|
||
className="min-w-0 flex-1 max-h-40 space-y-0.5 overflow-y-auto pr-1"
|
||
>
|
||
{lines.map((line, index) => (
|
||
<p
|
||
key={index}
|
||
className="whitespace-pre-wrap break-words text-(length:--text-compact) italic leading-5 text-muted-foreground/70"
|
||
>
|
||
{line}
|
||
</p>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IssueChatReasoningPart({ text }: { text: string }) {
|
||
const lines = text.split("\n").filter((l) => l.trim());
|
||
const lastLine = lines[lines.length - 1] ?? text.slice(-200);
|
||
const prevRef = useRef(lastLine);
|
||
const [ticker, setTicker] = useState<{
|
||
key: number;
|
||
current: string;
|
||
exiting: string | null;
|
||
}>({ key: 0, current: lastLine, exiting: null });
|
||
|
||
useEffect(() => {
|
||
if (lastLine !== prevRef.current) {
|
||
const prev = prevRef.current;
|
||
prevRef.current = lastLine;
|
||
setTicker((t) => ({ key: t.key + 1, current: lastLine, exiting: prev }));
|
||
}
|
||
}, [lastLine]);
|
||
|
||
return (
|
||
<div className="flex gap-2 px-1">
|
||
<div className="flex flex-col items-center pt-0.5">
|
||
<Brain className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||
</div>
|
||
<div className="relative h-5 min-w-0 flex-1 overflow-hidden">
|
||
{ticker.exiting !== null && (
|
||
<span
|
||
key={`out-${ticker.key}`}
|
||
className="cot-line-exit absolute inset-x-0 truncate text-(length:--text-compact) italic leading-5 text-muted-foreground/70"
|
||
onAnimationEnd={() => setTicker((t) => ({ ...t, exiting: null }))}
|
||
>
|
||
{ticker.exiting}
|
||
</span>
|
||
)}
|
||
<span
|
||
key={`in-${ticker.key}`}
|
||
className={cn(
|
||
"absolute inset-x-0 truncate text-(length:--text-compact) italic leading-5 text-muted-foreground/70",
|
||
ticker.key > 0 && "cot-line-enter",
|
||
)}
|
||
>
|
||
{ticker.current}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IssueChatRollingToolPart({
|
||
toolParts,
|
||
}: {
|
||
toolParts: ToolCallMessagePart[];
|
||
}) {
|
||
const latest = toolParts[toolParts.length - 1];
|
||
if (!latest) return null;
|
||
|
||
const fullText = cleanToolDisplayText(latest);
|
||
|
||
const prevRef = useRef(fullText);
|
||
const [ticker, setTicker] = useState<{
|
||
key: number;
|
||
current: string;
|
||
exiting: string | null;
|
||
}>({ key: 0, current: fullText, exiting: null });
|
||
|
||
useEffect(() => {
|
||
if (fullText !== prevRef.current) {
|
||
const prev = prevRef.current;
|
||
prevRef.current = fullText;
|
||
setTicker((t) => ({ key: t.key + 1, current: fullText, exiting: prev }));
|
||
}
|
||
}, [fullText]);
|
||
|
||
const ToolIcon = getToolIcon(latest.toolName);
|
||
const isRunning = latest.result === undefined;
|
||
|
||
return (
|
||
<div className="flex gap-2 px-1">
|
||
<div className="flex flex-col items-center pt-0.5">
|
||
{isRunning ? (
|
||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/50" />
|
||
) : (
|
||
<ToolIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||
)}
|
||
</div>
|
||
<div className="relative h-5 min-w-0 flex-1 overflow-hidden">
|
||
{ticker.exiting !== null && (
|
||
<span
|
||
key={`out-${ticker.key}`}
|
||
className="cot-line-exit absolute inset-x-0 truncate text-(length:--text-compact) leading-5 text-muted-foreground/70"
|
||
onAnimationEnd={() => setTicker((t) => ({ ...t, exiting: null }))}
|
||
>
|
||
{ticker.exiting}
|
||
</span>
|
||
)}
|
||
<span
|
||
key={`in-${ticker.key}`}
|
||
className={cn(
|
||
"absolute inset-x-0 truncate text-(length:--text-compact) leading-5 text-muted-foreground/70",
|
||
ticker.key > 0 && "cot-line-enter",
|
||
)}
|
||
>
|
||
{ticker.current}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CopyablePreBlock({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: string;
|
||
className?: string;
|
||
}) {
|
||
const [copied, setCopied] = useState(false);
|
||
const toastActions = useOptionalToastActions();
|
||
return (
|
||
<div className="group/pre relative">
|
||
<pre className={className}>{children}</pre>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"absolute right-1.5 top-1.5 inline-flex h-6 w-6 items-center justify-center rounded-md bg-background/80 text-muted-foreground opacity-0 backdrop-blur-sm transition-opacity hover:text-foreground group-hover/pre:opacity-100",
|
||
copied && "opacity-100",
|
||
)}
|
||
title="Copy"
|
||
aria-label="Copy"
|
||
onClick={() => {
|
||
void copyTextToClipboard(children)
|
||
.then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
})
|
||
.catch((error) => {
|
||
toastActions?.pushToast({
|
||
title: "Copy failed",
|
||
body:
|
||
error instanceof Error
|
||
? error.message
|
||
: "Unable to copy text",
|
||
tone: "error",
|
||
});
|
||
});
|
||
}}
|
||
>
|
||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const TOOL_ICON_MAP: Record<
|
||
string,
|
||
React.ComponentType<{ className?: string }>
|
||
> = {
|
||
paperclip_provider_activity: ClipboardList,
|
||
};
|
||
|
||
function getToolIcon(
|
||
toolName: string,
|
||
): React.ComponentType<{ className?: string }> {
|
||
return TOOL_ICON_MAP[toolName] ?? Hammer;
|
||
}
|
||
|
||
function IssueChatToolPart({
|
||
toolName,
|
||
args,
|
||
argsText,
|
||
result,
|
||
isError,
|
||
}: {
|
||
toolName: string;
|
||
args?: unknown;
|
||
argsText?: string;
|
||
result?: unknown;
|
||
isError?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
if (toolName === "paperclip_provider_activity") {
|
||
return (
|
||
<IssueChatProviderActivity
|
||
args={args}
|
||
running={result === undefined}
|
||
open={open}
|
||
onToggle={() => setOpen((current) => !current)}
|
||
/>
|
||
);
|
||
}
|
||
const rawArgsText = argsText ?? "";
|
||
const parsedArgs = args ?? parseToolPayload(rawArgsText);
|
||
const resultText =
|
||
typeof result === "string"
|
||
? result
|
||
: result === undefined
|
||
? ""
|
||
: formatToolPayload(result);
|
||
const inputDetails = describeToolInput(toolName, parsedArgs);
|
||
const displayName = displayToolName(toolName, parsedArgs);
|
||
const isCommand = isCommandTool(toolName, parsedArgs);
|
||
const summary = isCommand
|
||
? null
|
||
: result === undefined
|
||
? summarizeToolInput(toolName, parsedArgs)
|
||
: summarizeToolResult(resultText, false);
|
||
const ToolIcon = getToolIcon(toolName);
|
||
|
||
const intentDetail = inputDetails.find((d) => d.label === "Intent");
|
||
const title = intentDetail?.value ?? displayName;
|
||
const nonIntentDetails = inputDetails.filter((d) => d.label !== "Intent");
|
||
|
||
return (
|
||
<div className="flex gap-2 px-1">
|
||
<div className="flex flex-col items-center pt-1">
|
||
<ToolIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||
{open ? <div className="mt-1 w-px flex-1 bg-border/40" /> : null}
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center gap-2 rounded-md py-0.5 text-left transition-colors hover:bg-accent/5"
|
||
onClick={() => setOpen((current) => !current)}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate text-(length:--text-compact) text-muted-foreground/80">
|
||
{title}
|
||
{!intentDetail && summary ? (
|
||
<span className="ml-1.5 text-muted-foreground/50">{summary}</span>
|
||
) : null}
|
||
</span>
|
||
{result === undefined ? (
|
||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground/50" />
|
||
) : null}
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-3.5 w-3.5 shrink-0 text-muted-foreground/40 transition-transform",
|
||
open && "rotate-180",
|
||
)}
|
||
/>
|
||
</button>
|
||
|
||
{open ? (
|
||
<div className="mt-1 space-y-2 pb-1">
|
||
{nonIntentDetails.length > 0 ? (
|
||
<div>
|
||
<div className="mb-1 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground/60">
|
||
Input
|
||
</div>
|
||
<dl className="space-y-1.5">
|
||
{nonIntentDetails.map((detail) => (
|
||
<div key={`${detail.label}:${detail.value}`}>
|
||
<dt className="text-(length:--text-nano) font-medium text-muted-foreground/60">
|
||
{detail.label}
|
||
</dt>
|
||
<dd
|
||
className={cn(
|
||
"text-xs leading-5 text-foreground/70",
|
||
detail.tone === "code" &&
|
||
"font-mono text-(length:--text-micro)",
|
||
)}
|
||
>
|
||
{detail.value}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</div>
|
||
) : rawArgsText ? (
|
||
<div>
|
||
<div className="mb-1 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground/60">
|
||
Input
|
||
</div>
|
||
<CopyablePreBlock className="overflow-x-auto rounded-md bg-accent/30 p-2 text-(length:--text-micro) leading-4 text-foreground/70">
|
||
{rawArgsText}
|
||
</CopyablePreBlock>
|
||
</div>
|
||
) : null}
|
||
{result !== undefined ? (
|
||
<div>
|
||
<div className="mb-1 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground/60">
|
||
Result
|
||
</div>
|
||
<CopyablePreBlock className="overflow-x-auto rounded-md bg-accent/30 p-2 text-(length:--text-micro) leading-4 text-foreground/70">
|
||
{resultText}
|
||
</CopyablePreBlock>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IssueChatProviderActivity({
|
||
args,
|
||
running,
|
||
open,
|
||
onToggle,
|
||
}: {
|
||
args: unknown;
|
||
running: boolean;
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
}) {
|
||
const value =
|
||
typeof args === "object" && args !== null && !Array.isArray(args)
|
||
? (args as Record<string, unknown>)
|
||
: {};
|
||
const payload =
|
||
typeof value.payload === "object" &&
|
||
value.payload !== null &&
|
||
!Array.isArray(value.payload)
|
||
? (value.payload as Record<string, unknown>)
|
||
: {};
|
||
const title =
|
||
typeof value.title === "string" ? value.title : "Provider activity";
|
||
const summary = typeof value.summary === "string" ? value.summary : "";
|
||
const steps = Array.isArray(payload.steps) ? payload.steps.slice(0, 256) : [];
|
||
const children = Array.isArray(payload.children)
|
||
? payload.children.slice(0, 64)
|
||
: [];
|
||
const sources = Array.isArray(payload.sources)
|
||
? payload.sources.slice(0, 64)
|
||
: [];
|
||
const output =
|
||
typeof payload.output === "string" ? payload.output.slice(-(8 * 1024)) : "";
|
||
const effectiveModel =
|
||
typeof payload.effectiveModel === "string" ? payload.effectiveModel : null;
|
||
const requestedModel =
|
||
typeof payload.requestedModel === "string" ? payload.requestedModel : null;
|
||
const hasDetails =
|
||
steps.length > 0 ||
|
||
children.length > 0 ||
|
||
sources.length > 0 ||
|
||
output.length > 0 ||
|
||
effectiveModel !== null;
|
||
return (
|
||
<div
|
||
className="flex gap-2 px-1"
|
||
data-provider-family={String(value.family ?? "unknown")}
|
||
>
|
||
<div className="pt-1">
|
||
<ClipboardList className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center gap-2 rounded-md py-0.5 text-left hover:bg-accent/5"
|
||
onClick={onToggle}
|
||
aria-expanded={open}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate text-(length:--text-compact) text-muted-foreground/80">
|
||
{title}
|
||
{summary ? (
|
||
<span className="ml-1.5 text-muted-foreground/50">{summary}</span>
|
||
) : null}
|
||
</span>
|
||
{running ? (
|
||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground/50" />
|
||
) : null}
|
||
{hasDetails ? (
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-3.5 w-3.5 text-muted-foreground/40 transition-transform",
|
||
open && "rotate-180",
|
||
)}
|
||
/>
|
||
) : null}
|
||
</button>
|
||
{open && hasDetails ? (
|
||
<div className="mt-1 space-y-2 rounded-md border border-border/50 bg-accent/15 p-2 text-xs">
|
||
{steps.map((entry, index) => {
|
||
const step =
|
||
typeof entry === "object" && entry !== null
|
||
? (entry as Record<string, unknown>)
|
||
: {};
|
||
return (
|
||
<div key={String(step.stepId ?? index)} className="flex gap-2">
|
||
<span aria-hidden>
|
||
{step.status === "completed"
|
||
? "✓"
|
||
: step.status === "blocked"
|
||
? "!"
|
||
: "○"}
|
||
</span>
|
||
<span>{String(step.body ?? "")}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
{children.map((entry, index) => {
|
||
const child =
|
||
typeof entry === "object" && entry !== null
|
||
? (entry as Record<string, unknown>)
|
||
: {};
|
||
return (
|
||
<div key={String(child.childId ?? index)}>
|
||
<span className="font-medium">
|
||
{String(child.role ?? "Child agent")}
|
||
</span>{" "}
|
||
· {String(child.status ?? "unknown")}
|
||
{child.summary ? ` — ${String(child.summary)}` : ""}
|
||
</div>
|
||
);
|
||
})}
|
||
{sources.map((entry, index) => {
|
||
const source =
|
||
typeof entry === "object" && entry !== null
|
||
? (entry as Record<string, unknown>)
|
||
: {};
|
||
const href =
|
||
typeof source.url === "string" &&
|
||
/^https?:\/\//.test(source.url)
|
||
? source.url
|
||
: null;
|
||
return (
|
||
<div key={String(source.sourceId ?? index)}>
|
||
{href ? (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="underline"
|
||
>
|
||
{String(source.title ?? href)}
|
||
</a>
|
||
) : (
|
||
<span>{String(source.title ?? "Unavailable source")}</span>
|
||
)}{" "}
|
||
<span className="text-muted-foreground">
|
||
(provider-reported)
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
{effectiveModel ? (
|
||
<div>
|
||
<span className="text-muted-foreground">Model</span>{" "}
|
||
{requestedModel && requestedModel !== effectiveModel
|
||
? `${requestedModel} → `
|
||
: ""}
|
||
{effectiveModel}
|
||
</div>
|
||
) : null}
|
||
{output ? (
|
||
<CopyablePreBlock className="max-h-56 overflow-auto whitespace-pre-wrap rounded bg-background/70 p-2 font-mono text-(length:--text-micro)">
|
||
{output}
|
||
</CopyablePreBlock>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getThreadMessageCopyText(message: ThreadMessage) {
|
||
return message.content
|
||
.filter((part): part is TextMessagePart => part.type === "text")
|
||
.map((part) => part.text)
|
||
.join("\n\n");
|
||
}
|
||
|
||
const IssueChatTextParts = memo(function IssueChatTextParts({
|
||
message,
|
||
recessed = false,
|
||
onAccent = false,
|
||
}: {
|
||
message: ThreadMessage;
|
||
recessed?: boolean;
|
||
onAccent?: boolean;
|
||
}) {
|
||
return (
|
||
<>
|
||
{message.content
|
||
.filter((part): part is TextMessagePart => part.type === "text")
|
||
.map((part, index) => (
|
||
<IssueChatTextPart
|
||
key={`${message.id}:text:${index}`}
|
||
text={part.text}
|
||
recessed={recessed}
|
||
onAccent={onAccent}
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
});
|
||
|
||
function groupAssistantParts(
|
||
content: readonly ThreadMessage["content"][number][],
|
||
): Array<
|
||
| { type: "text"; part: TextMessagePart; index: number }
|
||
| { type: "cot"; parts: IssueChatCoTPart[]; startIndex: number }
|
||
> {
|
||
const groups: Array<
|
||
| { type: "text"; part: TextMessagePart; index: number }
|
||
| { type: "cot"; parts: IssueChatCoTPart[]; startIndex: number }
|
||
> = [];
|
||
let pendingCoT: IssueChatCoTPart[] = [];
|
||
let pendingStartIndex = -1;
|
||
|
||
const flushCoT = () => {
|
||
if (pendingCoT.length === 0) return;
|
||
groups.push({
|
||
type: "cot",
|
||
parts: pendingCoT,
|
||
startIndex: pendingStartIndex,
|
||
});
|
||
pendingCoT = [];
|
||
pendingStartIndex = -1;
|
||
};
|
||
|
||
content.forEach((part, index) => {
|
||
if (part.type === "reasoning" || part.type === "tool-call") {
|
||
if (pendingCoT.length === 0) pendingStartIndex = index;
|
||
pendingCoT.push(part);
|
||
return;
|
||
}
|
||
flushCoT();
|
||
if (part.type === "text") {
|
||
groups.push({ type: "text", part, index });
|
||
}
|
||
});
|
||
flushCoT();
|
||
|
||
return groups;
|
||
}
|
||
|
||
const IssueChatAssistantParts = memo(function IssueChatAssistantParts({
|
||
message,
|
||
hasCoT,
|
||
}: {
|
||
message: ThreadMessage;
|
||
hasCoT: boolean;
|
||
}) {
|
||
const groupedParts = useMemo(
|
||
() => groupAssistantParts(message.content),
|
||
[message.content],
|
||
);
|
||
return (
|
||
<>
|
||
{groupedParts.map((group) => {
|
||
if (group.type === "text") {
|
||
return (
|
||
<IssueChatTextPart
|
||
key={`${message.id}:text:${group.index}`}
|
||
text={group.part.text}
|
||
recessed={hasCoT}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<IssueChatChainOfThought
|
||
key={`${message.id}:cot:${group.startIndex}`}
|
||
message={message}
|
||
cotParts={group.parts}
|
||
/>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
});
|
||
|
||
function IssueChatUserMessage({
|
||
message,
|
||
isInterruptingQueuedRun,
|
||
}: {
|
||
message: ThreadMessage;
|
||
isInterruptingQueuedRun: boolean;
|
||
}) {
|
||
const {
|
||
onInterruptQueued,
|
||
onCancelQueued,
|
||
onDeleteComment,
|
||
currentUserId,
|
||
userProfileMap,
|
||
} = useContext(IssueChatCtx);
|
||
const custom = message.metadata.custom as Record<string, unknown>;
|
||
const anchorId =
|
||
typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||
const commentId =
|
||
typeof custom.commentId === "string" ? custom.commentId : message.id;
|
||
const authorName =
|
||
typeof custom.authorName === "string" ? custom.authorName : null;
|
||
const authorUserId =
|
||
typeof custom.authorUserId === "string" ? custom.authorUserId : null;
|
||
const queued =
|
||
custom.queueState === "queued" || custom.clientStatus === "queued";
|
||
const sourceTrust = isSourceTrustMetadata(custom.sourceTrust)
|
||
? custom.sourceTrust
|
||
: null;
|
||
const followUpRequested = custom.followUpRequested === true;
|
||
const sentFromIMessage = isIssueCommentMetadata(custom.commentMetadata) &&
|
||
custom.commentMetadata.sourceChannel === "imessage-photon";
|
||
const queueReason =
|
||
typeof custom.queueReason === "string" ? custom.queueReason : null;
|
||
const queueBadgeLabel =
|
||
queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued";
|
||
const pending = custom.clientStatus === "pending";
|
||
const deleted = Boolean(custom.deletedAt);
|
||
const queueTargetRunId =
|
||
typeof custom.queueTargetRunId === "string"
|
||
? custom.queueTargetRunId
|
||
: null;
|
||
const [copied, setCopied] = useState(false);
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const toastActions = useOptionalToastActions();
|
||
const {
|
||
isCurrentUser,
|
||
authorName: resolvedAuthorName,
|
||
avatarUrl,
|
||
} = resolveIssueChatHumanAuthor({
|
||
authorName,
|
||
authorUserId,
|
||
currentUserId,
|
||
userProfileMap,
|
||
});
|
||
const authorAvatar = (
|
||
<Avatar size="sm" className="shrink-0">
|
||
{avatarUrl ? (
|
||
<AvatarImage src={avatarUrl} alt={resolvedAuthorName} />
|
||
) : null}
|
||
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
|
||
</Avatar>
|
||
);
|
||
const canDeleteComment = Boolean(
|
||
onDeleteComment && isCurrentUser && !queued && !pending && !deleted,
|
||
);
|
||
const handleDeleteComment = () => {
|
||
if (!canDeleteComment) return;
|
||
setDeleteDialogOpen(true);
|
||
};
|
||
const confirmDeleteComment = () => {
|
||
if (!canDeleteComment) return;
|
||
setDeleteDialogOpen(false);
|
||
void onDeleteComment?.(commentId);
|
||
};
|
||
const messageBody = (
|
||
<div
|
||
className={cn(
|
||
"flex min-w-0 max-w-(--pct-85) flex-col",
|
||
isCurrentUser && "items-end",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"mb-1 flex items-center gap-2 px-1",
|
||
isCurrentUser ? "justify-end" : "justify-start",
|
||
)}
|
||
>
|
||
<span className="text-sm font-medium text-foreground">
|
||
{resolvedAuthorName}
|
||
</span>
|
||
<SourceTrustBadge sourceTrust={sourceTrust} artifactLabel="comment" />
|
||
{followUpRequested ? (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow)"
|
||
>
|
||
Follow-up
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5",
|
||
// Tail-hugging corner: flatten the bottom corner nearest the avatar so
|
||
// the bubble points at it (bottom-right for the right-aligned human).
|
||
isCurrentUser ? "rounded-br-(--rad-4)" : "rounded-bl-(--rad-4)",
|
||
queued
|
||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||
: deleted
|
||
? "bg-muted/50 text-muted-foreground"
|
||
: isCurrentUser
|
||
? // Liveness blue (--liveness-blue, decoupled from --status-task-in_progress
|
||
// in DECISION-SHEET.md A6) for the human's own messages (PAP-95 rev 5).
|
||
"bg-(--liveness-blue) text-white"
|
||
: "bg-muted",
|
||
pending && "opacity-80",
|
||
)}
|
||
>
|
||
{queued ? (
|
||
<div className="mb-1.5 flex items-center gap-2">
|
||
<Badge
|
||
variant="outline"
|
||
className="border-amber-400/60 bg-amber-100/70 text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200"
|
||
>
|
||
{queueBadgeLabel}
|
||
</Badge>
|
||
{onInterruptQueued ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 border-red-300 px-2 text-(length:--text-micro) text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||
disabled={isInterruptingQueuedRun}
|
||
onClick={() => void onInterruptQueued(queueTargetRunId)}
|
||
>
|
||
{isInterruptingQueuedRun ? "Interrupting..." : "Interrupt"}
|
||
</Button>
|
||
) : null}
|
||
{onCancelQueued ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 border-amber-300 px-2 text-(length:--text-micro) text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
|
||
onClick={() => onCancelQueued(commentId)}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{deleted ? (
|
||
<div className="text-sm italic text-muted-foreground">
|
||
Comment deleted
|
||
</div>
|
||
) : (
|
||
<div className="min-w-0 max-w-full space-y-3">
|
||
<IssueChatTextParts
|
||
message={message}
|
||
onAccent={isCurrentUser && !queued}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{sentFromIMessage && !deleted ? (
|
||
<div className="mt-1 px-1 text-xs text-muted-foreground">
|
||
Sent from iMessage
|
||
</div>
|
||
) : null}
|
||
{pending ? (
|
||
<div
|
||
className={cn(
|
||
"mt-1 flex px-1 text-(length:--text-micro) text-muted-foreground",
|
||
isCurrentUser ? "justify-end" : "justify-start",
|
||
)}
|
||
>
|
||
Sending...
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={cn(
|
||
"mt-1 flex items-center gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100",
|
||
isCurrentUser ? "justify-end" : "justify-start",
|
||
)}
|
||
>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<a
|
||
href={anchorId ? `#${anchorId}` : undefined}
|
||
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground hover:underline"
|
||
>
|
||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||
</a>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">
|
||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
{!deleted ? (
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||
title="Copy message"
|
||
aria-label="Copy message"
|
||
onClick={() => {
|
||
const text = message.content
|
||
.filter(
|
||
(p): p is { type: "text"; text: string } =>
|
||
p.type === "text",
|
||
)
|
||
.map((p) => p.text)
|
||
.join("\n\n");
|
||
void copyTextToClipboard(text)
|
||
.then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
})
|
||
.catch((error) => {
|
||
toastActions?.pushToast({
|
||
title: "Copy failed",
|
||
body:
|
||
error instanceof Error
|
||
? error.message
|
||
: "Unable to copy message",
|
||
tone: "error",
|
||
});
|
||
});
|
||
}}
|
||
>
|
||
{copied ? (
|
||
<Check className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Copy className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
) : null}
|
||
{canDeleteComment ? (
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-destructive"
|
||
title="Delete comment"
|
||
aria-label="Delete comment"
|
||
onClick={handleDeleteComment}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<div id={anchorId}>
|
||
<div
|
||
className={cn(
|
||
"group flex items-end gap-2",
|
||
isCurrentUser && "justify-end",
|
||
)}
|
||
>
|
||
{isCurrentUser ? (
|
||
<>
|
||
{messageBody}
|
||
{authorAvatar}
|
||
</>
|
||
) : (
|
||
<>
|
||
{authorAvatar}
|
||
{messageBody}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Delete comment?</DialogTitle>
|
||
<DialogDescription>
|
||
This will replace the comment with a deleted-comment marker.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setDeleteDialogOpen(false)}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button variant="destructive" onClick={confirmDeleteComment}>
|
||
Delete comment
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function IssueChatAssistantMessage({
|
||
message,
|
||
activeVote,
|
||
isRunActive,
|
||
isStoppingRun,
|
||
}: {
|
||
message: ThreadMessage;
|
||
activeVote: FeedbackVoteValue | null;
|
||
isRunActive: boolean;
|
||
isStoppingRun: boolean;
|
||
}) {
|
||
const {
|
||
feedbackDataSharingPreference,
|
||
feedbackTermsUrl,
|
||
onVote,
|
||
agentMap,
|
||
onStopRun,
|
||
stopRunLabel = "Stop run",
|
||
stoppingRunLabel = "Stopping...",
|
||
stopRunVariant = "stop",
|
||
runFinalizationActions = [],
|
||
userLabelMap,
|
||
issueAssigneeAgentId,
|
||
} = useContext(IssueChatCtx);
|
||
const custom = message.metadata.custom as Record<string, unknown>;
|
||
const anchorId =
|
||
typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||
const authorName =
|
||
typeof custom.authorName === "string"
|
||
? custom.authorName
|
||
: typeof custom.runAgentName === "string"
|
||
? custom.runAgentName
|
||
: "Agent";
|
||
const authorAgentId =
|
||
typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
|
||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||
const runAgentId =
|
||
typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||
const runStatus =
|
||
typeof custom.runStatus === "string" ? custom.runStatus : null;
|
||
const agentId = authorAgentId ?? runAgentId;
|
||
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
||
const commentId =
|
||
typeof custom.commentId === "string" ? custom.commentId : null;
|
||
const sourceTrust = isSourceTrustMetadata(custom.sourceTrust)
|
||
? custom.sourceTrust
|
||
: null;
|
||
const attribution = resolveCommentAttribution({
|
||
authorAgentId,
|
||
onBehalfOfUserId:
|
||
typeof custom.onBehalfOfUserId === "string"
|
||
? custom.onBehalfOfUserId
|
||
: null,
|
||
issueAssigneeAgentId,
|
||
resolveUserLabel: (userId) => userLabelMap?.get(userId),
|
||
});
|
||
const notices = Array.isArray(custom.notices)
|
||
? custom.notices.filter(
|
||
(notice): notice is string =>
|
||
typeof notice === "string" && notice.length > 0,
|
||
)
|
||
: [];
|
||
const waitingText =
|
||
typeof custom.waitingText === "string" ? custom.waitingText : "";
|
||
const isRunning =
|
||
message.role === "assistant" && message.status?.type === "running";
|
||
const runHref =
|
||
runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
||
const canStopRun =
|
||
Boolean(runId) &&
|
||
(isRunActive || runStatus === "queued" || runStatus === "running");
|
||
const chainOfThoughtLabel =
|
||
typeof custom.chainOfThoughtLabel === "string"
|
||
? custom.chainOfThoughtLabel
|
||
: null;
|
||
const hasCoT = message.content.some(
|
||
(p) => p.type === "reasoning" || p.type === "tool-call",
|
||
);
|
||
const deleted = Boolean(custom.deletedAt);
|
||
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||
const [folded, setFolded] = useState(isFoldable);
|
||
const [prevFoldKey, setPrevFoldKey] = useState({
|
||
messageId: message.id,
|
||
isFoldable,
|
||
});
|
||
const [copied, setCopied] = useState(false);
|
||
const toastActions = useOptionalToastActions();
|
||
const copyText = deleted ? "" : getThreadMessageCopyText(message);
|
||
|
||
// Derive fold state synchronously during render (not in useEffect) so the
|
||
// browser never paints the un-folded intermediate state — prevents the
|
||
// visible "jump" when loading a page with already-folded work sections.
|
||
if (
|
||
message.id !== prevFoldKey.messageId ||
|
||
isFoldable !== prevFoldKey.isFoldable
|
||
) {
|
||
const nextFolded = resolveAssistantMessageFoldedState({
|
||
messageId: message.id,
|
||
currentFolded: folded,
|
||
isFoldable,
|
||
previousMessageId: prevFoldKey.messageId,
|
||
previousIsFoldable: prevFoldKey.isFoldable,
|
||
});
|
||
setPrevFoldKey({ messageId: message.id, isFoldable });
|
||
if (nextFolded !== folded) {
|
||
setFolded(nextFolded);
|
||
}
|
||
}
|
||
|
||
const handleVote = async (
|
||
vote: FeedbackVoteValue,
|
||
options?: { allowSharing?: boolean; reason?: string },
|
||
) => {
|
||
if (!commentId || !onVote) return;
|
||
await onVote(commentId, vote, options);
|
||
};
|
||
|
||
const followUpRequested = custom.followUpRequested === true;
|
||
|
||
const kind = typeof custom.kind === "string" ? custom.kind : null;
|
||
const hasCommentText = message.content.some(
|
||
(part) =>
|
||
part.type === "text" &&
|
||
typeof part.text === "string" &&
|
||
part.text.trim().length > 0,
|
||
);
|
||
// A genuine posted agent comment (kind "comment" with real text) renders in a
|
||
// left-aligned neutral bubble — the mirror of the human blue bubble. Run
|
||
// activity (chain-of-thought, tool calls, waiting shimmer, "worked N min")
|
||
// keeps the existing flat / metadata treatment (PAP-95 rev 6).
|
||
const isGenuineComment =
|
||
kind === "comment" &&
|
||
!!commentId &&
|
||
!isRunning &&
|
||
(hasCommentText || deleted);
|
||
|
||
const agentAvatar = <AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId, name: authorName } : { name: authorName }} size={32} />;
|
||
|
||
const messageActionBar = (
|
||
<div className="mt-2 flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||
title="Copy message"
|
||
aria-label="Copy message"
|
||
onClick={() => {
|
||
void copyTextToClipboard(copyText)
|
||
.then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
})
|
||
.catch((error) => {
|
||
toastActions?.pushToast({
|
||
title: "Copy failed",
|
||
body:
|
||
error instanceof Error
|
||
? error.message
|
||
: "Unable to copy message",
|
||
tone: "error",
|
||
});
|
||
});
|
||
}}
|
||
>
|
||
{copied ? (
|
||
<Check className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Copy className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
{commentId && onVote ? (
|
||
<IssueChatFeedbackButtons
|
||
activeVote={activeVote}
|
||
sharingPreference={feedbackDataSharingPreference}
|
||
termsUrl={feedbackTermsUrl ?? null}
|
||
onVote={handleVote}
|
||
/>
|
||
) : null}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<a
|
||
href={anchorId ? `#${anchorId}` : undefined}
|
||
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground hover:underline"
|
||
>
|
||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||
</a>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">
|
||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-xs"
|
||
className="text-muted-foreground hover:text-foreground"
|
||
title="More actions"
|
||
aria-label="More actions"
|
||
>
|
||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{custom.origin === "fast_response" && <div className="max-w-xs px-2 py-2 text-xs text-muted-foreground">Platform-generated acknowledgement. The agent’s substantive response follows separately.</div>}
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
void copyTextToClipboard(copyText).catch((error) => {
|
||
toastActions?.pushToast({
|
||
title: "Copy failed",
|
||
body:
|
||
error instanceof Error
|
||
? error.message
|
||
: "Unable to copy message",
|
||
tone: "error",
|
||
});
|
||
});
|
||
}}
|
||
>
|
||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||
Copy message
|
||
</DropdownMenuItem>
|
||
{canStopRun && onStopRun && runId ? (
|
||
<DropdownMenuItem
|
||
disabled={isStoppingRun}
|
||
className={cn(
|
||
stopRunVariant === "pause"
|
||
? "text-amber-700 focus:text-amber-800 dark:text-amber-300 dark:focus:text-amber-200"
|
||
: "text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200",
|
||
)}
|
||
onSelect={() => {
|
||
void onStopRun(runId);
|
||
}}
|
||
>
|
||
{stopRunVariant === "pause" ? (
|
||
<PauseCircle className="mr-2 h-3.5 w-3.5" />
|
||
) : (
|
||
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
|
||
)}
|
||
{isStoppingRun ? stoppingRunLabel : stopRunLabel}
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
{runHref ? (
|
||
<DropdownMenuItem asChild>
|
||
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
||
<Search className="mr-2 h-3.5 w-3.5" />
|
||
View run
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
);
|
||
|
||
// Genuine agent comment → neutral left-aligned bubble (mirror of the human
|
||
// blue bubble in IssueChatUserMessage). See PAP-95 rev 6.
|
||
if (isGenuineComment) {
|
||
return (
|
||
<div id={anchorId}>
|
||
<div className="group flex flex-col items-start py-1.5">
|
||
{/* Icon + name together in a header ABOVE the bubble (PAP-95 rev 7). */}
|
||
<div className="mb-1 flex items-center gap-1.5 px-1">
|
||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||
{agentId ? (
|
||
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
|
||
) : (
|
||
<Avatar size="sm" className="size-5">
|
||
<AvatarFallback className="text-(length:--text-nano)">
|
||
{initialsForName(authorName)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
)}
|
||
</span>
|
||
<span className="text-sm font-medium text-foreground">
|
||
{authorName}
|
||
</span>
|
||
{/* Reads as "Fable · for Dotta" beside the author name (the open cross-task write design (attribution)). */}
|
||
{attribution ? (
|
||
<CommentAttributionChip
|
||
agentName={authorName}
|
||
userName={attribution.userName}
|
||
/>
|
||
) : null}
|
||
<SourceTrustBadge
|
||
sourceTrust={sourceTrust}
|
||
artifactLabel="comment"
|
||
/>
|
||
{followUpRequested ? (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow)"
|
||
>
|
||
Follow-up
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
{/* Canonical conference-room agent bubble (BoardChat.tsx:712). */}
|
||
<div
|
||
className={cn(
|
||
"min-w-0 break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible [border-radius:14px_14px_14px_4px]",
|
||
AGENT_COMMENT_BUBBLE_WIDTH_CLASS,
|
||
deleted
|
||
? "border border-border bg-muted/50 text-muted-foreground"
|
||
: "border border-border bg-card text-foreground",
|
||
)}
|
||
>
|
||
{deleted ? (
|
||
<div className="text-sm italic text-muted-foreground">
|
||
Comment deleted
|
||
</div>
|
||
) : (
|
||
<div className="min-w-0 max-w-full space-y-3">
|
||
<IssueChatAssistantParts message={message} hasCoT={false} />
|
||
{notices.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{notices.map((notice, index) => (
|
||
<div
|
||
key={`${message.id}:notice:${index}`}
|
||
className="rounded-sm border border-border/60 bg-accent/20 px-3 py-2 text-sm text-muted-foreground"
|
||
>
|
||
{notice}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!deleted ? messageActionBar : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div id={anchorId}>
|
||
<div className="flex items-start gap-2.5 py-1.5">
|
||
{agentAvatar}
|
||
|
||
<div className="min-w-0 flex-1">
|
||
{isFoldable ? (
|
||
<button
|
||
type="button"
|
||
className="group flex w-full items-center gap-2 py-0.5 text-left"
|
||
onClick={() => setFolded((v) => !v)}
|
||
>
|
||
<span className="text-sm font-medium text-foreground">
|
||
{authorName}
|
||
</span>
|
||
<SourceTrustBadge
|
||
sourceTrust={sourceTrust}
|
||
artifactLabel="comment"
|
||
/>
|
||
<span className="text-xs text-muted-foreground/60">
|
||
{chainOfThoughtLabel?.toLowerCase()}
|
||
</span>
|
||
<ChevronDown
|
||
aria-hidden="true"
|
||
className={cn(
|
||
"h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-[opacity,transform] group-hover:opacity-100 group-focus-visible:opacity-100",
|
||
!folded && "rotate-180",
|
||
)}
|
||
/>
|
||
{message.createdAt ? (
|
||
<span className="ml-auto text-(length:--text-micro) text-muted-foreground/50">
|
||
{commentDateLabel(message.createdAt)}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
) : (
|
||
<div className="mb-1.5 flex items-center gap-2">
|
||
<span className="text-sm font-medium text-foreground">
|
||
{authorName}
|
||
</span>
|
||
<SourceTrustBadge
|
||
sourceTrust={sourceTrust}
|
||
artifactLabel="comment"
|
||
/>
|
||
{followUpRequested ? (
|
||
<Badge
|
||
variant="outline"
|
||
className="text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow)"
|
||
>
|
||
Follow-up
|
||
</Badge>
|
||
) : null}
|
||
{isRunning ? (
|
||
// Running chip shares the liveness-blue badge recipe with the
|
||
// issue header's "Live" badge (one live/running blue).
|
||
<Badge
|
||
variant="outline"
|
||
className={cn(
|
||
"text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow)",
|
||
liveBlueBadge,
|
||
)}
|
||
>
|
||
<Loader2 className="h-3 w-3 animate-spin" />
|
||
Running
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
|
||
{deleted ? (
|
||
<div className="rounded-sm bg-muted/40 px-3 py-2 text-sm italic text-muted-foreground">
|
||
Comment deleted
|
||
</div>
|
||
) : !folded ? (
|
||
<>
|
||
<div className="space-y-3">
|
||
<IssueChatAssistantParts message={message} hasCoT={hasCoT} />
|
||
{message.content.length === 0 && waitingText ? (
|
||
<div className="rounded-lg px-1 py-2">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||
{agentId ? (
|
||
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
|
||
) : (
|
||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||
)}
|
||
<span className="shimmer-text">{waitingText}</span>
|
||
</span>
|
||
</div>
|
||
<IssueChatLiveRunStatusLine
|
||
custom={custom}
|
||
active={isRunning}
|
||
className="pl-6"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{notices.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{notices.map((notice, index) => (
|
||
<div
|
||
key={`${message.id}:notice:${index}`}
|
||
className="rounded-sm border border-border/60 bg-accent/20 px-3 py-2 text-sm text-muted-foreground"
|
||
>
|
||
{notice}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{messageActionBar}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IssueChatFeedbackButtons({
|
||
activeVote,
|
||
sharingPreference = "prompt",
|
||
termsUrl,
|
||
onVote,
|
||
}: {
|
||
activeVote: FeedbackVoteValue | null;
|
||
sharingPreference: FeedbackDataSharingPreference;
|
||
termsUrl: string | null;
|
||
onVote: (
|
||
vote: FeedbackVoteValue,
|
||
options?: { allowSharing?: boolean; reason?: string },
|
||
) => Promise<void>;
|
||
}) {
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [optimisticVote, setOptimisticVote] =
|
||
useState<FeedbackVoteValue | null>(null);
|
||
const [reasonOpen, setReasonOpen] = useState(false);
|
||
const [downvoteReason, setDownvoteReason] = useState("");
|
||
const [pendingSharingDialog, setPendingSharingDialog] = useState<{
|
||
vote: FeedbackVoteValue;
|
||
reason?: string;
|
||
} | null>(null);
|
||
const visibleVote = optimisticVote ?? activeVote ?? null;
|
||
|
||
useEffect(() => {
|
||
if (optimisticVote && activeVote === optimisticVote)
|
||
setOptimisticVote(null);
|
||
}, [activeVote, optimisticVote]);
|
||
|
||
async function doVote(
|
||
vote: FeedbackVoteValue,
|
||
options?: { allowSharing?: boolean; reason?: string },
|
||
) {
|
||
setIsSaving(true);
|
||
try {
|
||
await onVote(vote, options);
|
||
} catch {
|
||
setOptimisticVote(null);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
function handleVote(vote: FeedbackVoteValue, reason?: string) {
|
||
setOptimisticVote(vote);
|
||
if (sharingPreference === "prompt") {
|
||
setPendingSharingDialog({ vote, ...(reason ? { reason } : {}) });
|
||
return;
|
||
}
|
||
const allowSharing = sharingPreference === "allowed";
|
||
void doVote(vote, {
|
||
...(allowSharing ? { allowSharing: true } : {}),
|
||
...(reason ? { reason } : {}),
|
||
});
|
||
}
|
||
|
||
function handleThumbsUp() {
|
||
handleVote("up");
|
||
}
|
||
|
||
function handleThumbsDown() {
|
||
setOptimisticVote("down");
|
||
setReasonOpen(true);
|
||
// Submit the initial down vote right away
|
||
handleVote("down");
|
||
}
|
||
|
||
function handleSubmitReason() {
|
||
if (!downvoteReason.trim()) return;
|
||
// Re-submit with reason attached
|
||
if (sharingPreference === "prompt") {
|
||
setPendingSharingDialog({ vote: "down", reason: downvoteReason });
|
||
} else {
|
||
const allowSharing = sharingPreference === "allowed";
|
||
void doVote("down", {
|
||
...(allowSharing ? { allowSharing: true } : {}),
|
||
reason: downvoteReason,
|
||
});
|
||
}
|
||
setReasonOpen(false);
|
||
setDownvoteReason("");
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
disabled={isSaving}
|
||
className={cn(
|
||
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors",
|
||
visibleVote === "up"
|
||
? "text-green-600 dark:text-green-400"
|
||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||
)}
|
||
title="Helpful"
|
||
aria-label="Helpful"
|
||
onClick={handleThumbsUp}
|
||
>
|
||
<ThumbsUp className="h-3.5 w-3.5" />
|
||
</button>
|
||
<Popover open={reasonOpen} onOpenChange={setReasonOpen}>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
disabled={isSaving}
|
||
className={cn(
|
||
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors",
|
||
visibleVote === "down"
|
||
? "text-amber-600 dark:text-amber-400"
|
||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||
)}
|
||
title="Needs work"
|
||
aria-label="Needs work"
|
||
onClick={handleThumbsDown}
|
||
>
|
||
<ThumbsDown className="h-3.5 w-3.5" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent side="top" align="start" className="w-80 p-3">
|
||
<div className="mb-2 text-sm font-medium">
|
||
What could have been better?
|
||
</div>
|
||
<Textarea
|
||
value={downvoteReason}
|
||
onChange={(event) => setDownvoteReason(event.target.value)}
|
||
placeholder="Add a short note"
|
||
className="min-h-20 resize-y bg-background text-sm"
|
||
disabled={isSaving}
|
||
/>
|
||
<div className="mt-2 flex items-center justify-end gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={isSaving}
|
||
onClick={() => {
|
||
setReasonOpen(false);
|
||
setDownvoteReason("");
|
||
}}
|
||
>
|
||
Dismiss
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
disabled={isSaving || !downvoteReason.trim()}
|
||
onClick={handleSubmitReason}
|
||
>
|
||
{isSaving ? "Saving..." : "Save note"}
|
||
</Button>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
<Dialog
|
||
open={Boolean(pendingSharingDialog)}
|
||
onOpenChange={(open) => {
|
||
if (!open && !isSaving) {
|
||
setPendingSharingDialog(null);
|
||
setOptimisticVote(null);
|
||
}
|
||
}}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Save your feedback sharing preference</DialogTitle>
|
||
<DialogDescription>
|
||
Choose whether voted AI outputs can be shared with Paperclip Labs.
|
||
This answer becomes the default for future thumbs up and thumbs
|
||
down votes.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3 text-sm text-muted-foreground">
|
||
<p>This vote is always saved locally.</p>
|
||
<p>
|
||
Choose{" "}
|
||
<span className="font-medium text-foreground">Always allow</span>{" "}
|
||
to share this vote and future voted AI outputs. Choose{" "}
|
||
<span className="font-medium text-foreground">Don't allow</span>{" "}
|
||
to keep this vote and future votes local.
|
||
</p>
|
||
<p>You can change this later in Settings > General.</p>
|
||
{termsUrl ? (
|
||
<a
|
||
href={termsUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex text-sm text-foreground underline underline-offset-4"
|
||
>
|
||
Read our terms of service
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={!pendingSharingDialog || isSaving}
|
||
onClick={() => {
|
||
if (!pendingSharingDialog) return;
|
||
void doVote(
|
||
pendingSharingDialog.vote,
|
||
pendingSharingDialog.reason
|
||
? { reason: pendingSharingDialog.reason }
|
||
: undefined,
|
||
).then(() => setPendingSharingDialog(null));
|
||
}}
|
||
>
|
||
{isSaving ? "Saving..." : "Don't allow"}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={!pendingSharingDialog || isSaving}
|
||
onClick={() => {
|
||
if (!pendingSharingDialog) return;
|
||
void doVote(pendingSharingDialog.vote, {
|
||
allowSharing: true,
|
||
...(pendingSharingDialog.reason
|
||
? { reason: pendingSharingDialog.reason }
|
||
: {}),
|
||
}).then(() => setPendingSharingDialog(null));
|
||
}}
|
||
>
|
||
{isSaving ? "Saving..." : "Always allow"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ExpiredRequestConfirmationActivity({
|
||
message,
|
||
anchorId,
|
||
interaction,
|
||
}: {
|
||
message: ThreadMessage;
|
||
anchorId?: string;
|
||
interaction: RequestConfirmationInteraction;
|
||
}) {
|
||
const {
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
onAcceptInteraction,
|
||
onRejectInteraction,
|
||
onCancelInteraction,
|
||
onUploadImage,
|
||
externalReferences,
|
||
} = useContext(IssueChatCtx);
|
||
const [expanded, setExpanded] = useState(false);
|
||
const hasResolvedActor = Boolean(
|
||
interaction.resolvedByAgentId || interaction.resolvedByUserId,
|
||
);
|
||
const actorAgentId = hasResolvedActor
|
||
? (interaction.resolvedByAgentId ?? null)
|
||
: (interaction.createdByAgentId ?? null);
|
||
const actorUserId = hasResolvedActor
|
||
? (interaction.resolvedByUserId ?? null)
|
||
: (interaction.createdByUserId ?? null);
|
||
const actorName = formatInteractionActorLabel({
|
||
agentId: actorAgentId,
|
||
userId: actorUserId,
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
});
|
||
const actorIcon = actorAgentId
|
||
? agentMap?.get(actorAgentId)?.icon
|
||
: undefined;
|
||
const isCurrentUser = Boolean(
|
||
actorUserId && currentUserId && actorUserId === currentUserId,
|
||
);
|
||
const detailsId = anchorId
|
||
? `${anchorId}-details`
|
||
: `${interaction.id}-details`;
|
||
const summary = buildIssueThreadInteractionSummary(interaction);
|
||
|
||
const rowContent = (
|
||
<div className="min-w-0 flex-1">
|
||
<div
|
||
className={cn(
|
||
"flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs",
|
||
isCurrentUser && "justify-end",
|
||
)}
|
||
>
|
||
<span className="font-medium text-foreground">{actorName}</span>
|
||
<span className="text-muted-foreground">updated this task</span>
|
||
<a
|
||
href={anchorId ? `#${anchorId}` : undefined}
|
||
className="text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||
>
|
||
{timeAgo(message.createdAt)}
|
||
</a>
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1 rounded-md border border-border/70 bg-background/70 px-1.5 py-0.5 text-(length:--text-micro) font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||
aria-expanded={expanded}
|
||
aria-controls={detailsId}
|
||
onClick={() => setExpanded((current) => !current)}
|
||
>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-3 w-3 transition-transform",
|
||
expanded && "rotate-180",
|
||
)}
|
||
/>
|
||
{expanded ? "Hide confirmation" : "Expired confirmation"}
|
||
</button>
|
||
</div>
|
||
{expanded ? (
|
||
<p
|
||
className={cn(
|
||
"mt-1 text-xs text-muted-foreground",
|
||
isCurrentUser && "text-right",
|
||
)}
|
||
>
|
||
{summary}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div id={anchorId}>
|
||
{isCurrentUser ? (
|
||
<div className="flex items-start justify-end gap-2 py-1">
|
||
{rowContent}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-start gap-2.5 py-1">
|
||
{actorAgentId ? (
|
||
<AgentAvatar agent={agentMap?.get(actorAgentId) ?? { id: actorAgentId, name: actorName }} size={32} />
|
||
) : (
|
||
<Avatar size="sm" className="mt-0.5"><AvatarFallback>{initialsForName(actorName)}</AvatarFallback></Avatar>
|
||
)}
|
||
{rowContent}
|
||
</div>
|
||
)}
|
||
{expanded ? (
|
||
<div id={detailsId} className="mt-2">
|
||
<IssueThreadInteractionCard
|
||
interaction={interaction}
|
||
agentMap={agentMap}
|
||
currentUserId={currentUserId}
|
||
userLabelMap={userLabelMap}
|
||
onAcceptInteraction={onAcceptInteraction}
|
||
onRejectInteraction={onRejectInteraction}
|
||
onCancelInteraction={onCancelInteraction}
|
||
onUploadImage={onUploadImage}
|
||
externalReferences={externalReferences}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function isIssueCommentPresentation(
|
||
value: unknown,
|
||
): value is IssueCommentPresentation {
|
||
if (!value || typeof value !== "object") return false;
|
||
const v = value as Record<string, unknown>;
|
||
return v.kind === "system_notice" || v.kind === "message";
|
||
}
|
||
|
||
function isIssueCommentMetadata(value: unknown): value is IssueCommentMetadata {
|
||
if (!value || typeof value !== "object") return false;
|
||
const v = value as Record<string, unknown>;
|
||
return v.version === 1 && Array.isArray(v.sections);
|
||
}
|
||
|
||
function isSourceTrustMetadata(value: unknown): value is SourceTrustMetadata {
|
||
if (!value || typeof value !== "object") return false;
|
||
const v = value as Record<string, unknown>;
|
||
return (
|
||
v.preset === "low_trust_review" &&
|
||
(v.disposition === "quarantined" || v.disposition === "promoted")
|
||
);
|
||
}
|
||
|
||
function issueStatusIsTerminalDisposition(issueStatus: string | undefined) {
|
||
return issueStatus === "done" || issueStatus === "cancelled";
|
||
}
|
||
|
||
function sourceRunIdFromSuccessfulRunHandoffMetadata(
|
||
metadata: IssueCommentMetadata | null,
|
||
) {
|
||
if (metadata?.sourceRunId) return metadata.sourceRunId;
|
||
const runLinks = [];
|
||
for (const section of metadata?.sections ?? []) {
|
||
for (const row of section.rows) {
|
||
if (row.type === "run_link") runLinks.push(row.runId);
|
||
}
|
||
}
|
||
return runLinks.length === 1 ? runLinks[0] : null;
|
||
}
|
||
|
||
function isStaleSuccessfulRunHandoffNotice(input: {
|
||
bodyText: string;
|
||
issueStatus?: string;
|
||
successfulRunHandoff?: SuccessfulRunHandoffState | null;
|
||
runId?: string | null;
|
||
metadata: IssueCommentMetadata | null;
|
||
}) {
|
||
if (!isSuccessfulRunHandoffComment(input.bodyText)) return false;
|
||
|
||
const currentHandoff = input.successfulRunHandoff ?? null;
|
||
if (currentHandoff?.state === "resolved") return true;
|
||
if (issueStatusIsTerminalDisposition(input.issueStatus)) return true;
|
||
// A live continuation (running/queued run or queued wake) means an agent is
|
||
// already handling the issue — fold the warning until the issue is actually
|
||
// stuck again.
|
||
if (currentHandoff?.hasLiveContinuation) return true;
|
||
|
||
const noticeSourceRunId =
|
||
sourceRunIdFromSuccessfulRunHandoffMetadata(input.metadata) ??
|
||
input.runId ??
|
||
null;
|
||
if (
|
||
noticeSourceRunId &&
|
||
currentHandoff?.sourceRunId &&
|
||
noticeSourceRunId !== currentHandoff.sourceRunId
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function StaleDispositionWarningMetadataRow({
|
||
row,
|
||
}: {
|
||
row: SystemNoticeMetadataRow;
|
||
}) {
|
||
const label = (
|
||
<span className="text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
|
||
{row.label}
|
||
</span>
|
||
);
|
||
const value = (() => {
|
||
switch (row.kind) {
|
||
case "text":
|
||
return <span>{row.value}</span>;
|
||
case "code":
|
||
return (
|
||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-(length:--text-micro) text-foreground/80">
|
||
{row.value}
|
||
</code>
|
||
);
|
||
case "issue": {
|
||
const content = (
|
||
<>
|
||
<span>{row.identifier}</span>
|
||
{row.title ? (
|
||
<span className="text-muted-foreground"> - {row.title}</span>
|
||
) : null}
|
||
</>
|
||
);
|
||
return row.href ? (
|
||
<a
|
||
href={row.href}
|
||
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||
>
|
||
{content}
|
||
</a>
|
||
) : (
|
||
<span className="font-medium text-foreground">{content}</span>
|
||
);
|
||
}
|
||
case "agent":
|
||
return row.href ? (
|
||
<a
|
||
href={row.href}
|
||
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||
>
|
||
{row.name}
|
||
</a>
|
||
) : (
|
||
<span className="font-medium text-foreground">{row.name}</span>
|
||
);
|
||
case "run": {
|
||
const runShort =
|
||
row.runId.length > 12 ? `${row.runId.slice(0, 8)}...` : row.runId;
|
||
const content = (
|
||
<>
|
||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-(length:--text-micro) text-foreground/80">
|
||
{runShort}
|
||
</code>
|
||
{row.status ? <span>{row.status}</span> : null}
|
||
</>
|
||
);
|
||
return row.href ? (
|
||
<a
|
||
href={row.href}
|
||
className="inline-flex items-center gap-1.5 underline-offset-2 hover:underline"
|
||
>
|
||
{content}
|
||
</a>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1.5">{content}</span>
|
||
);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return (
|
||
<div className="grid grid-cols-(--gtc-7) gap-2 text-xs leading-5">
|
||
{label}
|
||
<div className="min-w-0 break-words text-foreground/80">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function metadataRowKey(row: SystemNoticeMetadataRow) {
|
||
switch (row.kind) {
|
||
case "issue":
|
||
return `issue:${row.label}:${row.identifier}:${row.href ?? ""}:${row.title ?? ""}`;
|
||
case "agent":
|
||
return `agent:${row.label}:${row.name}:${row.href ?? ""}`;
|
||
case "run":
|
||
return `run:${row.label}:${row.runId}:${row.href ?? ""}:${row.status ?? ""}`;
|
||
default:
|
||
return `${row.kind}:${row.label}:${row.value}`;
|
||
}
|
||
}
|
||
|
||
function metadataSectionKey(section: SystemNoticeMetadataSection) {
|
||
return `${section.title ?? "details"}:${section.rows.map(metadataRowKey).join("|")}`;
|
||
}
|
||
|
||
function isNullableString(value: unknown): value is string | null {
|
||
return value === null || typeof value === "string";
|
||
}
|
||
|
||
function isTimelineWorkspace(value: unknown): value is IssueTimelineWorkspace {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||
const workspace = value as Record<string, unknown>;
|
||
return (
|
||
isNullableString(workspace.label) &&
|
||
isNullableString(workspace.projectWorkspaceId) &&
|
||
isNullableString(workspace.executionWorkspaceId) &&
|
||
isNullableString(workspace.mode)
|
||
);
|
||
}
|
||
|
||
function isTimelineWorkspaceChange(
|
||
value: unknown,
|
||
): value is NonNullable<IssueTimelineEvent["workspaceChange"]> {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||
const change = value as Record<string, unknown>;
|
||
return isTimelineWorkspace(change.from) && isTimelineWorkspace(change.to);
|
||
}
|
||
|
||
function StaleDispositionWarningDetails({
|
||
sections,
|
||
}: {
|
||
sections: SystemNoticeMetadataSection[];
|
||
}) {
|
||
if (sections.length === 0) {
|
||
return (
|
||
<div className="text-xs leading-5 text-muted-foreground">
|
||
No additional details.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3 text-left">
|
||
{sections.map((section) => (
|
||
<div key={metadataSectionKey(section)} className="space-y-1.5">
|
||
{section.title ? (
|
||
<div className="text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
|
||
{section.title}
|
||
</div>
|
||
) : null}
|
||
<div className="space-y-1">
|
||
{section.rows.map((row) => (
|
||
<StaleDispositionWarningMetadataRow
|
||
key={metadataRowKey(row)}
|
||
row={row}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StaleDispositionWarningRow({
|
||
anchorId,
|
||
message,
|
||
metadata,
|
||
runAgentId,
|
||
}: {
|
||
anchorId?: string;
|
||
message: ThreadMessage;
|
||
metadata: IssueCommentMetadata | null;
|
||
runAgentId?: string | null;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const detailsId = useId();
|
||
const sections = mapCommentMetadataToSystemNoticeSections(metadata, {
|
||
runAgentId,
|
||
});
|
||
|
||
return (
|
||
<div id={anchorId} data-testid="stale-disposition-warning">
|
||
<div className="flex items-start gap-2.5 py-1.5">
|
||
<span className="size-6 shrink-0" aria-hidden />
|
||
<div className="min-w-0 flex-1">
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
aria-controls={detailsId}
|
||
className="group flex w-full items-center gap-2 py-0.5 text-left"
|
||
onClick={() => setOpen((value) => !value)}
|
||
>
|
||
<span className="text-sm font-medium text-foreground/80">
|
||
Stale disposition warning
|
||
</span>
|
||
<span className="ml-auto flex items-center gap-1.5">
|
||
{message.createdAt ? (
|
||
<span
|
||
data-testid="stale-disposition-warning-time"
|
||
className="text-(length:--text-micro) text-muted-foreground/50"
|
||
>
|
||
{commentDateLabel(message.createdAt)}
|
||
</span>
|
||
) : null}
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-3.5 w-3.5 text-muted-foreground/40 transition-transform",
|
||
open && "rotate-180",
|
||
)}
|
||
/>
|
||
</span>
|
||
</button>
|
||
<div id={detailsId} hidden={!open} className="space-y-1 py-1">
|
||
<StaleDispositionWarningDetails sections={sections} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Tone-colored dot for the fully-collapsed compact notice row. Tone is never
|
||
// conveyed by color alone — the adjacent title text names the notice.
|
||
const COMPACT_TONE_DOT: Record<SystemNoticeTone, string> = {
|
||
neutral: "bg-muted-foreground/40",
|
||
info: "bg-sky-500 dark:bg-sky-400",
|
||
success: "bg-emerald-500 dark:bg-emerald-400",
|
||
warning: "bg-amber-500 dark:bg-amber-400",
|
||
danger: "bg-red-500 dark:bg-red-400",
|
||
};
|
||
|
||
// A system notice whose presentation opts into `density: "compact"` collapses to
|
||
// a single quiet row — tone dot + title (+ author) + timestamp + chevron.
|
||
// Expanding reveals the full SystemNotice card (body + details), so no
|
||
// information is lost. Generalized from the StaleDispositionWarningRow precedent.
|
||
function CompactSystemNoticeRow({
|
||
anchorId,
|
||
message,
|
||
tone,
|
||
title,
|
||
source,
|
||
noticeProps,
|
||
defaultOpen = false,
|
||
}: {
|
||
anchorId?: string;
|
||
message: ThreadMessage;
|
||
tone: SystemNoticeTone;
|
||
title: string;
|
||
source?: SystemNoticeProps["source"];
|
||
noticeProps: SystemNoticeProps;
|
||
defaultOpen?: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
const detailsId = useId();
|
||
|
||
return (
|
||
<div id={anchorId} data-testid="compact-system-notice" className="group">
|
||
<div className="flex items-start gap-2.5 py-1.5">
|
||
<span className="size-6 shrink-0" aria-hidden />
|
||
<div className="min-w-0 flex-1">
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
aria-controls={detailsId}
|
||
className="-mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-accent/5"
|
||
onClick={() => setOpen((value) => !value)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"size-1.5 shrink-0 rounded-full",
|
||
COMPACT_TONE_DOT[tone],
|
||
)}
|
||
aria-hidden
|
||
/>
|
||
<span className="truncate text-sm font-medium text-foreground/80">
|
||
{title}
|
||
</span>
|
||
{source ? (
|
||
<span className="truncate text-(length:--text-micro) text-muted-foreground">
|
||
· {source.label}
|
||
</span>
|
||
) : null}
|
||
{/* Trailing meta never shrinks — keeps the timestamp on one line so the
|
||
collapsed row stays a single quiet line on narrow / mobile widths. */}
|
||
<span className="ml-auto flex shrink-0 items-center gap-1.5">
|
||
{message.createdAt ? (
|
||
<span
|
||
data-testid="compact-system-notice-time"
|
||
className="whitespace-nowrap text-(length:--text-micro) text-muted-foreground/50"
|
||
>
|
||
{commentDateLabel(message.createdAt)}
|
||
</span>
|
||
) : null}
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-3.5 w-3.5 shrink-0 text-muted-foreground/40 transition-transform group-hover:text-muted-foreground/70",
|
||
open && "rotate-180",
|
||
)}
|
||
/>
|
||
</span>
|
||
</button>
|
||
<div id={detailsId} hidden={!open} className="py-1">
|
||
<SystemNotice {...noticeProps} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SystemNoticeCommentRow(props: { message: ThreadMessage; anchorId?: string }) {
|
||
const custom = props.message.metadata.custom as Record<string, unknown>;
|
||
const email = useEmailComment(typeof custom.commentId === "string" ? custom.commentId : props.message.id);
|
||
return email ?? <SystemNoticeCommentContent {...props} />;
|
||
}
|
||
function SystemNoticeCommentContent({
|
||
message,
|
||
anchorId,
|
||
}: {
|
||
message: ThreadMessage;
|
||
anchorId?: string;
|
||
}) {
|
||
const { onImageClick, agentMap, issueStatus, successfulRunHandoff } =
|
||
useContext(IssueChatCtx);
|
||
const toastActions = useOptionalToastActions();
|
||
const custom = message.metadata.custom as Record<string, unknown>;
|
||
const presentation = isIssueCommentPresentation(custom.presentation)
|
||
? custom.presentation
|
||
: null;
|
||
const commentMetadata = isIssueCommentMetadata(custom.commentMetadata)
|
||
? custom.commentMetadata
|
||
: null;
|
||
const recoverySnapshot = useDispositionRecoverySnapshot(commentMetadata);
|
||
const branchRecovery = useWorkspaceBaseRefRecovery(commentMetadata);
|
||
const workspaceRetry = useWorkspaceRetry(commentMetadata);
|
||
const runAgentId =
|
||
typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||
const authorType =
|
||
typeof custom.authorType === "string" ? custom.authorType : null;
|
||
const authorName =
|
||
typeof custom.authorName === "string" ? custom.authorName : null;
|
||
const bodyText = message.content
|
||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||
.map((p) => p.text)
|
||
.join("\n\n");
|
||
const staleSuccessfulRunHandoffNotice = isStaleSuccessfulRunHandoffNotice({
|
||
bodyText,
|
||
issueStatus,
|
||
successfulRunHandoff,
|
||
runId,
|
||
metadata: commentMetadata,
|
||
});
|
||
const [copied, setCopied] = useState(false);
|
||
const [copiedLink, setCopiedLink] = useState(false);
|
||
|
||
const source = (() => {
|
||
const runAgentName = runAgentId
|
||
? (agentMap?.get(runAgentId)?.name ?? null)
|
||
: null;
|
||
if (authorType === "system") {
|
||
const label = runAgentName ?? "Paperclip";
|
||
if (runAgentId && runId)
|
||
return { label, href: `/agents/${runAgentId}/runs/${runId}` };
|
||
return { label };
|
||
}
|
||
if (runAgentId && runId) {
|
||
return {
|
||
label: authorName ?? runAgentName ?? "Paperclip",
|
||
href: `/agents/${runAgentId}/runs/${runId}`,
|
||
};
|
||
}
|
||
if (authorName) return { label: authorName };
|
||
return undefined;
|
||
})();
|
||
|
||
const props = buildSystemNoticeProps({
|
||
presentation,
|
||
metadata: commentMetadata,
|
||
body: (
|
||
<MarkdownBody
|
||
className="text-sm leading-6"
|
||
softBreaks
|
||
onImageClick={onImageClick}
|
||
>
|
||
{bodyText}
|
||
</MarkdownBody>
|
||
),
|
||
timestamp: toValidIsoString(message.createdAt),
|
||
source,
|
||
runAgentId,
|
||
});
|
||
|
||
const handleCopy = () => {
|
||
void copyTextToClipboard(bodyText)
|
||
.then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
})
|
||
.catch((error) => {
|
||
toastActions?.pushToast({
|
||
title: "Copy failed",
|
||
body:
|
||
error instanceof Error
|
||
? error.message
|
||
: "Unable to copy system notice",
|
||
tone: "error",
|
||
});
|
||
});
|
||
};
|
||
|
||
const handleCopyLink = () => {
|
||
if (!anchorId || typeof window === "undefined") return;
|
||
const url = `${window.location.origin}${window.location.pathname}#${anchorId}`;
|
||
void copyTextToClipboard(url)
|
||
.then(() => {
|
||
setCopiedLink(true);
|
||
setTimeout(() => setCopiedLink(false), 2000);
|
||
})
|
||
.catch((error) => {
|
||
toastActions?.pushToast({
|
||
title: "Copy failed",
|
||
body:
|
||
error instanceof Error
|
||
? error.message
|
||
: "Unable to copy system notice link",
|
||
tone: "error",
|
||
});
|
||
});
|
||
};
|
||
|
||
if (authorType === "system" && workspaceRetry) return null;
|
||
if (authorType === "system" && branchRecovery) return <div id={anchorId}><WorkspaceBaseRefRecoveryNotice key={`${branchRecovery.actionId}:${branchRecovery.runId}`} {...branchRecovery.props} /></div>;
|
||
|
||
if (authorType === "system" && recoverySnapshot) {
|
||
return <div id={anchorId}><DispositionRecoveryNotice snapshot={recoverySnapshot} createdAt={toValidIsoString(message.createdAt)} defaultExpanded={presentation?.detailsDefaultOpen} /></div>;
|
||
}
|
||
|
||
if (staleSuccessfulRunHandoffNotice) {
|
||
return (
|
||
<StaleDispositionWarningRow
|
||
anchorId={anchorId}
|
||
message={message}
|
||
metadata={commentMetadata}
|
||
runAgentId={runAgentId}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Compact presentation collapses the notice to a single quiet row. Notices
|
||
// without `density` (old comments / old data) keep today's full card.
|
||
if (presentation?.density === "compact") {
|
||
const tone = presentation.tone ?? "neutral";
|
||
const title = systemNoticeLabelForTone(tone, presentation.title);
|
||
return (
|
||
<CompactSystemNoticeRow
|
||
anchorId={anchorId}
|
||
message={message}
|
||
tone={tone}
|
||
title={title}
|
||
source={source}
|
||
noticeProps={props}
|
||
defaultOpen={Boolean(presentation.detailsDefaultOpen)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div id={anchorId} className="group">
|
||
<div className="py-1">
|
||
<SystemNotice {...props} />
|
||
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<a
|
||
href={anchorId ? `#${anchorId}` : undefined}
|
||
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground hover:underline"
|
||
>
|
||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||
</a>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">
|
||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
{anchorId ? (
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||
title="Copy link"
|
||
aria-label="Copy link to system notice"
|
||
onClick={handleCopyLink}
|
||
>
|
||
{copiedLink ? (
|
||
<Check className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Paperclip className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||
title="Copy notice text"
|
||
aria-label="Copy system notice"
|
||
onClick={handleCopy}
|
||
>
|
||
{copied ? (
|
||
<Check className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Copy className="h-3.5 w-3.5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Non-comment timeline items (run/status events, "updated this task",
|
||
// "worked for N minutes") render as quiet, subordinate metadata rows hung off a
|
||
// left rail — visually distinct from the bubbles used for genuine comments.
|
||
// Virtualized rows are absolutely positioned, so each row carries its own rail
|
||
// segment; stacked rows read as one continuous rail. See PAP-95 mockup rev 5.
|
||
function IssueChatMetadataRow({
|
||
anchorId,
|
||
icon,
|
||
children,
|
||
testid = "issue-chat-metadata-row",
|
||
}: {
|
||
anchorId?: string;
|
||
icon: ReactNode;
|
||
children: ReactNode;
|
||
testid?: string;
|
||
}) {
|
||
return (
|
||
<div id={anchorId} data-testid={testid}>
|
||
<div className="ml-3 flex items-start gap-2.5 border-l-2 border-border/50 py-0.5 pl-3">
|
||
<span className="mt-px flex size-(--sz-18px) shrink-0 items-center justify-center rounded-full border border-border/70 bg-muted/30 text-muted-foreground/60">
|
||
{icon}
|
||
</span>
|
||
<div className="min-w-0 flex-1 space-y-1">{children}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||
const {
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
onAcceptInteraction,
|
||
onRejectInteraction,
|
||
onSubmitInteractionAnswers,
|
||
onCancelInteraction,
|
||
onSubmitInteractionVerdicts,
|
||
onUploadImage,
|
||
externalReferences,
|
||
} = useContext(IssueChatCtx);
|
||
const custom = message.metadata.custom as Record<string, unknown>;
|
||
const anchorId =
|
||
typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||
const runId = typeof custom.runId === "string" ? custom.runId : null;
|
||
const runAgentId =
|
||
typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||
const runAgentName =
|
||
typeof custom.runAgentName === "string" ? custom.runAgentName : null;
|
||
const runStatus =
|
||
typeof custom.runStatus === "string" ? custom.runStatus : null;
|
||
const actorName =
|
||
typeof custom.actorName === "string" ? custom.actorName : null;
|
||
const actorType =
|
||
typeof custom.actorType === "string" ? custom.actorType : null;
|
||
const actorId = typeof custom.actorId === "string" ? custom.actorId : null;
|
||
const statusChange =
|
||
typeof custom.statusChange === "object" && custom.statusChange
|
||
? (custom.statusChange as { from: string | null; to: string | null })
|
||
: null;
|
||
const assigneeChange =
|
||
typeof custom.assigneeChange === "object" && custom.assigneeChange
|
||
? (custom.assigneeChange as {
|
||
from: IssueTimelineAssignee;
|
||
to: IssueTimelineAssignee;
|
||
})
|
||
: null;
|
||
const workspaceChange = isTimelineWorkspaceChange(custom.workspaceChange)
|
||
? custom.workspaceChange
|
||
: null;
|
||
const interaction = isIssueThreadInteraction(custom.interaction)
|
||
? custom.interaction
|
||
: null;
|
||
|
||
if (custom.kind === "system_notice") {
|
||
return <SystemNoticeCommentRow message={message} anchorId={anchorId} />;
|
||
}
|
||
|
||
if (custom.kind === "interaction" && interaction) {
|
||
if (
|
||
interaction.kind === "request_confirmation" &&
|
||
interaction.status === "expired" &&
|
||
!interaction.payload.secretProposal
|
||
) {
|
||
return (
|
||
<ExpiredRequestConfirmationActivity
|
||
message={message}
|
||
anchorId={anchorId}
|
||
interaction={interaction}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div id={anchorId}>
|
||
<div className="py-1.5">
|
||
<IssueThreadInteractionCard
|
||
interaction={interaction}
|
||
agentMap={agentMap}
|
||
currentUserId={currentUserId}
|
||
userLabelMap={userLabelMap}
|
||
onAcceptInteraction={onAcceptInteraction}
|
||
onRejectInteraction={onRejectInteraction}
|
||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||
onCancelInteraction={onCancelInteraction}
|
||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||
onUploadImage={onUploadImage}
|
||
externalReferences={externalReferences}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (custom.kind === "event" && actorName) {
|
||
const isAgent = actorType === "agent";
|
||
const agentIcon = isAgent && actorId ? agentMap?.get(actorId)?.icon : undefined;
|
||
const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId;
|
||
const rowIcon = isAgent
|
||
? <AgentAvatar agent={actorId ? agentMap?.get(actorId) ?? { id: actorId } : undefined} size={16} />
|
||
: <ClipboardList className="h-3 w-3" />;
|
||
const handoffResolvers: HandoffChipResolvers = {
|
||
agentMap,
|
||
currentUserId,
|
||
resolveUserLabel: (userId) =>
|
||
formatAssigneeUserLabel(userId, null, userLabelMap),
|
||
};
|
||
|
||
return (
|
||
<IssueChatMetadataRow anchorId={anchorId} icon={rowIcon}>
|
||
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-xs">
|
||
<span className="font-medium text-foreground">{actorName}</span>
|
||
<span className="text-muted-foreground">
|
||
{custom.followUpRequested === true
|
||
? "requested follow-up"
|
||
: "updated this task"}
|
||
</span>
|
||
<a
|
||
href={anchorId ? `#${anchorId}` : undefined}
|
||
className="text-xs text-muted-foreground/70 transition-colors hover:text-foreground hover:underline"
|
||
>
|
||
{timeAgo(message.createdAt)}
|
||
</a>
|
||
</div>
|
||
|
||
{statusChange ? (
|
||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||
<span className="text-(length:--text-nano) font-medium uppercase tracking-wider text-muted-foreground/70">
|
||
Status
|
||
</span>
|
||
<span className="text-muted-foreground">
|
||
{humanizeValue(statusChange.from)}
|
||
</span>
|
||
<ArrowRight className="h-3 w-3 text-muted-foreground/70" />
|
||
<span className="font-medium text-foreground">
|
||
{humanizeValue(statusChange.to)}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
|
||
{assigneeChange ? (
|
||
<div className="space-y-1">
|
||
<div
|
||
className={cn(
|
||
"flex flex-wrap items-center gap-1.5 text-xs",
|
||
isCurrentUser && "justify-end",
|
||
)}
|
||
>
|
||
<span className="text-(length:--text-nano) font-medium uppercase tracking-wider text-muted-foreground/70">
|
||
Assignee
|
||
</span>
|
||
<AssigneeChip
|
||
assignee={assigneeChange.from}
|
||
resolvers={handoffResolvers}
|
||
/>
|
||
<ArrowRight className="h-3 w-3 text-muted-foreground/70" />
|
||
<AssigneeChip
|
||
assignee={assigneeChange.to}
|
||
resolvers={handoffResolvers}
|
||
/>
|
||
</div>
|
||
<div className={cn(isCurrentUser && "flex justify-end")}>
|
||
<HandoffWakeRow
|
||
to={assigneeChange.to}
|
||
resolvers={handoffResolvers}
|
||
interruptedRunAttached={custom.interruptedRunId != null}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{workspaceChange ? (
|
||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||
<span className="text-(length:--text-nano) font-medium uppercase tracking-wider text-muted-foreground/70">
|
||
Workspace
|
||
</span>
|
||
<span className="text-muted-foreground">
|
||
{formatTimelineWorkspaceLabel(workspaceChange.from)}
|
||
</span>
|
||
<ArrowRight className="h-3 w-3 text-muted-foreground/70" />
|
||
<span className="font-medium text-foreground">
|
||
{formatTimelineWorkspaceLabel(workspaceChange.to)}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</IssueChatMetadataRow>
|
||
);
|
||
}
|
||
|
||
const displayedRunAgentName =
|
||
runAgentName ??
|
||
(runAgentId
|
||
? (agentMap?.get(runAgentId)?.name ?? runAgentId.slice(0, 8))
|
||
: null);
|
||
const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined;
|
||
if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) {
|
||
const rowIcon = <AgentAvatar agent={agentMap?.get(runAgentId) ?? { id: runAgentId }} size={16} />;
|
||
|
||
return (
|
||
<IssueChatMetadataRow anchorId={anchorId} icon={rowIcon}>
|
||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs">
|
||
<Link
|
||
to={`/agents/${runAgentId}`}
|
||
className="font-medium text-foreground transition-colors hover:underline"
|
||
>
|
||
{displayedRunAgentName}
|
||
</Link>
|
||
<span className="text-muted-foreground">run</span>
|
||
<Link
|
||
to={`/agents/${runAgentId}/runs/${runId}`}
|
||
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-1.5 py-0.5 font-mono text-(length:--text-nano) text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||
>
|
||
{runId.slice(0, 8)}
|
||
</Link>
|
||
<RunStatusBadge
|
||
status={runStatus}
|
||
operatorInterrupted={custom.runOperatorInterrupted === true}
|
||
/>
|
||
<a
|
||
href={anchorId ? `#${anchorId}` : undefined}
|
||
className="text-xs text-muted-foreground/70 transition-colors hover:text-foreground hover:underline"
|
||
>
|
||
{timeAgo(message.createdAt)}
|
||
</a>
|
||
</div>
|
||
</IssueChatMetadataRow>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function issueChatMessageCustom(
|
||
message: ThreadMessage,
|
||
): Record<string, unknown> {
|
||
return (message.metadata?.custom ?? {}) as Record<string, unknown>;
|
||
}
|
||
|
||
function issueChatMessageKind(message: ThreadMessage): string {
|
||
const custom = issueChatMessageCustom(message);
|
||
return typeof custom.kind === "string" ? custom.kind : message.role;
|
||
}
|
||
|
||
function issueChatMessageCommentId(message: ThreadMessage): string | null {
|
||
const custom = issueChatMessageCustom(message);
|
||
return typeof custom.commentId === "string" ? custom.commentId : null;
|
||
}
|
||
|
||
function issueChatMessageRunId(message: ThreadMessage): string | null {
|
||
const custom = issueChatMessageCustom(message);
|
||
return typeof custom.runId === "string" ? custom.runId : null;
|
||
}
|
||
|
||
function issueChatMessageQueueTargetRunId(
|
||
message: ThreadMessage,
|
||
): string | null {
|
||
const custom = issueChatMessageCustom(message);
|
||
return typeof custom.queueTargetRunId === "string"
|
||
? custom.queueTargetRunId
|
||
: null;
|
||
}
|
||
|
||
function issueChatMessageActiveVote(
|
||
message: ThreadMessage,
|
||
feedbackVoteByTargetId: ReadonlyMap<string, FeedbackVoteValue>,
|
||
): FeedbackVoteValue | null {
|
||
const commentId = issueChatMessageCommentId(message);
|
||
return commentId ? (feedbackVoteByTargetId.get(commentId) ?? null) : null;
|
||
}
|
||
|
||
function issueChatMessageRunIsActive(
|
||
message: ThreadMessage,
|
||
activeRunIds: ReadonlySet<string>,
|
||
): boolean {
|
||
const runId = issueChatMessageRunId(message);
|
||
return Boolean(runId && activeRunIds.has(runId));
|
||
}
|
||
|
||
function issueChatMessageRunIsStopping(
|
||
message: ThreadMessage,
|
||
stoppingRunId: string | null | undefined,
|
||
): boolean {
|
||
const runId = issueChatMessageRunId(message);
|
||
return Boolean(runId && stoppingRunId === runId);
|
||
}
|
||
|
||
function issueChatMessageQueuedRunIsInterrupting(
|
||
message: ThreadMessage,
|
||
interruptingQueuedRunId: string | null | undefined,
|
||
): boolean {
|
||
const queueTargetRunId = issueChatMessageQueueTargetRunId(message);
|
||
return Boolean(
|
||
queueTargetRunId && interruptingQueuedRunId === queueTargetRunId,
|
||
);
|
||
}
|
||
|
||
function issueChatMessageIsDeleted(message: ThreadMessage): boolean {
|
||
const custom = issueChatMessageCustom(message);
|
||
return Boolean(custom.deletedAt);
|
||
}
|
||
|
||
function issueChatMessageDeletedAt(message: ThreadMessage): string | null {
|
||
const custom = issueChatMessageCustom(message);
|
||
return typeof custom.deletedAt === "string" ? custom.deletedAt : null;
|
||
}
|
||
|
||
// Above ~150 merged rows the direct render path forces React to mount and
|
||
// re-render hundreds of Markdown bodies, feedback controls, and avatars on
|
||
// unrelated parent updates. Above this threshold we switch to a windowed
|
||
// render path so only visible rows plus overscan stay mounted.
|
||
export const VIRTUALIZED_THREAD_ROW_THRESHOLD = 150;
|
||
const VIRTUALIZED_THREAD_OVERSCAN = 6;
|
||
// Rough "average row" estimate. The virtualizer measures real heights as
|
||
// rows mount, so this only affects offscreen rows it has not seen yet.
|
||
const VIRTUALIZED_THREAD_ROW_ESTIMATE_PX = 220;
|
||
const VIRTUALIZED_THREAD_GAP_FULL_PX = 16;
|
||
const VIRTUALIZED_THREAD_GAP_EMBEDDED_PX = 12;
|
||
|
||
interface VirtualizedIssueChatThreadListProps {
|
||
messages: readonly ThreadMessage[];
|
||
feedbackVoteByTargetId: ReadonlyMap<string, FeedbackVoteValue>;
|
||
activeRunIds: ReadonlySet<string>;
|
||
stoppingRunId?: string | null;
|
||
interruptingQueuedRunId?: string | null;
|
||
variant: "full" | "embedded";
|
||
}
|
||
|
||
interface VirtualizedIssueChatThreadListHandle {
|
||
scrollToIndex: (
|
||
index: number,
|
||
options?: {
|
||
align?: "start" | "center" | "end" | "auto";
|
||
behavior?: ScrollBehavior;
|
||
},
|
||
) => void;
|
||
scrollToLatest: (options?: { behavior?: ScrollBehavior }) => void;
|
||
measure: () => void;
|
||
}
|
||
|
||
function issueChatMessageAnchorId(message: ThreadMessage): string | null {
|
||
const custom = message.metadata.custom as { anchorId?: unknown } | undefined;
|
||
return typeof custom?.anchorId === "string" ? custom.anchorId : null;
|
||
}
|
||
|
||
function findMessageAnchorIndex(
|
||
messages: readonly ThreadMessage[],
|
||
anchorId: string,
|
||
): number {
|
||
return messages.findIndex(
|
||
(message) => issueChatMessageAnchorId(message) === anchorId,
|
||
);
|
||
}
|
||
|
||
export function findLatestCommentMessageIndex(
|
||
messages: readonly ThreadMessage[],
|
||
): number {
|
||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||
const anchorId = issueChatMessageAnchorId(messages[index]);
|
||
if (anchorId && anchorId.startsWith("comment-")) return index;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
type VirtualizedVisibleAnchorSnapshot = {
|
||
anchorId: string;
|
||
index: number;
|
||
viewportTop: number;
|
||
};
|
||
|
||
type VirtualizedScrollMode =
|
||
{ kind: "window" } | { kind: "element"; element: HTMLElement };
|
||
|
||
type SimpleVirtualItem = {
|
||
index: number;
|
||
key: React.Key;
|
||
start: number;
|
||
size: number;
|
||
};
|
||
|
||
export function getVirtualizedMeasurementScrollAdjustment(args: {
|
||
itemStart: number;
|
||
previousSize: number;
|
||
nextSize: number;
|
||
viewportStart: number;
|
||
}) {
|
||
const { itemStart, previousSize, nextSize, viewportStart } = args;
|
||
const previousEnd = itemStart + previousSize;
|
||
if (previousEnd > viewportStart) return 0;
|
||
return nextSize - previousSize;
|
||
}
|
||
|
||
function useIssueThreadVirtualizer({
|
||
count,
|
||
estimateSize,
|
||
overscan,
|
||
scrollMargin,
|
||
gap,
|
||
getItemKey,
|
||
mode,
|
||
}: {
|
||
count: number;
|
||
estimateSize: () => number;
|
||
overscan: number;
|
||
scrollMargin: number;
|
||
gap: number;
|
||
getItemKey: (index: number) => React.Key;
|
||
mode: VirtualizedScrollMode;
|
||
}) {
|
||
const measuredSizeByKeyRef = useRef(new Map<React.Key, number>());
|
||
const [, rerender] = useState(0);
|
||
const estimatedSize = estimateSize();
|
||
|
||
const itemStarts: number[] = [];
|
||
const itemSizes: number[] = [];
|
||
let nextStart = scrollMargin;
|
||
for (let index = 0; index < count; index += 1) {
|
||
const key = getItemKey(index);
|
||
const size = measuredSizeByKeyRef.current.get(key) ?? estimatedSize;
|
||
itemStarts.push(nextStart);
|
||
itemSizes.push(size);
|
||
nextStart += size + gap;
|
||
}
|
||
const totalSize = Math.max(0, nextStart - scrollMargin - gap);
|
||
|
||
const viewportHeight = () =>
|
||
mode.kind === "window" ? window.innerHeight : mode.element.clientHeight;
|
||
const scrollOffset = () =>
|
||
mode.kind === "window" ? window.scrollY : mode.element.scrollTop;
|
||
const maxScrollOffset = () => {
|
||
const targetScrollHeight =
|
||
mode.kind === "window"
|
||
? document.documentElement.scrollHeight
|
||
: mode.element.scrollHeight;
|
||
return Math.max(
|
||
0,
|
||
Math.max(targetScrollHeight, totalSize) - viewportHeight(),
|
||
);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const target: Window | HTMLElement =
|
||
mode.kind === "window" ? window : mode.element;
|
||
const update = () => rerender((value) => value + 1);
|
||
target.addEventListener("scroll", update, { passive: true });
|
||
window.addEventListener("resize", update);
|
||
return () => {
|
||
target.removeEventListener("scroll", update);
|
||
window.removeEventListener("resize", update);
|
||
};
|
||
}, [mode]);
|
||
|
||
const rawStart = Math.max(scrollMargin, scrollOffset());
|
||
const rawEnd = rawStart + viewportHeight();
|
||
let visibleStartIndex = 0;
|
||
while (
|
||
visibleStartIndex < count - 1 &&
|
||
itemStarts[visibleStartIndex] + itemSizes[visibleStartIndex] < rawStart
|
||
) {
|
||
visibleStartIndex += 1;
|
||
}
|
||
let visibleEndIndex = visibleStartIndex;
|
||
while (visibleEndIndex < count - 1 && itemStarts[visibleEndIndex] <= rawEnd) {
|
||
visibleEndIndex += 1;
|
||
}
|
||
const startIndex = Math.max(0, visibleStartIndex - overscan);
|
||
const endIndex = Math.min(count - 1, visibleEndIndex + overscan);
|
||
const virtualItems: SimpleVirtualItem[] = [];
|
||
for (let index = startIndex; index <= endIndex; index += 1) {
|
||
virtualItems.push({
|
||
index,
|
||
key: getItemKey(index),
|
||
start: itemStarts[index] ?? scrollMargin,
|
||
size: itemSizes[index] ?? estimatedSize,
|
||
});
|
||
}
|
||
|
||
const scrollToIndex = (
|
||
index: number,
|
||
options?: {
|
||
align?: "start" | "center" | "end" | "auto";
|
||
behavior?: ScrollBehavior;
|
||
},
|
||
) => {
|
||
const clampedIndex = Math.max(0, Math.min(index, count - 1));
|
||
const targetMax = maxScrollOffset();
|
||
let top = itemStarts[clampedIndex] ?? scrollMargin;
|
||
if (options?.align === "center") {
|
||
top =
|
||
top -
|
||
viewportHeight() / 2 +
|
||
(itemSizes[clampedIndex] ?? estimatedSize) / 2;
|
||
} else if (options?.align === "end") {
|
||
top = top + (itemSizes[clampedIndex] ?? estimatedSize) - viewportHeight();
|
||
}
|
||
top = Math.max(0, Math.min(top, targetMax));
|
||
if (mode.kind === "window") {
|
||
window.scrollTo({ top, behavior: options?.behavior });
|
||
} else {
|
||
mode.element.scrollTo({ top, behavior: options?.behavior });
|
||
}
|
||
rerender((value) => value + 1);
|
||
};
|
||
|
||
return {
|
||
getVirtualItems: () => virtualItems,
|
||
getTotalSize: () => totalSize,
|
||
scrollToIndex,
|
||
measure: () => undefined,
|
||
measureElement: (element?: HTMLElement | null) => {
|
||
if (!element) return;
|
||
const index = Number(element.dataset.index);
|
||
if (!Number.isInteger(index) || index < 0 || index >= count) return;
|
||
const measuredSize =
|
||
element.getBoundingClientRect().height || element.offsetHeight;
|
||
if (!Number.isFinite(measuredSize) || measuredSize <= 0) return;
|
||
const key = getItemKey(index);
|
||
const previousSize =
|
||
measuredSizeByKeyRef.current.get(key) ?? estimatedSize;
|
||
if (Math.abs(previousSize - measuredSize) < 1) return;
|
||
const scrollAdjustment = getVirtualizedMeasurementScrollAdjustment({
|
||
itemStart: itemStarts[index] ?? scrollMargin,
|
||
previousSize,
|
||
nextSize: measuredSize,
|
||
viewportStart: Math.max(scrollMargin, scrollOffset()),
|
||
});
|
||
measuredSizeByKeyRef.current.set(key, measuredSize);
|
||
if (Math.abs(scrollAdjustment) >= 1) {
|
||
if (mode.kind === "window") {
|
||
window.scrollBy({ top: scrollAdjustment, behavior: "auto" });
|
||
} else {
|
||
mode.element.scrollBy({ top: scrollAdjustment, behavior: "auto" });
|
||
}
|
||
}
|
||
rerender((value) => value + 1);
|
||
},
|
||
};
|
||
}
|
||
|
||
// The chat thread renders inside `<main id="main-content">` on the real issue
|
||
// page (overflow-auto on desktop), but lives at document scope on mobile (main
|
||
// is overflow-visible) and in the auth-free perf fixture. Walk the DOM to find
|
||
// the actual scroll container so the virtualizer binds to the right offset
|
||
// source — otherwise it stays anchored at offset 0 forever and the visible
|
||
// chat area renders blank past the first viewport (PAP-2660).
|
||
function findScrollContainer(el: HTMLElement | null): HTMLElement | null {
|
||
if (!el || typeof window === "undefined") return null;
|
||
let current: HTMLElement | null = el.parentElement;
|
||
while (
|
||
current &&
|
||
current !== document.body &&
|
||
current !== document.documentElement
|
||
) {
|
||
const overflowY = window.getComputedStyle(current).overflowY;
|
||
if (
|
||
overflowY === "auto" ||
|
||
overflowY === "scroll" ||
|
||
overflowY === "overlay"
|
||
) {
|
||
return current;
|
||
}
|
||
current = current.parentElement;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const VirtualizedIssueChatThreadList = forwardRef<
|
||
VirtualizedIssueChatThreadListHandle,
|
||
VirtualizedIssueChatThreadListProps
|
||
>(function VirtualizedIssueChatThreadList(props, ref) {
|
||
const probeRef = useRef<HTMLDivElement | null>(null);
|
||
// Default to window scroll on first render so the imperative handle is
|
||
// available immediately for hash-target / submit-scroll effects. After mount
|
||
// we probe the DOM and remount via key={modeKey} if the actual scroll
|
||
// container is an element ancestor (e.g. desktop <main id="main-content">).
|
||
const [mode, setMode] = useState<VirtualizedScrollMode>({ kind: "window" });
|
||
|
||
useLayoutEffect(() => {
|
||
if (typeof window === "undefined") return;
|
||
const detect = () => {
|
||
const probe = probeRef.current;
|
||
if (!probe) return;
|
||
const container = findScrollContainer(probe);
|
||
setMode((prev) => {
|
||
if (container === null) {
|
||
return prev.kind === "window" ? prev : { kind: "window" };
|
||
}
|
||
if (prev.kind === "element" && prev.element === container) return prev;
|
||
return { kind: "element", element: container };
|
||
});
|
||
};
|
||
detect();
|
||
window.addEventListener("resize", detect);
|
||
return () => {
|
||
window.removeEventListener("resize", detect);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<VirtualizedIssueChatThreadListInner
|
||
key={mode.kind === "window" ? "window" : "element"}
|
||
ref={ref}
|
||
probeRef={probeRef}
|
||
mode={mode}
|
||
{...props}
|
||
/>
|
||
);
|
||
});
|
||
|
||
interface VirtualizedIssueChatThreadListInnerProps extends VirtualizedIssueChatThreadListProps {
|
||
mode: VirtualizedScrollMode;
|
||
probeRef: React.MutableRefObject<HTMLDivElement | null>;
|
||
}
|
||
|
||
const VirtualizedIssueChatThreadListInner = forwardRef<
|
||
VirtualizedIssueChatThreadListHandle,
|
||
VirtualizedIssueChatThreadListInnerProps
|
||
>(function VirtualizedIssueChatThreadListInner(
|
||
{
|
||
messages,
|
||
feedbackVoteByTargetId,
|
||
activeRunIds,
|
||
stoppingRunId,
|
||
interruptingQueuedRunId,
|
||
variant,
|
||
mode,
|
||
probeRef,
|
||
},
|
||
ref,
|
||
) {
|
||
const parentRef = useRef<HTMLDivElement | null>(null);
|
||
const [scrollMargin, setScrollMargin] = useState(0);
|
||
const pendingPrependAnchorRef =
|
||
useRef<VirtualizedVisibleAnchorSnapshot | null>(null);
|
||
|
||
const setRefs = useCallback(
|
||
(element: HTMLDivElement | null) => {
|
||
parentRef.current = element;
|
||
probeRef.current = element;
|
||
},
|
||
[probeRef],
|
||
);
|
||
|
||
useLayoutEffect(() => {
|
||
const element = parentRef.current;
|
||
if (!element || typeof window === "undefined") return;
|
||
const update = () => {
|
||
if (!parentRef.current) return;
|
||
const rect = parentRef.current.getBoundingClientRect();
|
||
const offset =
|
||
mode.kind === "window"
|
||
? rect.top + window.scrollY
|
||
: rect.top -
|
||
mode.element.getBoundingClientRect().top +
|
||
mode.element.scrollTop;
|
||
setScrollMargin((previous) =>
|
||
Math.abs(previous - offset) < 0.5 ? previous : offset,
|
||
);
|
||
};
|
||
update();
|
||
window.addEventListener("resize", update);
|
||
return () => {
|
||
window.removeEventListener("resize", update);
|
||
};
|
||
}, [mode]);
|
||
|
||
const gap =
|
||
variant === "embedded"
|
||
? VIRTUALIZED_THREAD_GAP_EMBEDDED_PX
|
||
: VIRTUALIZED_THREAD_GAP_FULL_PX;
|
||
|
||
const virtualizer = useIssueThreadVirtualizer({
|
||
count: messages.length,
|
||
estimateSize: () => VIRTUALIZED_THREAD_ROW_ESTIMATE_PX,
|
||
overscan: VIRTUALIZED_THREAD_OVERSCAN,
|
||
scrollMargin,
|
||
gap,
|
||
getItemKey: (index) => messages[index]?.id ?? index,
|
||
mode,
|
||
});
|
||
|
||
useImperativeHandle(
|
||
ref,
|
||
() => ({
|
||
scrollToIndex: (index, options) => {
|
||
if (index < 0 || index >= messages.length) return;
|
||
virtualizer.scrollToIndex(index, {
|
||
align: options?.align ?? "center",
|
||
behavior: options?.behavior ?? "smooth",
|
||
});
|
||
},
|
||
scrollToLatest: (options) => {
|
||
if (messages.length === 0) return;
|
||
virtualizer.scrollToIndex(messages.length - 1, {
|
||
align: "end",
|
||
behavior: options?.behavior ?? "smooth",
|
||
});
|
||
},
|
||
measure: () => {
|
||
virtualizer.measure();
|
||
},
|
||
}),
|
||
[messages.length, virtualizer],
|
||
);
|
||
|
||
useLayoutEffect(() => {
|
||
return () => {
|
||
const element = parentRef.current;
|
||
if (!element || typeof window === "undefined") return;
|
||
const rows = Array.from(
|
||
element.querySelectorAll<HTMLElement>("[data-anchor-id][data-index]"),
|
||
);
|
||
const visibleRow = rows.find(
|
||
(row) => row.getBoundingClientRect().bottom >= 0,
|
||
);
|
||
if (!visibleRow) return;
|
||
const anchorId = visibleRow.dataset.anchorId;
|
||
const index = Number(visibleRow.dataset.index);
|
||
if (!anchorId || !Number.isFinite(index)) return;
|
||
pendingPrependAnchorRef.current = {
|
||
anchorId,
|
||
index,
|
||
viewportTop: visibleRow.getBoundingClientRect().top,
|
||
};
|
||
};
|
||
}, [messages]);
|
||
|
||
useLayoutEffect(() => {
|
||
const pendingAnchor = pendingPrependAnchorRef.current;
|
||
pendingPrependAnchorRef.current = null;
|
||
virtualizer.measure();
|
||
if (!pendingAnchor || typeof window === "undefined") return;
|
||
const nextIndex = findMessageAnchorIndex(messages, pendingAnchor.anchorId);
|
||
if (nextIndex <= pendingAnchor.index) return;
|
||
|
||
virtualizer.scrollToIndex(nextIndex, { align: "start", behavior: "auto" });
|
||
requestAnimationFrame(() => {
|
||
const element = document.getElementById(pendingAnchor.anchorId);
|
||
if (!element) return;
|
||
const delta =
|
||
element.getBoundingClientRect().top - pendingAnchor.viewportTop;
|
||
if (Math.abs(delta) > 1) {
|
||
if (mode.kind === "window") {
|
||
window.scrollBy({ top: delta, behavior: "auto" });
|
||
} else {
|
||
mode.element.scrollBy({ top: delta, behavior: "auto" });
|
||
}
|
||
}
|
||
virtualizer.measure();
|
||
});
|
||
}, [messages, virtualizer, mode]);
|
||
|
||
const virtualItems = virtualizer.getVirtualItems();
|
||
const totalSize = virtualizer.getTotalSize();
|
||
|
||
return (
|
||
<div
|
||
ref={setRefs}
|
||
data-testid="issue-chat-thread-virtualizer"
|
||
data-virtual-count={messages.length}
|
||
style={{ position: "relative", width: "100%", height: totalSize }}
|
||
>
|
||
{virtualItems.map((virtualItem) => {
|
||
const message = messages[virtualItem.index];
|
||
if (!message) return null;
|
||
const anchorId = issueChatMessageAnchorId(message);
|
||
return (
|
||
<div
|
||
key={virtualItem.key}
|
||
data-index={virtualItem.index}
|
||
data-anchor-id={anchorId ?? undefined}
|
||
data-testid="issue-chat-thread-virtual-row"
|
||
ref={(element) => {
|
||
if (element) virtualizer.measureElement(element);
|
||
}}
|
||
onLoadCapture={(event) => {
|
||
virtualizer.measureElement(event.currentTarget);
|
||
}}
|
||
onClickCapture={(event) => {
|
||
const row = event.currentTarget;
|
||
requestAnimationFrame(() => {
|
||
virtualizer.measureElement(row);
|
||
});
|
||
}}
|
||
onTransitionEndCapture={(event) => {
|
||
virtualizer.measureElement(event.currentTarget);
|
||
}}
|
||
style={{
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
|
||
}}
|
||
>
|
||
<IssueChatMessageRow
|
||
message={message}
|
||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||
activeRunIds={activeRunIds}
|
||
stoppingRunId={stoppingRunId}
|
||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
interface IssueChatMessageRowProps {
|
||
message: ThreadMessage;
|
||
feedbackVoteByTargetId: ReadonlyMap<string, FeedbackVoteValue>;
|
||
activeRunIds: ReadonlySet<string>;
|
||
stoppingRunId?: string | null;
|
||
interruptingQueuedRunId?: string | null;
|
||
}
|
||
|
||
function IssueChatDeletedComment({
|
||
message,
|
||
deletedAt,
|
||
}: {
|
||
message: ThreadMessage;
|
||
deletedAt: string;
|
||
}) {
|
||
const custom = issueChatMessageCustom(message);
|
||
const anchorId =
|
||
typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||
const authorName =
|
||
typeof custom.authorName === "string" ? custom.authorName : "Comment";
|
||
const deletedDate = new Date(deletedAt);
|
||
const deletedDateLabel = Number.isNaN(deletedDate.getTime())
|
||
? ""
|
||
: formatDateTime(deletedDate);
|
||
|
||
return (
|
||
<div id={anchorId} className="flex items-start gap-2.5 py-1.5">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border bg-muted/40 text-muted-foreground">
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</div>
|
||
<div className="min-w-0 rounded-md border border-dashed border-border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
|
||
<span className="font-medium text-foreground/80">{authorName}</span>
|
||
<span> deleted this comment</span>
|
||
{deletedDateLabel ? (
|
||
<span className="text-xs"> · {deletedDateLabel}</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const IssueChatMessageRow = memo(function IssueChatMessageRow({
|
||
message,
|
||
feedbackVoteByTargetId,
|
||
activeRunIds,
|
||
stoppingRunId,
|
||
interruptingQueuedRunId,
|
||
}: IssueChatMessageRowProps) {
|
||
const kind = issueChatMessageKind(message);
|
||
const deletedAt = issueChatMessageDeletedAt(message);
|
||
const activeVote = issueChatMessageActiveVote(
|
||
message,
|
||
feedbackVoteByTargetId,
|
||
);
|
||
const isRunActive = issueChatMessageRunIsActive(message, activeRunIds);
|
||
const isStoppingRun = issueChatMessageRunIsStopping(message, stoppingRunId);
|
||
const isInterruptingQueuedRun = issueChatMessageQueuedRunIsInterrupting(
|
||
message,
|
||
interruptingQueuedRunId,
|
||
);
|
||
const renderedMessage = deletedAt ? (
|
||
<IssueChatDeletedComment message={message} deletedAt={deletedAt} />
|
||
) : message.role === "user" ? (
|
||
<IssueChatUserMessage
|
||
message={message}
|
||
isInterruptingQueuedRun={isInterruptingQueuedRun}
|
||
/>
|
||
) : message.role === "assistant" ? (
|
||
<IssueChatAssistantMessage
|
||
message={message}
|
||
activeVote={activeVote}
|
||
isRunActive={isRunActive}
|
||
isStoppingRun={isStoppingRun}
|
||
/>
|
||
) : (
|
||
<IssueChatSystemMessage message={message} />
|
||
);
|
||
|
||
return (
|
||
<div
|
||
data-testid="issue-chat-message-row"
|
||
data-message-role={message.role}
|
||
data-message-kind={kind}
|
||
>
|
||
{renderedMessage}
|
||
</div>
|
||
);
|
||
}, areIssueChatMessageRowPropsEqual);
|
||
|
||
function areIssueChatMessageRowPropsEqual(
|
||
prev: IssueChatMessageRowProps,
|
||
next: IssueChatMessageRowProps,
|
||
) {
|
||
if (prev.message !== next.message) return false;
|
||
if (
|
||
issueChatMessageActiveVote(prev.message, prev.feedbackVoteByTargetId) !==
|
||
issueChatMessageActiveVote(next.message, next.feedbackVoteByTargetId)
|
||
)
|
||
return false;
|
||
if (
|
||
issueChatMessageRunIsActive(prev.message, prev.activeRunIds) !==
|
||
issueChatMessageRunIsActive(next.message, next.activeRunIds)
|
||
)
|
||
return false;
|
||
if (
|
||
issueChatMessageRunIsStopping(prev.message, prev.stoppingRunId) !==
|
||
issueChatMessageRunIsStopping(next.message, next.stoppingRunId)
|
||
)
|
||
return false;
|
||
if (
|
||
issueChatMessageQueuedRunIsInterrupting(
|
||
prev.message,
|
||
prev.interruptingQueuedRunId,
|
||
) !==
|
||
issueChatMessageQueuedRunIsInterrupting(
|
||
next.message,
|
||
next.interruptingQueuedRunId,
|
||
)
|
||
)
|
||
return false;
|
||
return true;
|
||
}
|
||
|
||
const IssueChatComposer = forwardRef<
|
||
IssueChatComposerHandle,
|
||
IssueChatComposerProps
|
||
>(function IssueChatComposer(
|
||
{
|
||
onSend,
|
||
confirmedSubmissionIds,
|
||
onStop,
|
||
stopPending,
|
||
stopScope = "leaf",
|
||
onImageUpload,
|
||
onAttachImage,
|
||
draftKey: sharedDraftKey,
|
||
enableReassign = false,
|
||
reassignOptions = [],
|
||
currentAssigneeValue = "",
|
||
companyId,
|
||
assigneeAdapterOverrides,
|
||
suggestedAssigneeValue,
|
||
mentions = [],
|
||
agentMap,
|
||
hasActiveRun = false,
|
||
currentUserId = null,
|
||
userLabelMap = null,
|
||
composerPause = null,
|
||
composerDisabledReason = null,
|
||
composerHint = null,
|
||
issueStatus,
|
||
issueWorkMode,
|
||
onWorkModeChange,
|
||
},
|
||
forwardedRef,
|
||
) {
|
||
const stopControl = useComposerStop(onStop, stopPending);
|
||
const submitKey = useComposerSubmitKey();
|
||
const restoredRecovery = useMemo(() => {
|
||
const key = sharedDraftKey && loadDraftRecoveryKey(sharedDraftKey);
|
||
return key ? { sourceKey: sharedDraftKey!, key, persisted: true } : null;
|
||
}, [sharedDraftKey]);
|
||
const [newRecovery, setDraftRecovery] = useState<{ sourceKey: string; key: string; persisted: boolean } | null>(null);
|
||
const draftRecovery = newRecovery?.sourceKey === sharedDraftKey ? newRecovery : restoredRecovery;
|
||
const draftKey = draftRecovery?.key ?? sharedDraftKey;
|
||
// Keep the active buffer through send completion. Re-entering a task may
|
||
// return to its shared draft after an empty recovery has been retired.
|
||
useEffect(() => setDraftRecovery(restoredRecovery), [sharedDraftKey, restoredRecovery]);
|
||
const retiredDraftKeyRef = useRef<string | undefined>(undefined);
|
||
// Initialize before StrictMode's mount cleanup can flush an empty value over
|
||
// the stored draft. The effect below handles subsequent task-key changes.
|
||
const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [uncertainSubmission, setUncertainSubmission] =
|
||
useState<ComposerDraftSubmission | null>(() =>
|
||
draftKey ? loadDraftSubmission(draftKey) : null,
|
||
);
|
||
// Attempts this composer resent and saw confirmed; they settle exactly like
|
||
// a receipt that arrives through the thread.
|
||
const [resentSubmissionIds, setResentSubmissionIds] = useState<ReadonlySet<string>>(() => new Set());
|
||
const mountedTaskKey = useRef(draftKey);
|
||
useEffect(() => {
|
||
mountedTaskKey.current = draftKey;
|
||
setUncertainSubmission(draftKey ? loadDraftSubmission(draftKey) : null);
|
||
return () => {
|
||
mountedTaskKey.current = undefined;
|
||
};
|
||
}, [draftKey]);
|
||
const bodyRef = useRef(body);
|
||
bodyRef.current = body;
|
||
const reconciledSubmissionRef = useRef<{ draftKey: string | undefined; attemptId: string } | null>(null);
|
||
// Only the account that made a request may replay it.
|
||
const draftOwner = useDraftOwner();
|
||
const isCurrentAccount = useCurrentAccountGuard();
|
||
const pendingDraftRef = useRef<{
|
||
draftKey: string;
|
||
attemptId: string;
|
||
submittedBody: string;
|
||
submittedAttachmentIds: string[];
|
||
request: ComposerSendRequest;
|
||
requestOwner?: string;
|
||
} | null>(null);
|
||
const markRequestUnbound = useRequestOwnerBinding({
|
||
draftKey, owner: draftOwner, uncertainSubmission, setUncertainSubmission, pendingDraftRef,
|
||
});
|
||
function changeBody(update: string | ((current: string) => string)) {
|
||
const value = typeof update === "function" ? update(bodyRef.current) : update;
|
||
bodyRef.current = value;
|
||
setBody(value);
|
||
const pending = pendingDraftRef.current;
|
||
if (!pending || pending.draftKey !== draftKey ||
|
||
loadDraftSubmission(pending.draftKey)?.attemptId !== pending.attemptId) return;
|
||
// Persist the next draft while delivery is pending, before navigation or a
|
||
// lost response can turn the original submission into an uncertain one.
|
||
saveDraft(pending.draftKey,
|
||
value ? `${pending.submittedBody}\n\n${value}` : pending.submittedBody,
|
||
pending.attemptId);
|
||
saveDraftSubmission(pending.draftKey, {
|
||
attemptId: pending.attemptId, reviewed: false,
|
||
nextDraftOffset: pending.submittedBody.length + (value ? 2 : 0),
|
||
submittedAttachmentIds: pending.submittedAttachmentIds,
|
||
request: pending.request,
|
||
requestOwner: pending.requestOwner,
|
||
});
|
||
}
|
||
const submittingRef = useRef(submitting);
|
||
submittingRef.current = submitting;
|
||
const [attaching, setAttaching] = useState(false);
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
const [composerAttachments, setComposerAttachmentState] = useState<
|
||
ComposerAttachmentItem[]
|
||
>(() =>
|
||
draftKey
|
||
? loadDraftAttachments(draftKey).map((item) => ({
|
||
...item,
|
||
size: item.size ?? 0,
|
||
id: `receipt:${item.attachmentId}`,
|
||
status: "attached",
|
||
}))
|
||
: [],
|
||
);
|
||
const composerAttachmentsRef = useRef(composerAttachments);
|
||
function setComposerAttachments(
|
||
update:
|
||
| ComposerAttachmentItem[]
|
||
| ((previous: ComposerAttachmentItem[]) => ComposerAttachmentItem[]),
|
||
) {
|
||
const next =
|
||
typeof update === "function"
|
||
? update(composerAttachmentsRef.current)
|
||
: update;
|
||
composerAttachmentsRef.current = next;
|
||
setComposerAttachmentState(next);
|
||
const pending = pendingDraftRef.current;
|
||
if (pending && pending.draftKey === draftKey) {
|
||
saveDraftAttachments(pending.draftKey, next
|
||
.filter(item => item.status === "attached" && item.attachmentId)
|
||
.map(item => ({ ...item, inline: item.inline === true })), pending.attemptId);
|
||
}
|
||
}
|
||
const dragDepthRef = useRef(0);
|
||
const effectiveSuggestedAssigneeValue =
|
||
suggestedAssigneeValue ?? currentAssigneeValue;
|
||
const manualAssigneeRef = useRef(false);
|
||
const assigneeDraftKeyRef = useRef(draftKey);
|
||
const selectAssignee = (value: string) => {
|
||
manualAssigneeRef.current = true;
|
||
setReassignTarget(value);
|
||
};
|
||
const [reassignTarget, setReassignTarget] = useState(
|
||
effectiveSuggestedAssigneeValue,
|
||
);
|
||
const [runSettings, setRunSettings] = useState<ComposerRunSettings | null>(null);
|
||
useEffect(() => setRunSettings(null), [draftKey, currentAssigneeValue]);
|
||
const [noAssigneeDialogOpen, setNoAssigneeDialogOpen] = useState(false);
|
||
const [dismissedCoachToken, setDismissedCoachToken] = useState<string | null>(
|
||
null,
|
||
);
|
||
const resolvedIssueWorkMode: IssueWorkMode = issueWorkMode ?? "standard";
|
||
const [pendingWorkMode, setPendingWorkMode] = useState<IssueWorkMode>(
|
||
resolvedIssueWorkMode,
|
||
);
|
||
const canToggleWorkMode = typeof onWorkModeChange === "function";
|
||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||
const reassignTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||
const focusAssigneeOnDialogCloseRef = useRef(false);
|
||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const canAcceptFiles =
|
||
!uncertainSubmission && Boolean(onImageUpload || onAttachImage);
|
||
const uploadUnsettled =
|
||
attaching || composerAttachments.some((item) => item.status !== "attached");
|
||
const attachedFiles = composerAttachments.filter(
|
||
(item) => item.status === "attached" && !item.inline && item.contentPath,
|
||
);
|
||
|
||
function queueViewportRestore(
|
||
snapshot: ReturnType<typeof captureComposerViewportSnapshot>,
|
||
) {
|
||
if (!snapshot) return;
|
||
requestAnimationFrame(() => {
|
||
restoreComposerViewportSnapshot(snapshot, composerContainerRef.current);
|
||
});
|
||
}
|
||
|
||
function focusComposer() {
|
||
if (typeof composerContainerRef.current?.scrollIntoView === "function") {
|
||
composerContainerRef.current.scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "end",
|
||
});
|
||
}
|
||
requestAnimationFrame(() => {
|
||
window.scrollBy({
|
||
top: COMPOSER_FOCUS_SCROLL_PADDING_PX,
|
||
behavior: "smooth",
|
||
});
|
||
editorRef.current?.focus();
|
||
});
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!draftKey || (draftRecovery?.key === draftKey && !draftRecovery.persisted)) return;
|
||
setBody(loadDraft(draftKey));
|
||
setComposerAttachments(
|
||
loadDraftAttachments(draftKey).map((item) => ({
|
||
...item,
|
||
size: item.size ?? 0,
|
||
id: `receipt:${item.attachmentId}`,
|
||
status: "attached",
|
||
})),
|
||
);
|
||
}, [draftKey]);
|
||
|
||
// A server receipt for this exact request settles a restored submission.
|
||
// Text equality is not delivery proof: users may intentionally repeat text.
|
||
useEffect(() => {
|
||
if (
|
||
!uncertainSubmission ||
|
||
!(confirmedSubmissionIds.has(uncertainSubmission.attemptId) || resentSubmissionIds.has(uncertainSubmission.attemptId))
|
||
) return;
|
||
const { attemptId } = uncertainSubmission;
|
||
const reconciled = reconciledSubmissionRef.current;
|
||
if (reconciled && reconciled.draftKey === draftKey && reconciled.attemptId === attemptId) return;
|
||
const nextDraft = uncertainSubmission.nextDraftOffset === undefined
|
||
? "" : bodyRef.current.slice(uncertainSubmission.nextDraftOffset);
|
||
const submittedIds = uncertainSubmission.submittedAttachmentIds;
|
||
let nextAttachments = submittedIds
|
||
? composerAttachmentsRef.current.filter(item => !item.attachmentId || !submittedIds.includes(item.attachmentId))
|
||
: [];
|
||
if (draftKey) {
|
||
const retained = loadDraftSubmission(draftKey);
|
||
// Settle storage from its own snapshot, never from this tab's stale copy.
|
||
// A different retained attempt is owned by its original tab.
|
||
if (retained?.attemptId === attemptId) settleDraftSubmission(draftKey, attemptId);
|
||
const storedDraft = loadDraftIfAvailable(draftKey);
|
||
const storedAttachments = loadDraftAttachments(draftKey).filter(item => !submittedIds?.includes(item.attachmentId));
|
||
const foreignAttempt = retained && retained.attemptId !== attemptId;
|
||
const differentAttachments = JSON.stringify(nextAttachments.map(item => item.attachmentId).sort()) !==
|
||
JSON.stringify(storedAttachments.map(item => item.attachmentId).sort());
|
||
if (storedDraft !== null && (foreignAttempt || !retained || storedDraft !== nextDraft || differentAttachments)) {
|
||
// Text has no cross-tab ordering. Preserve each buffer separately. When
|
||
// both snapshots describe the same text, retain all attachment receipts.
|
||
if (!foreignAttempt && (storedDraft === nextDraft || storedDraft === bodyRef.current)) {
|
||
const ids = new Set(nextAttachments.map(item => item.attachmentId));
|
||
const combined = [...nextAttachments, ...storedAttachments.filter(item => !ids.has(item.attachmentId)).map(item => ({
|
||
...item, size: item.size ?? 0, id: `receipt:${item.attachmentId}`, status: "attached" as const,
|
||
}))];
|
||
// Each draft is limited to 20 receipts. If their union exceeds that,
|
||
// keep the local selection here and the other selection in shared storage.
|
||
if (combined.length <= 20) nextAttachments = combined;
|
||
}
|
||
const recovered = preserveDraftInTab(sharedDraftKey!, nextDraft, nextAttachments);
|
||
// Fence old effect cleanups before switching keys or updating bodyRef.
|
||
retiredDraftKeyRef.current = draftKey;
|
||
setDraftRecovery({ sourceKey: sharedDraftKey!, ...recovered });
|
||
}
|
||
// With unavailable storage, the confirmed in-memory attempt still settles.
|
||
}
|
||
reconciledSubmissionRef.current = { draftKey, attemptId };
|
||
setUncertainSubmission(null);
|
||
setBody(nextDraft);
|
||
bodyRef.current = nextDraft;
|
||
setComposerAttachments(nextAttachments);
|
||
}, [confirmedSubmissionIds, draftKey, resentSubmissionIds, sharedDraftKey, uncertainSubmission]);
|
||
|
||
async function resendUncertainSubmission(): Promise<SendOutcome> {
|
||
if (!isCurrentAccount()) return "pending";
|
||
const submission = uncertainSubmission;
|
||
const request = replayableRequest(submission, draftOwner);
|
||
if (!submission || !request) return "rejected";
|
||
const taskKey = draftKey;
|
||
const args = [
|
||
request.body,
|
||
request.reopen,
|
||
request.reassignment,
|
||
request.attachmentIds,
|
||
submission.attemptId,
|
||
] as const;
|
||
try {
|
||
if (request.runSettings) await onSend(...args, request.runSettings as unknown as ComposerRunSettings);
|
||
else await onSend(...args);
|
||
} catch (error) {
|
||
if (!isCurrentAccount()) return "pending";
|
||
const failure = classifySendFailure(error);
|
||
if (failure === "rejected") {
|
||
// A definitive answer: the text is already back in the editor.
|
||
if (taskKey) clearDraftSubmission(taskKey, submission.attemptId);
|
||
if (mountedTaskKey.current === taskKey) setUncertainSubmission(null);
|
||
}
|
||
return failure;
|
||
}
|
||
if (mountedTaskKey.current === taskKey) {
|
||
setResentSubmissionIds((current) => new Set([...current, submission.attemptId]));
|
||
} else if (taskKey) {
|
||
settleDraftSubmission(taskKey, submission.attemptId);
|
||
}
|
||
return "sent";
|
||
}
|
||
|
||
const resendSourceId = useId();
|
||
const autoResend = useAutoResend({
|
||
sourceId: `issue-chat-composer:${resendSourceId}`,
|
||
active: Boolean(replayableRequest(uncertainSubmission, draftOwner)) && !submitting,
|
||
resend: resendUncertainSubmission,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!draftKey || retiredDraftKeyRef.current === draftKey ||
|
||
submitting ||
|
||
composerAttachments !== composerAttachmentsRef.current
|
||
)
|
||
return;
|
||
saveDraftAttachments(
|
||
draftKey,
|
||
composerAttachments.filter(
|
||
(item) => item.status === "attached" && item.attachmentId,
|
||
),
|
||
);
|
||
}, [composerAttachments, draftKey, submitting]);
|
||
|
||
useEffect(() => {
|
||
if (!draftKey || submitting) return;
|
||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||
draftTimer.current = setTimeout(() => {
|
||
if (retiredDraftKeyRef.current !== draftKey) saveDraft(draftKey, body);
|
||
}, DRAFT_DEBOUNCE_MS);
|
||
}, [body, draftKey, submitting]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||
if (draftKey && retiredDraftKeyRef.current !== draftKey && !submittingRef.current)
|
||
saveDraft(draftKey, bodyRef.current);
|
||
};
|
||
}, [draftKey]);
|
||
|
||
useEffect(() => {
|
||
if (!draftKey) return;
|
||
const flushDraft = () => {
|
||
if (retiredDraftKeyRef.current !== draftKey && !submittingRef.current) saveDraft(draftKey, bodyRef.current);
|
||
};
|
||
window.addEventListener("beforeunload", flushDraft);
|
||
return () => window.removeEventListener("beforeunload", flushDraft);
|
||
}, [draftKey]);
|
||
|
||
useEffect(() => {
|
||
if (assigneeDraftKeyRef.current !== draftKey) {
|
||
assigneeDraftKeyRef.current = draftKey;
|
||
manualAssigneeRef.current = false;
|
||
}
|
||
if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue);
|
||
}, [draftKey, effectiveSuggestedAssigneeValue]);
|
||
|
||
useEffect(() => {
|
||
setPendingWorkMode(resolvedIssueWorkMode);
|
||
}, [resolvedIssueWorkMode]);
|
||
|
||
useImperativeHandle(
|
||
forwardedRef,
|
||
() => ({
|
||
focus: focusComposer,
|
||
restoreDraft: (submittedBody: string) => {
|
||
setBody((current) =>
|
||
restoreSubmittedCommentDraft({
|
||
currentBody: current,
|
||
submittedBody,
|
||
}),
|
||
);
|
||
focusComposer();
|
||
},
|
||
}),
|
||
[],
|
||
);
|
||
|
||
const showStop =
|
||
!submitting &&
|
||
!attaching &&
|
||
body.trim().length === 0 &&
|
||
composerAttachments.length === 0 &&
|
||
Boolean(onStop || stopControl.stopping);
|
||
|
||
async function handleSubmit() {
|
||
if (composerPause) return;
|
||
const trimmed = body.trim();
|
||
if (
|
||
(!trimmed && attachedFiles.length === 0) ||
|
||
submitting ||
|
||
uploadUnsettled ||
|
||
uncertainSubmission
|
||
)
|
||
return;
|
||
|
||
const composerHasAssigneePicker =
|
||
enableReassign && reassignOptions.length > 0;
|
||
if (
|
||
composerHasAssigneePicker &&
|
||
isUnassignedReassignValue(reassignTarget)
|
||
) {
|
||
setNoAssigneeDialogOpen(true);
|
||
return;
|
||
}
|
||
|
||
await submitComment();
|
||
}
|
||
|
||
async function submitComment() {
|
||
if (composerPause) return;
|
||
const trimmed = body.trim();
|
||
if (
|
||
(!trimmed && attachedFiles.length === 0) ||
|
||
submitting ||
|
||
uploadUnsettled ||
|
||
uncertainSubmission
|
||
)
|
||
return;
|
||
|
||
const hasReassignment =
|
||
enableReassign && reassignTarget !== currentAssigneeValue;
|
||
const reassignment = hasReassignment
|
||
? (parseReassignment(reassignTarget) ?? undefined)
|
||
: undefined;
|
||
const reopen = shouldImplicitlyReopenComment(
|
||
issueStatus,
|
||
hasReassignment ? reassignTarget : currentAssigneeValue,
|
||
)
|
||
? true
|
||
: undefined;
|
||
const submittedBody = [
|
||
trimmed,
|
||
...attachedFiles.map(
|
||
(item) =>
|
||
`[${item.name.replace(/[[\]]/g, "\\$&")}](${item.contentPath})`,
|
||
),
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
const submittedAttachmentKeys = new Set(
|
||
composerAttachments.map((item) => item.id),
|
||
);
|
||
const attachmentIds = [
|
||
...new Set(
|
||
composerAttachments
|
||
.filter(
|
||
(item) =>
|
||
item.status === "attached" &&
|
||
item.attachmentId &&
|
||
(!item.inline ||
|
||
(item.contentPath && trimmed.includes(item.contentPath))),
|
||
)
|
||
.map((item) => item.attachmentId!),
|
||
),
|
||
];
|
||
const viewportSnapshot = captureComposerViewportSnapshot(
|
||
composerContainerRef.current,
|
||
);
|
||
|
||
const workModeChanged = pendingWorkMode !== resolvedIssueWorkMode;
|
||
if (draftKey) saveDraft(draftKey, trimmed);
|
||
setSubmitting(true);
|
||
bodyRef.current = "";
|
||
setBody("");
|
||
let attemptId: string | null = null;
|
||
let request: ComposerSendRequest | null = null;
|
||
const requestOwner = draftOwner;
|
||
try {
|
||
if (workModeChanged && onWorkModeChange) {
|
||
await onWorkModeChange(pendingWorkMode);
|
||
}
|
||
const retained = draftKey ? loadDraftSubmission(draftKey) : null;
|
||
if (retained) {
|
||
setUncertainSubmission(retained);
|
||
setBody(trimmed);
|
||
return;
|
||
}
|
||
attemptId = crypto.randomUUID();
|
||
if (requestOwner === undefined) markRequestUnbound(attemptId);
|
||
// The exact request is stored before it is sent, so a lost response or
|
||
// an outage can resend it with the same ID instead of losing it.
|
||
request = {
|
||
body: submittedBody,
|
||
...(reopen === undefined ? {} : { reopen }),
|
||
...(reassignment ? { reassignment } : {}),
|
||
...(attachmentIds.length ? { attachmentIds } : {}),
|
||
...(runSettings ? { runSettings: runSettings as unknown as Record<string, unknown> } : {}),
|
||
};
|
||
if (draftKey) {
|
||
saveDraft(draftKey, trimmed);
|
||
saveDraftSubmission(draftKey, { attemptId, reviewed: false, request, requestOwner });
|
||
pendingDraftRef.current = { draftKey, attemptId, submittedBody: trimmed, submittedAttachmentIds: attachmentIds, request, requestOwner };
|
||
changeBody(bodyRef.current);
|
||
}
|
||
// assistant-ui thread.append is fire-and-forget. Await the actual Board
|
||
// mutation; it already owns optimistic echo and durable error handling.
|
||
const sendPromise = runSettings
|
||
? onSend(submittedBody, reopen, reassignment,
|
||
attachmentIds.length ? attachmentIds : undefined, attemptId, runSettings)
|
||
: onSend(submittedBody, reopen, reassignment,
|
||
attachmentIds.length ? attachmentIds : undefined, attemptId);
|
||
queueViewportRestore(viewportSnapshot);
|
||
await sendPromise;
|
||
// Settle the captured task even if the user navigated away. The exact
|
||
// attempt guard preserves any newer submission in this or another tab.
|
||
if (draftKey) settleDraftSubmission(draftKey, attemptId,
|
||
mountedTaskKey.current === draftKey ? bodyRef.current : undefined);
|
||
if (mountedTaskKey.current !== draftKey) return;
|
||
setComposerAttachments((current) =>
|
||
current.filter((item) => !submittedAttachmentKeys.has(item.id)),
|
||
);
|
||
manualAssigneeRef.current = false;
|
||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||
setRunSettings(null);
|
||
} catch (error) {
|
||
if (mountedTaskKey.current !== draftKey) return;
|
||
const nextDraft = bodyRef.current;
|
||
// Not delivered, or not known to be: keep it and resend with the same
|
||
// ID when the connection allows. The server dedupes by that ID.
|
||
if (attemptId && request && classifySendFailure(error) === "pending") {
|
||
const uncertain = {
|
||
attemptId, reviewed: false,
|
||
nextDraftOffset: trimmed.length + (nextDraft ? 2 : 0),
|
||
submittedAttachmentIds: attachmentIds,
|
||
request,
|
||
requestOwner,
|
||
};
|
||
setUncertainSubmission(uncertain);
|
||
if (draftKey && loadDraftSubmission(draftKey)?.attemptId === attemptId)
|
||
saveDraftSubmission(draftKey, uncertain);
|
||
} else if (draftKey && attemptId)
|
||
clearDraftSubmission(draftKey, attemptId);
|
||
const restoredBody = nextDraft ? `${trimmed}\n\n${nextDraft}` : trimmed;
|
||
if (draftKey) saveDraft(draftKey, restoredBody, attemptId ?? undefined);
|
||
setBody(restoredBody);
|
||
} finally {
|
||
if (pendingDraftRef.current?.attemptId === attemptId) pendingDraftRef.current = null;
|
||
setSubmitting(false);
|
||
queueViewportRestore(viewportSnapshot);
|
||
}
|
||
}
|
||
|
||
async function attachFile(
|
||
file: File,
|
||
insertInline = true,
|
||
): Promise<string | undefined> {
|
||
const attachmentId = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
|
||
const inline = file.type.startsWith("image/");
|
||
setComposerAttachments((prev) => [
|
||
...prev,
|
||
{
|
||
id: attachmentId,
|
||
name: file.name,
|
||
size: file.size,
|
||
status: "uploading",
|
||
inline,
|
||
},
|
||
]);
|
||
|
||
try {
|
||
if (!onAttachImage && onImageUpload && inline) {
|
||
const url = await onImageUpload(file);
|
||
if (
|
||
!composerAttachmentsRef.current.some(
|
||
(item) => item.id === attachmentId,
|
||
)
|
||
)
|
||
return undefined;
|
||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||
const markdown = ``;
|
||
if (insertInline)
|
||
changeBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
|
||
setComposerAttachments((prev) =>
|
||
prev.map((item) =>
|
||
item.id === attachmentId
|
||
? { ...item, status: "attached", contentPath: url }
|
||
: item,
|
||
),
|
||
);
|
||
return url;
|
||
} else if (onAttachImage) {
|
||
const attachment = await onAttachImage(file);
|
||
if (!attachment?.contentPath)
|
||
throw new Error("Upload did not return a file URL");
|
||
if (
|
||
!composerAttachmentsRef.current.some(
|
||
(item) => item.id === attachmentId,
|
||
)
|
||
)
|
||
return undefined;
|
||
if (inline && insertInline) {
|
||
const markdown = `![${file.name.replace(/[[\]]/g, "\\$&")}](${attachment.contentPath})`;
|
||
changeBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
|
||
}
|
||
setComposerAttachments((prev) =>
|
||
prev.map((item) =>
|
||
item.id === attachmentId
|
||
? {
|
||
...item,
|
||
status: "attached",
|
||
attachmentId: attachment.id,
|
||
contentPath: attachment?.contentPath,
|
||
name: attachment?.originalFilename ?? item.name,
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
return attachment.contentPath;
|
||
} else {
|
||
setComposerAttachments((prev) =>
|
||
prev.map((item) =>
|
||
item.id === attachmentId
|
||
? {
|
||
...item,
|
||
status: "error",
|
||
error: "This file type cannot be attached here",
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}
|
||
} catch (err) {
|
||
setComposerAttachments((prev) =>
|
||
prev.map((item) =>
|
||
item.id === attachmentId
|
||
? {
|
||
...item,
|
||
status: "error",
|
||
error: describeError(err, { action: "upload the file" }).body,
|
||
file,
|
||
}
|
||
: item,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Retry a failed upload from its kept file; only ever on an explicit click. */
|
||
function retryUpload(attachment: ComposerAttachmentItem) {
|
||
const file = attachment.file;
|
||
if (!file) return;
|
||
setComposerAttachments((current) => current.filter((item) => item.id !== attachment.id));
|
||
void attachFile(file);
|
||
}
|
||
|
||
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
||
const file = evt.target.files?.[0];
|
||
if (!file) return;
|
||
setAttaching(true);
|
||
try {
|
||
await attachFile(file);
|
||
} finally {
|
||
setAttaching(false);
|
||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||
}
|
||
}
|
||
|
||
async function handleDroppedFiles(files: FileList | null | undefined) {
|
||
if (!files || files.length === 0) return;
|
||
setAttaching(true);
|
||
try {
|
||
for (const file of Array.from(files)) {
|
||
await attachFile(file);
|
||
}
|
||
} finally {
|
||
setAttaching(false);
|
||
}
|
||
}
|
||
|
||
function resetDragState() {
|
||
dragDepthRef.current = 0;
|
||
setIsDragOver(false);
|
||
}
|
||
|
||
function handleFileDragEnter(evt: ReactDragEvent<HTMLDivElement>) {
|
||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
dragDepthRef.current += 1;
|
||
setIsDragOver(true);
|
||
}
|
||
|
||
function handleFileDragOver(evt: ReactDragEvent<HTMLDivElement>) {
|
||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
evt.dataTransfer.dropEffect = "copy";
|
||
}
|
||
|
||
function handleFileDragLeave(evt: ReactDragEvent<HTMLDivElement>) {
|
||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||
}
|
||
|
||
function handleFileDrop(evt: ReactDragEvent<HTMLDivElement>) {
|
||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
resetDragState();
|
||
void handleDroppedFiles(evt.dataTransfer?.files);
|
||
}
|
||
|
||
const canSubmit =
|
||
!submitting &&
|
||
!uploadUnsettled &&
|
||
!uncertainSubmission &&
|
||
(!!body.trim() || attachedFiles.length > 0);
|
||
|
||
// Interrupt-handoff clarity (PAP-10669): preview what this comment will durably
|
||
// do, and coach plain agent names toward real mentions.
|
||
const agentMentionOptions = useMemo<HandoffAgentMention[]>(
|
||
() =>
|
||
mentions
|
||
.filter((m) => (m.kind ?? "agent") === "agent" && (m.agentId ?? m.id))
|
||
.map((m) => ({
|
||
agentId: m.agentId ?? m.id.replace(/^agent:/, ""),
|
||
name: m.name,
|
||
})),
|
||
[mentions],
|
||
);
|
||
const handoffResolvers = useMemo<HandoffChipResolvers>(
|
||
() => ({
|
||
agentMap,
|
||
currentUserId,
|
||
resolveUserLabel: (userId: string) =>
|
||
formatAssigneeUserLabel(userId, null, userLabelMap),
|
||
}),
|
||
[agentMap, currentUserId, userLabelMap],
|
||
);
|
||
const mentionedAgentIds = useMemo(() => extractAgentMentionIds(body), [body]);
|
||
const plainNameCandidate = useMemo(
|
||
() =>
|
||
mentionedAgentIds.length > 0
|
||
? null
|
||
: findPlainAgentNameCandidate(body, agentMentionOptions),
|
||
[body, mentionedAgentIds, agentMentionOptions],
|
||
);
|
||
const handoffPreview = useMemo(
|
||
() =>
|
||
computeComposerHandoffPreview({
|
||
reassignTarget,
|
||
currentAssigneeValue,
|
||
hasActiveRun,
|
||
bodyHasAgentMention: mentionedAgentIds.length > 0,
|
||
mentionedAgentId: mentionedAgentIds[0] ?? null,
|
||
plainNameCandidate,
|
||
}),
|
||
[
|
||
reassignTarget,
|
||
currentAssigneeValue,
|
||
hasActiveRun,
|
||
mentionedAgentIds,
|
||
plainNameCandidate,
|
||
],
|
||
);
|
||
const coachVisible = Boolean(
|
||
plainNameCandidate &&
|
||
plainNameCandidate.matchedText !== dismissedCoachToken,
|
||
);
|
||
const coachAgentName = plainNameCandidate
|
||
? (agentMap?.get(plainNameCandidate.agentId)?.name ??
|
||
plainNameCandidate.matchedText)
|
||
: "";
|
||
|
||
function insertCoachMention() {
|
||
if (!plainNameCandidate) return;
|
||
const option = mentions.find(
|
||
(m) =>
|
||
(m.agentId ?? m.id.replace(/^agent:/, "")) ===
|
||
plainNameCandidate.agentId,
|
||
);
|
||
const agentId = plainNameCandidate.agentId;
|
||
const name = option?.name ?? plainNameCandidate.matchedText;
|
||
const markdown = `[@${name}](${buildAgentMentionHref(agentId, option?.agentIcon ?? null)}) `;
|
||
// Replace the first bare occurrence of the matched token (outside links).
|
||
const tokenRe = new RegExp(
|
||
`(?<![\\w@/])${plainNameCandidate.matchedText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/])`,
|
||
"i",
|
||
);
|
||
changeBody((current) => {
|
||
if (tokenRe.test(current))
|
||
return current.replace(tokenRe, markdown.trimEnd());
|
||
return current ? `${current} ${markdown}` : markdown;
|
||
});
|
||
setDismissedCoachToken(plainNameCandidate.matchedText);
|
||
}
|
||
|
||
if (composerPause) {
|
||
return <TaskChatPausedTakeover {...composerPause} hasDraft={Boolean(body.trim() || attachedFiles.length)} />;
|
||
}
|
||
|
||
if (composerDisabledReason) {
|
||
return (
|
||
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||
{composerDisabledReason}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const pendingWorkModeMeta = workModeMetaFor(pendingWorkMode);
|
||
|
||
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
|
||
// Match the period via both `code` and `key`: iOS Safari with a hardware
|
||
// keyboard often leaves `code` empty for cmd-period, so relying on it alone
|
||
// lets the event fall through and triggers Safari's default cancel/dismiss
|
||
// (which closes the view). Catching `key === "."` keeps the shortcut working
|
||
// on iOS while preserving desktop behavior.
|
||
const isPeriod = evt.code === "Period" || evt.key === ".";
|
||
if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return;
|
||
evt.preventDefault();
|
||
setPendingWorkMode((current) => nextWorkMode(current));
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={composerContainerRef}
|
||
data-testid="issue-chat-composer"
|
||
data-pending-work-mode={pendingWorkMode}
|
||
className={cn(
|
||
"relative rounded-md border border-border/70 bg-background/95 p-(--sz-15px) shadow-(--shadow-extract-4) backdrop-blur transition-(--tp-border-color-background-color-box-shadow) duration-150 supports-[backdrop-filter]:bg-background/85 dark:shadow-(--shadow-extract-5)",
|
||
pendingWorkModeMeta.classes.container,
|
||
isDragOver &&
|
||
"border-primary/45 bg-background shadow-(--shadow-extract-7)",
|
||
)}
|
||
onKeyDownCapture={handleComposerKeyDown}
|
||
onDragEnterCapture={handleFileDragEnter}
|
||
onDragOverCapture={handleFileDragOver}
|
||
onDragLeaveCapture={handleFileDragLeave}
|
||
onDropCapture={handleFileDrop}
|
||
>
|
||
{isDragOver && canAcceptFiles ? (
|
||
<div
|
||
data-testid="issue-chat-composer-drop-overlay"
|
||
className="pointer-events-none absolute inset-2 z-30 flex items-center justify-center rounded-sm border border-dashed border-primary/55 bg-background/75 px-4 py-3 text-center shadow-sm backdrop-blur-(--blur-2px) dark:bg-background/65"
|
||
>
|
||
<div className="flex max-w-md items-center gap-3 rounded-md bg-background/80 px-3 py-2 text-left shadow-sm ring-1 ring-border/60">
|
||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||
<Paperclip className="h-4 w-4" />
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium text-foreground">
|
||
Drop to upload
|
||
</div>
|
||
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||
Images insert into the reply. Other files are added to this
|
||
task.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{draftRecovery && draftRecovery.sourceKey === sharedDraftKey ? (
|
||
<p role="status" className="mb-3 text-sm text-muted-foreground">
|
||
Another tab changed the saved draft. This draft is kept separately in this tab.
|
||
{!draftRecovery.persisted ? " Browser storage is unavailable; copy your text before leaving." : " It will be restored if you reload this tab."}
|
||
</p>
|
||
) : null}
|
||
{uncertainSubmission ? (
|
||
<PendingSendNotice
|
||
className="mb-3"
|
||
noun="comment"
|
||
persisted={Boolean(draftKey && loadDraftSubmission(draftKey)?.attemptId === uncertainSubmission.attemptId && replayableRequest(loadDraftSubmission(draftKey), draftOwner))}
|
||
resending={autoResend.resending}
|
||
stalled={autoResend.stalled}
|
||
onResendNow={replayableRequest(uncertainSubmission, draftOwner) ? autoResend.resendNow : undefined}
|
||
onCancel={() => {
|
||
// Stop resending; the text stays in the editor to edit or send again.
|
||
if (draftKey) clearDraftSubmission(draftKey, uncertainSubmission.attemptId);
|
||
setUncertainSubmission(null);
|
||
}}
|
||
/>
|
||
) : null}
|
||
<MarkdownEditor
|
||
ref={editorRef}
|
||
readOnly={!!uncertainSubmission}
|
||
value={body}
|
||
onChange={changeBody}
|
||
placeholder="Reply"
|
||
mentions={mentions}
|
||
onSubmit={handleSubmit}
|
||
submitKey={submitKey}
|
||
imageUploadHandler={
|
||
canAcceptFiles
|
||
? async (file) => {
|
||
const url = await attachFile(file, false);
|
||
if (!url) throw new Error("Upload did not return a file URL");
|
||
return url;
|
||
}
|
||
: undefined
|
||
}
|
||
fileDropTarget="parent"
|
||
bordered={false}
|
||
contentClassName="max-h-(--sz-28dvh) overflow-y-auto pr-1 pb-2 text-sm scrollbar-auto-hide"
|
||
/>
|
||
|
||
{coachVisible && plainNameCandidate ? (
|
||
<div className="mt-2">
|
||
<ComposerMentionCoach
|
||
candidate={plainNameCandidate}
|
||
agentDisplayName={coachAgentName}
|
||
onInsert={insertCoachMention}
|
||
onDismiss={() =>
|
||
setDismissedCoachToken(plainNameCandidate.matchedText)
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{composerHint ? (
|
||
<div className="inline-flex items-center rounded-full border border-border/70 bg-muted/30 px-2 py-1 text-(length:--text-micro) text-muted-foreground">
|
||
{composerHint}
|
||
</div>
|
||
) : null}
|
||
|
||
{composerAttachments.length > 0 ? (
|
||
<div
|
||
data-testid="issue-chat-composer-attachments"
|
||
className="mb-3 mt-2 space-y-1.5 rounded-md border border-dashed border-border/80 bg-muted/20 p-2"
|
||
>
|
||
{composerAttachments.map((attachment) => {
|
||
const sizeLabel = formatAttachmentSize(attachment.size);
|
||
const statusLabel =
|
||
attachment.status === "uploading"
|
||
? "Uploading to task"
|
||
: attachment.status === "error"
|
||
? (attachment.error ?? "Upload failed")
|
||
: attachment.inline
|
||
? "Inserted inline"
|
||
: "Attached to task";
|
||
return (
|
||
<div
|
||
key={attachment.id}
|
||
className={cn(
|
||
"flex min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-xs",
|
||
attachment.status === "error"
|
||
? "bg-destructive/10 text-destructive"
|
||
: "bg-background/70 text-muted-foreground",
|
||
)}
|
||
>
|
||
{attachment.status === "uploading" ? (
|
||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||
) : attachment.status === "attached" ? (
|
||
<Check className="h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-400" />
|
||
) : (
|
||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||
)}
|
||
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
||
{attachment.name}
|
||
</span>
|
||
{sizeLabel ? (
|
||
<span className="shrink-0 text-muted-foreground">
|
||
{sizeLabel}
|
||
</span>
|
||
) : null}
|
||
<span className="shrink-0 text-muted-foreground">
|
||
{statusLabel}
|
||
</span>
|
||
{attachment.status === "error" && attachment.file ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={`Retry ${attachment.name}`}
|
||
disabled={!!uncertainSubmission}
|
||
onClick={() => retryUpload(attachment)}
|
||
>
|
||
<RotateCw className="h-3.5 w-3.5" aria-hidden />
|
||
</Button>
|
||
) : null}
|
||
{!attachment.inline || attachment.status !== "attached" ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
aria-label={`Remove ${attachment.name}`}
|
||
disabled={!!uncertainSubmission}
|
||
onClick={() =>
|
||
setComposerAttachments((current) =>
|
||
current.filter((item) => item.id !== attachment.id),
|
||
)
|
||
}
|
||
>
|
||
<X className="h-3.5 w-3.5" aria-hidden />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
{shouldRenderComposerHandoffPreview(body, handoffPreview) ? (
|
||
<div className="my-2">
|
||
<ComposerHandoffPreviewRow
|
||
preview={handoffPreview}
|
||
resolvers={handoffResolvers}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||
<div className="mr-auto flex items-center gap-2">
|
||
{canAcceptFiles ? (
|
||
<input ref={attachInputRef} type="file" className="hidden" onChange={handleAttachFile} />
|
||
) : null}
|
||
<ComposerAddMenu mode={pendingWorkMode}
|
||
onModeChange={canToggleWorkMode ? setPendingWorkMode : undefined}
|
||
onAttachFile={canAcceptFiles ? () => attachInputRef.current?.click() : undefined}
|
||
attachDisabled={attaching}
|
||
disabled={!!uncertainSubmission}
|
||
triggerTestId="issue-chat-composer-add" menuTestId="issue-chat-composer-add-menu" />
|
||
<ComposerModeChip mode={pendingWorkMode}
|
||
onRemove={canToggleWorkMode ? () => setPendingWorkMode("standard") : undefined}
|
||
disabled={!!uncertainSubmission}
|
||
testId="issue-chat-composer-work-mode-chip" />
|
||
</div>
|
||
|
||
{enableReassign && reassignOptions.length > 0 && companyId && agentMap ? (
|
||
<ComposerRunSettingsPicker
|
||
companyId={companyId}
|
||
assigneeValue={reassignTarget}
|
||
currentAssigneeValue={currentAssigneeValue}
|
||
options={reassignOptions}
|
||
agents={agentMap}
|
||
overrides={assigneeAdapterOverrides}
|
||
settings={runSettings}
|
||
onSettingsChange={setRunSettings}
|
||
onAssigneeChange={selectAssignee}
|
||
triggerRef={reassignTriggerRef}
|
||
renderAssigneeIdentity={(value) => {
|
||
const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null;
|
||
return selected ? <AgentAvatar agent={selected} size={16} className="size-4 shrink-0" /> : null;
|
||
}}
|
||
/>
|
||
) : enableReassign && reassignOptions.length > 0 ? (
|
||
<InlineEntitySelector
|
||
ref={reassignTriggerRef}
|
||
value={reassignTarget}
|
||
options={reassignOptions}
|
||
placeholder="Responsible"
|
||
noneLabel="No responsible"
|
||
searchPlaceholder="Search responsible..."
|
||
emptyMessage="No responsible found."
|
||
onChange={selectAssignee}
|
||
className="h-8 text-xs"
|
||
renderTriggerValue={(option) => {
|
||
if (!option)
|
||
return (
|
||
<span className="text-muted-foreground">Responsible</span>
|
||
);
|
||
const agentId = option.id.startsWith("agent:")
|
||
? option.id.slice("agent:".length)
|
||
: null;
|
||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||
return (
|
||
<>
|
||
{agent ? (
|
||
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||
) : null}
|
||
<span className="truncate">{option.label}</span>
|
||
</>
|
||
);
|
||
}}
|
||
renderOption={(option) => {
|
||
if (!option.id)
|
||
return <span className="truncate">{option.label}</span>;
|
||
const agentId = option.id.startsWith("agent:")
|
||
? option.id.slice("agent:".length)
|
||
: null;
|
||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||
return (
|
||
<>
|
||
{agent ? (
|
||
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||
) : null}
|
||
<span className="truncate">{option.label}</span>
|
||
</>
|
||
);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{showStop ? (
|
||
<Button
|
||
size="icon-sm"
|
||
disabled={stopControl.stopping}
|
||
onClick={() => void stopControl.stop()}
|
||
aria-label={stopControl.stopping ? "Stopping…" : "Stop"}
|
||
title="Stop response"
|
||
>
|
||
{stopControl.stopping ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||
) : (
|
||
<Square className="h-4 w-4 fill-current" aria-hidden />
|
||
)}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
disabled={!canSubmit}
|
||
onClick={() => void handleSubmit()}
|
||
>
|
||
{submitting ? "Posting..." : "Send"}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{stopControl.error ? (
|
||
<p role="alert" className="text-xs text-destructive">
|
||
{stopControl.error}
|
||
</p>
|
||
) : null}
|
||
|
||
{/* No-assignee warning modal (PAP-128 C): replaces the old press-Send-again toast. */}
|
||
<AlertDialog
|
||
open={noAssigneeDialogOpen}
|
||
onOpenChange={setNoAssigneeDialogOpen}
|
||
>
|
||
<AlertDialogContent
|
||
data-testid="issue-chat-no-assignee-dialog"
|
||
onCloseAutoFocus={(event) => {
|
||
if (!focusAssigneeOnDialogCloseRef.current) return;
|
||
event.preventDefault();
|
||
focusAssigneeOnDialogCloseRef.current = false;
|
||
reassignTriggerRef.current?.focus();
|
||
}}
|
||
>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>No responsible selected</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This comment will be posted without an assignee, so no agent will
|
||
be woken to act on it. Go back to pick a responsible, or send
|
||
anyway.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel
|
||
data-testid="issue-chat-no-assignee-go-back"
|
||
onClick={() => {
|
||
focusAssigneeOnDialogCloseRef.current = true;
|
||
}}
|
||
>
|
||
Go back
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
data-testid="issue-chat-no-assignee-send-anyway"
|
||
onClick={() => {
|
||
void submitComment();
|
||
}}
|
||
>
|
||
Send anyway
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export function IssueChatThread({
|
||
comments,
|
||
interactions = [],
|
||
feedbackVotes = [],
|
||
feedbackDataSharingPreference = "prompt",
|
||
feedbackTermsUrl = null,
|
||
linkedRuns = [],
|
||
timelineEvents = [],
|
||
liveRuns = [],
|
||
activeRun = null,
|
||
issueId = null,
|
||
blockedBy = [],
|
||
liveIssueIds,
|
||
blockerAttention = null,
|
||
stalledDependencies,
|
||
successfulRunHandoff = null,
|
||
scheduledRetry = null,
|
||
recoveryAction = null,
|
||
interruption,
|
||
retryPreparing = false,
|
||
onResolveRecoveryAction,
|
||
onReissueIsolatedRecoveryAction,
|
||
reissueIsolatedRecoveryActionPending = false,
|
||
onReconcileForwardRecoveryAction,
|
||
onBreakGlassOverrideRecoveryAction,
|
||
onQuarantineRestoreRecoveryAction,
|
||
quarantineRestoreRecoveryActionPending = false,
|
||
canBreakGlassRecoveryAction = false,
|
||
reconcileRecoveryActionPending = false,
|
||
canFalsePositiveRecoveryAction = false,
|
||
legacyRecoverySourceIssue = null,
|
||
companyId,
|
||
assigneeAdapterOverrides,
|
||
projectId,
|
||
issueStatus,
|
||
issueAssigneeAgentId = null,
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
userProfileMap,
|
||
onVote,
|
||
onAdd,
|
||
onCancelRun,
|
||
stopPending,
|
||
stopScope,
|
||
onStopRun,
|
||
stopRunLabel,
|
||
stoppingRunLabel,
|
||
stopRunVariant,
|
||
runFinalizationActions,
|
||
imageUploadHandler,
|
||
onAttachImage,
|
||
draftKey,
|
||
enableReassign = false,
|
||
reassignOptions = [],
|
||
currentAssigneeValue = "",
|
||
suggestedAssigneeValue,
|
||
mentions = [],
|
||
composerPause = null,
|
||
composerDisabledReason = null,
|
||
composerHint = null,
|
||
showComposer = true,
|
||
showJumpToLatest,
|
||
autoScrollToLatestOnInitialLoad = false,
|
||
autoScrollToHashOnInitialLoad = false,
|
||
emptyMessage,
|
||
footer,
|
||
variant = "full",
|
||
enableLiveTranscriptPolling = true,
|
||
transcriptsByRunId,
|
||
hasOutputForRun: hasOutputForRunOverride,
|
||
includeSucceededRunsWithoutOutput = false,
|
||
onInterruptQueued,
|
||
onCancelQueued,
|
||
onDeleteComment,
|
||
interruptingQueuedRunId = null,
|
||
stoppingRunId = null,
|
||
onImageClick,
|
||
onAcceptInteraction,
|
||
onRejectInteraction,
|
||
onSubmitInteractionAnswers,
|
||
onCancelInteraction,
|
||
onSubmitInteractionVerdicts,
|
||
composerRef,
|
||
composerAccessory,
|
||
issueWorkMode,
|
||
onWorkModeChange,
|
||
onRefreshLatestComments,
|
||
assigneeUserId = null,
|
||
onResumeFromBacklog,
|
||
resumeFromBacklogPending = false,
|
||
onResumeAssignee,
|
||
resumeAssigneePending = false,
|
||
onTryAgainNoLiveExecutionPath: _onTryAgainNoLiveExecutionPath,
|
||
tryAgainNoLiveExecutionPathPending: _tryAgainNoLiveExecutionPathPending,
|
||
onRetryFailedRun: _onRetryFailedRun,
|
||
retryFailedRunId: _retryFailedRunId,
|
||
externalReferences,
|
||
linkCaseReferences = false,
|
||
}: IssueChatThreadProps) {
|
||
const workspaceRetry = useCurrentWorkspaceRetry();
|
||
const location = useLocation();
|
||
const lastScrolledHashRef = useRef<string | null>(null);
|
||
const didInitialHashScrollDecisionRef = useRef(false);
|
||
const virtualizedThreadRef =
|
||
useRef<VirtualizedIssueChatThreadListHandle | null>(null);
|
||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
||
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
|
||
const composerViewportSnapshotRef =
|
||
useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
|
||
const preserveComposerViewportRef = useRef(false);
|
||
const pendingSubmitScrollRef = useRef(false);
|
||
const lastUserMessageIdRef = useRef<string | null>(null);
|
||
const didInitialLatestScrollRef = useRef(false);
|
||
const spacerBaselineAnchorRef = useRef<string | null>(null);
|
||
const spacerInitialReserveRef = useRef(0);
|
||
const latestSettleTimeoutsRef = useRef<number[]>([]);
|
||
const latestSettleCleanupRef = useRef<(() => void) | null>(null);
|
||
const [bottomSpacerHeight, setBottomSpacerHeight] = useState(0);
|
||
const displayLiveRuns = useMemo(() => {
|
||
const deduped = new Map<string, LiveRunForIssue>();
|
||
for (const run of liveRuns) {
|
||
if (!isLiveIssueRun(run, issueStatus)) continue;
|
||
deduped.set(run.id, run);
|
||
}
|
||
if (activeRun && isLiveIssueRun(activeRun, issueStatus)) {
|
||
deduped.set(activeRun.id, {
|
||
id: activeRun.id,
|
||
status: activeRun.status,
|
||
invocationSource: activeRun.invocationSource,
|
||
triggerDetail: activeRun.triggerDetail,
|
||
contextCommentId: activeRun.contextCommentId,
|
||
contextWakeCommentId: activeRun.contextWakeCommentId,
|
||
startedAt: toIsoString(activeRun.startedAt),
|
||
finishedAt: toIsoString(activeRun.finishedAt),
|
||
createdAt: toIsoString(activeRun.createdAt) ?? new Date().toISOString(),
|
||
agentId: activeRun.agentId,
|
||
agentName: activeRun.agentName,
|
||
adapterType: activeRun.adapterType,
|
||
logBytes: activeRun.logBytes,
|
||
lastOutputBytes: activeRun.lastOutputBytes,
|
||
issueId: activeRun.issueId,
|
||
livenessState: activeRun.livenessState,
|
||
livenessReason: activeRun.livenessReason,
|
||
continuationAttempt: activeRun.continuationAttempt,
|
||
lastUsefulActionAt: toIsoString(activeRun.lastUsefulActionAt),
|
||
nextAction: activeRun.nextAction,
|
||
outputSilence: activeRun.outputSilence,
|
||
currentStatusMessage: activeRun.currentStatusMessage ?? null,
|
||
currentStatusUpdatedAt: toIsoString(activeRun.currentStatusUpdatedAt),
|
||
currentToolName: activeRun.currentToolName ?? null,
|
||
lastAssistantSnippet: activeRun.lastAssistantSnippet ?? null,
|
||
lastEventAt: toIsoString(activeRun.lastEventAt),
|
||
});
|
||
}
|
||
return [...deduped.values()].sort(
|
||
(a, b) =>
|
||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||
);
|
||
}, [activeRun, issueStatus, liveRuns]);
|
||
const transcriptRuns = useMemo(() => {
|
||
return resolveIssueChatTranscriptRuns({
|
||
linkedRuns,
|
||
liveRuns: displayLiveRuns,
|
||
activeRun,
|
||
});
|
||
}, [activeRun, displayLiveRuns, linkedRuns]);
|
||
const activeRunIds = useMemo(() => {
|
||
const ids = new Set<string>();
|
||
for (const run of displayLiveRuns) {
|
||
if (run.status === "queued" || run.status === "running") {
|
||
ids.add(run.id);
|
||
}
|
||
}
|
||
return ids;
|
||
}, [displayLiveRuns]);
|
||
const hasActiveRun = useMemo(
|
||
() => displayLiveRuns.some((run) => run.status === "running"),
|
||
[displayLiveRuns],
|
||
);
|
||
// Real-time view of the handoff: a run that starts after the issue payload
|
||
// was fetched must quiet the missing-disposition warnings without waiting
|
||
// for a refetch to update `hasLiveContinuation`.
|
||
const successfulRunHandoffWithLiveness = useMemo(() => {
|
||
if (!successfulRunHandoff || successfulRunHandoff.hasLiveContinuation) {
|
||
return successfulRunHandoff ?? null;
|
||
}
|
||
const liveNow =
|
||
activeRunIds.size > 0 || Boolean(issueId && liveIssueIds?.has(issueId));
|
||
return liveNow
|
||
? { ...successfulRunHandoff, hasLiveContinuation: true }
|
||
: successfulRunHandoff;
|
||
}, [successfulRunHandoff, activeRunIds, issueId, liveIssueIds]);
|
||
const clearLatestSettleTimeouts = useCallback(() => {
|
||
for (const timeout of latestSettleTimeoutsRef.current) {
|
||
window.clearTimeout(timeout);
|
||
}
|
||
latestSettleTimeoutsRef.current = [];
|
||
latestSettleCleanupRef.current?.();
|
||
latestSettleCleanupRef.current = null;
|
||
}, []);
|
||
|
||
useEffect(() => clearLatestSettleTimeouts, [clearLatestSettleTimeouts]);
|
||
|
||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
|
||
companyId,
|
||
});
|
||
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
||
const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun;
|
||
const rawMessages = useMemo(
|
||
() =>
|
||
buildIssueChatMessages({
|
||
comments,
|
||
interactions,
|
||
timelineEvents,
|
||
linkedRuns,
|
||
liveRuns,
|
||
activeRun,
|
||
transcriptsByRunId: resolvedTranscriptByRun,
|
||
hasOutputForRun: resolvedHasOutputForRun,
|
||
includeSucceededRunsWithoutOutput,
|
||
companyId,
|
||
projectId,
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
issueStatus,
|
||
}),
|
||
[
|
||
comments,
|
||
interactions,
|
||
timelineEvents,
|
||
linkedRuns,
|
||
liveRuns,
|
||
activeRun,
|
||
resolvedTranscriptByRun,
|
||
resolvedHasOutputForRun,
|
||
includeSucceededRunsWithoutOutput,
|
||
companyId,
|
||
projectId,
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
issueStatus,
|
||
],
|
||
);
|
||
const stableMessagesRef = useRef<readonly ThreadMessage[]>([]);
|
||
const stableMessageCacheRef = useRef<
|
||
Map<string, StableThreadMessageCacheEntry>
|
||
>(new Map());
|
||
const messages = useMemo(() => {
|
||
const stabilized = stabilizeThreadMessages(
|
||
rawMessages,
|
||
stableMessagesRef.current,
|
||
stableMessageCacheRef.current,
|
||
);
|
||
stableMessagesRef.current = stabilized.messages;
|
||
stableMessageCacheRef.current = stabilized.cache;
|
||
return stabilized.messages;
|
||
}, [rawMessages]);
|
||
const latestMessagesRef = useRef<readonly ThreadMessage[]>(messages);
|
||
latestMessagesRef.current = messages;
|
||
|
||
const isRunning = displayLiveRuns.some(
|
||
(run) => run.status === "queued" || run.status === "running",
|
||
);
|
||
const unresolvedBlockers = useMemo(
|
||
() =>
|
||
blockedBy.filter(
|
||
(blocker) =>
|
||
blocker.status !== "done" && blocker.status !== "cancelled",
|
||
),
|
||
[blockedBy],
|
||
);
|
||
const assignedAgent = useMemo(() => {
|
||
if (!currentAssigneeValue.startsWith("agent:")) return null;
|
||
const assigneeAgentId = currentAssigneeValue.slice("agent:".length);
|
||
return agentMap?.get(assigneeAgentId) ?? null;
|
||
}, [agentMap, currentAssigneeValue]);
|
||
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]);
|
||
const useVirtualizedThread =
|
||
messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD;
|
||
const messageAnchorIndex = useMemo(() => {
|
||
const map = new Map<string, number>();
|
||
messages.forEach((message, index) => {
|
||
const anchorId = issueChatMessageAnchorId(message);
|
||
if (anchorId) map.set(anchorId, index);
|
||
});
|
||
return map;
|
||
}, [messages]);
|
||
|
||
function scrollToThreadAnchor(
|
||
anchorId: string,
|
||
options?: {
|
||
align?: "start" | "center" | "end" | "auto";
|
||
behavior?: ScrollBehavior;
|
||
},
|
||
messageSnapshot: readonly ThreadMessage[] = messages,
|
||
) {
|
||
const snapshotUsesVirtualizer =
|
||
messageSnapshot.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD;
|
||
const virtualIndex =
|
||
messageSnapshot === messages
|
||
? messageAnchorIndex.get(anchorId)
|
||
: findMessageAnchorIndex(messageSnapshot, anchorId);
|
||
if (
|
||
snapshotUsesVirtualizer &&
|
||
virtualIndex !== undefined &&
|
||
virtualIndex >= 0
|
||
) {
|
||
if (!virtualizedThreadRef.current) return false;
|
||
virtualizedThreadRef.current.scrollToIndex(virtualIndex, {
|
||
align: options?.align ?? "center",
|
||
behavior: options?.behavior ?? "smooth",
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const element = document.getElementById(anchorId);
|
||
if (!element) return false;
|
||
element.scrollIntoView({
|
||
behavior: options?.behavior ?? "smooth",
|
||
block:
|
||
options?.align === "start"
|
||
? "start"
|
||
: options?.align === "end"
|
||
? "end"
|
||
: "center",
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const {
|
||
pending: submissionPending,
|
||
takingLonger: submissionTakingLonger,
|
||
begin: beginSubmission,
|
||
reject: rejectSubmission,
|
||
blocked: startupBlocked,
|
||
} = useRunnerSubmission({
|
||
issueId,
|
||
agentId: issueAssigneeAgentId,
|
||
agent: agentMap?.get(issueAssigneeAgentId ?? ""),
|
||
blocked: Boolean(composerPause),
|
||
comments,
|
||
issueStatus,
|
||
runId: displayLiveRuns[0]?.id ?? null,
|
||
linkedRuns,
|
||
interruption,
|
||
onRefresh: onRefreshLatestComments,
|
||
});
|
||
const sendComposerComment = useCallback<IssueChatThreadProps["onAdd"]>(
|
||
async (body, reopen, reassignment, attachmentIds, clientRequestId, runSettings) => {
|
||
pendingSubmitScrollRef.current = true;
|
||
clientRequestId ??= crypto.randomUUID();
|
||
const attempt = beginSubmission(clientRequestId, undefined, reopen);
|
||
try {
|
||
await (runSettings
|
||
? onAdd(body, reopen, reassignment, attachmentIds, clientRequestId, runSettings)
|
||
: onAdd(body, reopen, reassignment, attachmentIds, clientRequestId));
|
||
} catch (error) {
|
||
rejectSubmission(attempt, error);
|
||
throw error;
|
||
}
|
||
},
|
||
[onAdd, beginSubmission, rejectSubmission],
|
||
);
|
||
const runtime = usePaperclipIssueRuntime({
|
||
messages,
|
||
isRunning,
|
||
onSend: ({ body, reopen, reassignment, attachmentIds }) =>
|
||
sendComposerComment(body, reopen, reassignment, attachmentIds),
|
||
onCancel: onCancelRun,
|
||
});
|
||
|
||
useEffect(() => {
|
||
const lastUserMessage = [...messages]
|
||
.reverse()
|
||
.find((m) => m.role === "user");
|
||
const lastUserId = lastUserMessage?.id ?? null;
|
||
|
||
if (
|
||
pendingSubmitScrollRef.current &&
|
||
lastUserId &&
|
||
lastUserId !== lastUserMessageIdRef.current
|
||
) {
|
||
pendingSubmitScrollRef.current = false;
|
||
const custom = lastUserMessage?.metadata.custom as
|
||
{ anchorId?: unknown } | undefined;
|
||
const anchorId =
|
||
typeof custom?.anchorId === "string" ? custom.anchorId : null;
|
||
if (anchorId) {
|
||
const reserve = Math.round(
|
||
window.innerHeight * SUBMIT_SCROLL_RESERVE_VH,
|
||
);
|
||
spacerBaselineAnchorRef.current = anchorId;
|
||
spacerInitialReserveRef.current = reserve;
|
||
setBottomSpacerHeight(reserve);
|
||
requestAnimationFrame(() => {
|
||
scrollToThreadAnchor(anchorId, {
|
||
align: "start",
|
||
behavior: "smooth",
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
lastUserMessageIdRef.current = lastUserId;
|
||
}, [messageAnchorIndex, messages, useVirtualizedThread]);
|
||
|
||
useLayoutEffect(() => {
|
||
const anchorId = spacerBaselineAnchorRef.current;
|
||
if (!anchorId || spacerInitialReserveRef.current <= 0) return;
|
||
const userEl = document.getElementById(anchorId);
|
||
const bottomEl = bottomAnchorRef.current;
|
||
if (!userEl || !bottomEl) return;
|
||
const contentBelow = Math.max(
|
||
0,
|
||
bottomEl.getBoundingClientRect().top -
|
||
userEl.getBoundingClientRect().bottom,
|
||
);
|
||
const next = Math.max(0, spacerInitialReserveRef.current - contentBelow);
|
||
setBottomSpacerHeight((prev) => (prev === next ? prev : next));
|
||
if (next === 0) {
|
||
spacerBaselineAnchorRef.current = null;
|
||
spacerInitialReserveRef.current = 0;
|
||
}
|
||
}, [messages]);
|
||
useLayoutEffect(() => {
|
||
const composerElement = composerViewportAnchorRef.current;
|
||
if (preserveComposerViewportRef.current) {
|
||
restoreComposerViewportSnapshot(
|
||
composerViewportSnapshotRef.current,
|
||
composerElement,
|
||
);
|
||
}
|
||
|
||
composerViewportSnapshotRef.current =
|
||
captureComposerViewportSnapshot(composerElement);
|
||
preserveComposerViewportRef.current =
|
||
shouldPreserveComposerViewport(composerElement);
|
||
}, [messages]);
|
||
|
||
useEffect(() => {
|
||
const hash =
|
||
location.hash ||
|
||
(typeof window !== "undefined" ? window.location.hash : "");
|
||
const isThreadHash =
|
||
hash.startsWith("#comment-") ||
|
||
hash.startsWith("#activity-") ||
|
||
hash.startsWith("#run-") ||
|
||
hash.startsWith("#interaction-");
|
||
if (messages.length === 0) return;
|
||
if (!isThreadHash) {
|
||
if (!didInitialHashScrollDecisionRef.current) {
|
||
didInitialHashScrollDecisionRef.current = true;
|
||
}
|
||
return;
|
||
}
|
||
if (lastScrolledHashRef.current === hash) return;
|
||
const targetId = hash.slice(1);
|
||
if (targetId.startsWith("comment-")) {
|
||
const targetMessage = messages.find(
|
||
(message) => issueChatMessageAnchorId(message) === targetId,
|
||
);
|
||
if (targetMessage && issueChatMessageIsDeleted(targetMessage)) {
|
||
didInitialHashScrollDecisionRef.current = true;
|
||
lastScrolledHashRef.current = hash;
|
||
if (typeof window !== "undefined") {
|
||
window.history.replaceState(
|
||
null,
|
||
"",
|
||
`${location.pathname}${location.search}`,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
if (!didInitialHashScrollDecisionRef.current) {
|
||
didInitialHashScrollDecisionRef.current = true;
|
||
if (!autoScrollToHashOnInitialLoad) {
|
||
lastScrolledHashRef.current = hash;
|
||
return;
|
||
}
|
||
}
|
||
let cancelled = false;
|
||
const attemptScroll = (finalAttempt = false) => {
|
||
if (cancelled || lastScrolledHashRef.current === hash) return;
|
||
const didScroll = scrollToThreadAnchor(targetId, {
|
||
align: "center",
|
||
behavior: "smooth",
|
||
});
|
||
if (!didScroll) return;
|
||
if (
|
||
finalAttempt ||
|
||
!useVirtualizedThread ||
|
||
document.getElementById(targetId)
|
||
) {
|
||
lastScrolledHashRef.current = hash;
|
||
}
|
||
};
|
||
|
||
attemptScroll();
|
||
const frame = requestAnimationFrame(() => attemptScroll());
|
||
const timeout = window.setTimeout(() => attemptScroll(true), 250);
|
||
return () => {
|
||
cancelled = true;
|
||
cancelAnimationFrame(frame);
|
||
window.clearTimeout(timeout);
|
||
};
|
||
}, [
|
||
autoScrollToHashOnInitialLoad,
|
||
location.hash,
|
||
messageAnchorIndex,
|
||
messages,
|
||
useVirtualizedThread,
|
||
]);
|
||
|
||
// Optional legacy behavior: callers may explicitly request landing on the
|
||
// latest comment. The shared default stays off so ordinary page loads keep
|
||
// the user's initial viewport stable.
|
||
useEffect(() => {
|
||
if (didInitialLatestScrollRef.current) return;
|
||
if (!autoScrollToLatestOnInitialLoad) return;
|
||
if (variant !== "full") return;
|
||
if (messages.length === 0) return;
|
||
const hash =
|
||
location.hash ||
|
||
(typeof window !== "undefined" ? window.location.hash : "");
|
||
if (
|
||
hash.startsWith("#comment-") ||
|
||
hash.startsWith("#activity-") ||
|
||
hash.startsWith("#run-") ||
|
||
hash.startsWith("#interaction-")
|
||
) {
|
||
didInitialLatestScrollRef.current = true;
|
||
return;
|
||
}
|
||
didInitialLatestScrollRef.current = true;
|
||
// Defer a frame so the virtualizer/DOM has mounted its initial rows before
|
||
// we resolve and scroll to the latest comment's anchor.
|
||
const frame = requestAnimationFrame(() =>
|
||
scrollToLatestCommentWithSettle(latestMessagesRef.current),
|
||
);
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [autoScrollToLatestOnInitialLoad, messages, variant, location.hash]);
|
||
|
||
function jumpToLatestFallback() {
|
||
if (useVirtualizedThread) {
|
||
virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" });
|
||
return;
|
||
}
|
||
bottomAnchorRef.current?.scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "end",
|
||
});
|
||
}
|
||
|
||
// Lands on the latest `comment-*` row and then drives the scroll the rest
|
||
// of the way home as the virtualizer's per-row measurements arrive.
|
||
//
|
||
// The virtualizer estimates 220px for unmeasured rows. On long threads
|
||
// with tall markdown comments (PAP-2536 et al.), totalSize is hugely
|
||
// underestimated until rows render and get measured. A single scroll
|
||
// lands above the actual bottom; rendered rows then expand, the layout
|
||
// grows, and the user has to keep clicking Jump-to-latest to walk closer
|
||
// to the real bottom. The convergence loop below issues `scrollIntoView`
|
||
// on the latest comment element on every tick until the DOM bottom of
|
||
// that element is at the scroll container's bottom (or scroll position
|
||
// and content height stop changing).
|
||
function scrollToLatestCommentWithSettle(
|
||
messageSnapshot: readonly ThreadMessage[] = latestMessagesRef.current,
|
||
) {
|
||
const latestCommentIndex = findLatestCommentMessageIndex(messageSnapshot);
|
||
if (latestCommentIndex < 0) {
|
||
jumpToLatestFallback();
|
||
return;
|
||
}
|
||
const latestCommentAnchor = issueChatMessageAnchorId(
|
||
messageSnapshot[latestCommentIndex],
|
||
);
|
||
if (!latestCommentAnchor) {
|
||
jumpToLatestFallback();
|
||
return;
|
||
}
|
||
|
||
const initial = scrollToThreadAnchor(
|
||
latestCommentAnchor,
|
||
{ align: "end", behavior: "smooth" },
|
||
messageSnapshot,
|
||
);
|
||
if (!initial) {
|
||
jumpToLatestFallback();
|
||
return;
|
||
}
|
||
|
||
if (typeof window === "undefined") return;
|
||
|
||
const startedAt =
|
||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||
const MAX_DURATION_MS = 4000;
|
||
const TICK_MS = 80;
|
||
const TOLERANCE_PX = 4;
|
||
|
||
clearLatestSettleTimeouts();
|
||
const resolveScrollContainer = (): HTMLElement | null =>
|
||
document.getElementById("main-content") as HTMLElement | null;
|
||
const cancelTarget = resolveScrollContainer() ?? window;
|
||
|
||
let lastScrollTop = -1;
|
||
let lastScrollHeight = -1;
|
||
let stableTicks = 0;
|
||
let cancelled = false;
|
||
|
||
const cancel = () => {
|
||
cancelled = true;
|
||
};
|
||
|
||
const cleanup = () => {
|
||
cancelTarget.removeEventListener("wheel", cancel);
|
||
cancelTarget.removeEventListener("touchstart", cancel);
|
||
};
|
||
|
||
cancelTarget.addEventListener("wheel", cancel, {
|
||
once: true,
|
||
passive: true,
|
||
});
|
||
cancelTarget.addEventListener("touchstart", cancel, {
|
||
once: true,
|
||
passive: true,
|
||
});
|
||
latestSettleCleanupRef.current = cleanup;
|
||
|
||
const finish = () => {
|
||
cleanup();
|
||
latestSettleCleanupRef.current = null;
|
||
for (const timeout of latestSettleTimeoutsRef.current) {
|
||
window.clearTimeout(timeout);
|
||
}
|
||
latestSettleTimeoutsRef.current = [];
|
||
};
|
||
|
||
const scheduleTick = (delay: number) => {
|
||
const timeout = window.setTimeout(() => {
|
||
latestSettleTimeoutsRef.current =
|
||
latestSettleTimeoutsRef.current.filter((entry) => entry !== timeout);
|
||
tick();
|
||
}, delay);
|
||
latestSettleTimeoutsRef.current.push(timeout);
|
||
};
|
||
|
||
const tick = () => {
|
||
const now =
|
||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||
if (cancelled || now - startedAt > MAX_DURATION_MS) {
|
||
finish();
|
||
return;
|
||
}
|
||
|
||
if (typeof document === "undefined") {
|
||
finish();
|
||
return;
|
||
}
|
||
|
||
const el = document.getElementById(latestCommentAnchor);
|
||
if (!el) {
|
||
// Row hasn't been rendered into the virtualizer's buffer yet — nudge
|
||
// the offset (instant) so it gets mounted, then keep settling.
|
||
virtualizedThreadRef.current?.scrollToIndex(latestCommentIndex, {
|
||
align: "end",
|
||
behavior: "auto",
|
||
});
|
||
scheduleTick(TICK_MS);
|
||
return;
|
||
}
|
||
|
||
const container = resolveScrollContainer();
|
||
const containerBottom = container
|
||
? container.getBoundingClientRect().bottom
|
||
: window.innerHeight;
|
||
const elBottom = el.getBoundingClientRect().bottom;
|
||
const offBottom = elBottom - containerBottom;
|
||
|
||
if (Math.abs(offBottom) > TOLERANCE_PX) {
|
||
el.scrollIntoView({ behavior: "smooth", block: "end" });
|
||
}
|
||
|
||
const currentScrollTop = container?.scrollTop ?? window.scrollY;
|
||
const currentScrollHeight =
|
||
container?.scrollHeight ?? document.documentElement.scrollHeight;
|
||
const scrollStable = Math.abs(currentScrollTop - lastScrollTop) < 1;
|
||
const heightStable = currentScrollHeight === lastScrollHeight;
|
||
const atBottom = Math.abs(offBottom) <= TOLERANCE_PX;
|
||
if (scrollStable && heightStable && atBottom) {
|
||
stableTicks += 1;
|
||
if (stableTicks >= 3) {
|
||
finish();
|
||
return;
|
||
}
|
||
} else {
|
||
stableTicks = 0;
|
||
}
|
||
lastScrollTop = currentScrollTop;
|
||
lastScrollHeight = currentScrollHeight;
|
||
scheduleTick(TICK_MS);
|
||
};
|
||
|
||
// Hold the first iteration off for one frame so the initial smooth
|
||
// scroll has begun (and the virtualizer has rendered the buffer around
|
||
// the target) before we start settling.
|
||
scheduleTick(120);
|
||
}
|
||
|
||
function handleJumpToLatest() {
|
||
if (onRefreshLatestComments) {
|
||
// Refetching the comments query (page 0 first) brings any comment that
|
||
// arrived after the initial load — including ones live updates may
|
||
// have missed during reconnects — into the loaded set before we
|
||
// resolve the latest target. Otherwise we'd land on the latest
|
||
// *loaded* comment but not the absolute newest. (PAP-2672 follow-up.)
|
||
const refreshed = onRefreshLatestComments();
|
||
if (
|
||
refreshed &&
|
||
typeof (refreshed as Promise<unknown>).then === "function"
|
||
) {
|
||
(refreshed as Promise<unknown>).then(
|
||
() => scrollToLatestCommentWithSettle(latestMessagesRef.current),
|
||
() => scrollToLatestCommentWithSettle(latestMessagesRef.current),
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
scrollToLatestCommentWithSettle(latestMessagesRef.current);
|
||
}
|
||
|
||
const stableOnVote = useStableEvent(onVote);
|
||
const stableOnStopRun = useStableEvent(onStopRun);
|
||
const stableOnInterruptQueued = useStableEvent(onInterruptQueued);
|
||
const stableOnCancelQueued = useStableEvent(onCancelQueued);
|
||
const stableOnDeleteComment = useStableEvent(onDeleteComment);
|
||
const stableOnImageClick = useStableEvent(onImageClick);
|
||
const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction);
|
||
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
|
||
const stableOnSubmitInteractionAnswers = useStableEvent(
|
||
onSubmitInteractionAnswers,
|
||
);
|
||
const stableOnCancelInteraction = useStableEvent(onCancelInteraction);
|
||
const stableOnSubmitInteractionVerdicts = useStableEvent(
|
||
onSubmitInteractionVerdicts,
|
||
);
|
||
const stableOnUploadImage = useStableEvent(imageUploadHandler);
|
||
|
||
const chatCtx = useMemo<IssueChatMessageContext>(
|
||
() => ({
|
||
feedbackDataSharingPreference,
|
||
feedbackTermsUrl,
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
userProfileMap,
|
||
onVote: stableOnVote,
|
||
onStopRun: stableOnStopRun,
|
||
stopRunLabel,
|
||
stoppingRunLabel,
|
||
stopRunVariant,
|
||
runFinalizationActions,
|
||
onInterruptQueued: composerPause ? undefined : stableOnInterruptQueued,
|
||
onCancelQueued: composerPause ? undefined : stableOnCancelQueued,
|
||
onDeleteComment: stableOnDeleteComment,
|
||
onImageClick: stableOnImageClick,
|
||
onAcceptInteraction: stableOnAcceptInteraction,
|
||
onRejectInteraction: stableOnRejectInteraction,
|
||
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
|
||
onCancelInteraction: stableOnCancelInteraction,
|
||
onSubmitInteractionVerdicts: stableOnSubmitInteractionVerdicts,
|
||
onUploadImage: stableOnUploadImage,
|
||
issueStatus,
|
||
issueAssigneeAgentId,
|
||
successfulRunHandoff: successfulRunHandoffWithLiveness,
|
||
externalReferences,
|
||
linkCaseReferences,
|
||
}),
|
||
[
|
||
feedbackDataSharingPreference,
|
||
feedbackTermsUrl,
|
||
agentMap,
|
||
currentUserId,
|
||
userLabelMap,
|
||
userProfileMap,
|
||
stableOnVote,
|
||
stableOnStopRun,
|
||
stopRunLabel,
|
||
stoppingRunLabel,
|
||
stopRunVariant,
|
||
runFinalizationActions,
|
||
composerPause,
|
||
stableOnInterruptQueued,
|
||
stableOnCancelQueued,
|
||
stableOnDeleteComment,
|
||
stableOnImageClick,
|
||
stableOnAcceptInteraction,
|
||
stableOnRejectInteraction,
|
||
stableOnSubmitInteractionAnswers,
|
||
stableOnCancelInteraction,
|
||
stableOnSubmitInteractionVerdicts,
|
||
stableOnUploadImage,
|
||
issueStatus,
|
||
issueAssigneeAgentId,
|
||
successfulRunHandoffWithLiveness,
|
||
externalReferences,
|
||
linkCaseReferences,
|
||
],
|
||
);
|
||
|
||
const resolvedShowJumpToLatest = showJumpToLatest ?? variant === "full";
|
||
const resolvedEmptyMessage =
|
||
emptyMessage ??
|
||
(variant === "embedded"
|
||
? "No run output yet."
|
||
: "This task conversation is empty. Start with a message below.");
|
||
const previousErrorBoundaryMessagesRef = useRef<
|
||
readonly ThreadMessage[] | null
|
||
>(null);
|
||
const errorBoundaryResetVersionRef = useRef(0);
|
||
if (previousErrorBoundaryMessagesRef.current !== messages) {
|
||
previousErrorBoundaryMessagesRef.current = messages;
|
||
errorBoundaryResetVersionRef.current += 1;
|
||
}
|
||
const errorBoundaryResetKey = String(errorBoundaryResetVersionRef.current);
|
||
|
||
return (
|
||
<AssistantRuntimeProvider runtime={runtime}>
|
||
<IssueChatCtx.Provider value={chatCtx}>
|
||
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||
{resolvedShowJumpToLatest ? (
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={handleJumpToLatest}
|
||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||
>
|
||
Jump to latest
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
<IssueChatErrorBoundary
|
||
resetKey={errorBoundaryResetKey}
|
||
messages={messages}
|
||
emptyMessage={resolvedEmptyMessage}
|
||
variant={variant}
|
||
externalReferences={externalReferences}
|
||
>
|
||
<div data-testid="thread-root">
|
||
<div
|
||
data-testid="thread-viewport"
|
||
className={variant === "embedded" ? "space-y-3" : "space-y-4"}
|
||
>
|
||
{messages.length === 0 ? (
|
||
<Card
|
||
className={cn(
|
||
"block shadow-none text-center text-sm text-muted-foreground",
|
||
variant === "embedded"
|
||
? "border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||
: "border-dashed px-6 py-10",
|
||
)}
|
||
>
|
||
{resolvedEmptyMessage}
|
||
</Card>
|
||
) : messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD ? (
|
||
<VirtualizedIssueChatThreadList
|
||
ref={virtualizedThreadRef}
|
||
messages={messages}
|
||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||
activeRunIds={activeRunIds}
|
||
stoppingRunId={stoppingRunId}
|
||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||
variant={variant}
|
||
/>
|
||
) : (
|
||
// Keep transcript rendering independent from assistant-ui's
|
||
// index-scoped message providers; live transcripts can shrink
|
||
// or regroup while the runtime still holds stale indices.
|
||
messages.map((message) => (
|
||
<IssueChatMessageRow
|
||
key={message.id}
|
||
message={message}
|
||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||
activeRunIds={activeRunIds}
|
||
stoppingRunId={stoppingRunId}
|
||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||
/>
|
||
))
|
||
)}
|
||
{!isRunning && !startupBlocked && issueAssigneeAgentId && (submissionPending || retryPreparing || interruption?.state === "working") ? (
|
||
<TaskChatRunnerTurn items={[]} status="queued" startedAtMs={null}
|
||
agent={agentMap?.get(issueAssigneeAgentId)}
|
||
agentName={agentMap?.get(issueAssigneeAgentId)?.name}
|
||
preparationLabel={submissionTakingLonger ? "Taking longer than usual…" : undefined} />
|
||
) : null}
|
||
{showComposer ? (
|
||
<div
|
||
data-testid="issue-chat-thread-notices"
|
||
className="space-y-2"
|
||
>
|
||
<IssueAssignedBacklogNotice
|
||
issueStatus={issueStatus ?? ""}
|
||
assigneeAgent={assignedAgent}
|
||
assigneeUserId={assigneeUserId}
|
||
onResume={onResumeFromBacklog}
|
||
resuming={resumeFromBacklogPending}
|
||
/>
|
||
{recoveryAction && !workspaceRetry ? (
|
||
<IssueRecoveryActionCard
|
||
action={recoveryAction}
|
||
agentMap={agentMap}
|
||
scheduledRetry={scheduledRetry}
|
||
onResolve={onResolveRecoveryAction}
|
||
onReissueIsolated={onReissueIsolatedRecoveryAction}
|
||
reissuePending={reissueIsolatedRecoveryActionPending}
|
||
onReconcileForward={onReconcileForwardRecoveryAction}
|
||
onBreakGlassOverride={
|
||
onBreakGlassOverrideRecoveryAction
|
||
}
|
||
onQuarantineRestore={onQuarantineRestoreRecoveryAction}
|
||
quarantineRestorePending={
|
||
quarantineRestoreRecoveryActionPending
|
||
}
|
||
canBreakGlass={canBreakGlassRecoveryAction}
|
||
reconcilePending={reconcileRecoveryActionPending}
|
||
canFalsePositive={canFalsePositiveRecoveryAction}
|
||
/>
|
||
) : null}
|
||
{legacyRecoverySourceIssue ? (
|
||
<SystemNotice
|
||
tone="info"
|
||
label="Legacy recovery task"
|
||
body={
|
||
<span>
|
||
Legacy recovery task. Newer recovery actions live on
|
||
the source task
|
||
{legacyRecoverySourceIssue.identifier ? (
|
||
<>
|
||
{" — "}
|
||
<Link
|
||
to={legacyRecoverySourceIssue.href}
|
||
className="underline-offset-2 hover:underline"
|
||
>
|
||
{legacyRecoverySourceIssue.identifier}
|
||
{legacyRecoverySourceIssue.title ? (
|
||
<span className="text-muted-foreground">
|
||
{" "}
|
||
— {legacyRecoverySourceIssue.title}
|
||
</span>
|
||
) : null}
|
||
</Link>
|
||
</>
|
||
) : (
|
||
"."
|
||
)}
|
||
</span>
|
||
}
|
||
/>
|
||
) : null}
|
||
<IssueBlockedNotice
|
||
issueId={issueId}
|
||
issueStatus={issueStatus}
|
||
blockers={unresolvedBlockers}
|
||
allBlockers={blockedBy}
|
||
liveIssueIds={liveIssueIds}
|
||
blockerAttention={blockerAttention}
|
||
successfulRunHandoff={
|
||
recoveryAction ? null : successfulRunHandoffWithLiveness
|
||
}
|
||
scheduledRetry={scheduledRetry}
|
||
agentName={
|
||
successfulRunHandoff?.assigneeAgentId
|
||
? (agentMap?.get(successfulRunHandoff.assigneeAgentId)
|
||
?.name ?? null)
|
||
: null
|
||
}
|
||
/>
|
||
<StalledDependencyNotice stalled={stalledDependencies} />
|
||
<IssueAssigneePausedNotice
|
||
agent={assignedAgent}
|
||
issueStatus={issueStatus}
|
||
onResume={onResumeAssignee}
|
||
resuming={resumeAssigneePending}
|
||
/>
|
||
</div>
|
||
) : (
|
||
// Read-only viewers still need to see why nothing is running.
|
||
<div
|
||
data-testid="issue-chat-thread-notices"
|
||
className="space-y-2"
|
||
>
|
||
<IssueAssigneePausedNotice
|
||
agent={assignedAgent}
|
||
issueStatus={issueStatus}
|
||
onResume={onResumeAssignee}
|
||
resuming={resumeAssigneePending}
|
||
/>
|
||
</div>
|
||
)}
|
||
{footer ? (
|
||
<div data-testid="issue-chat-thread-footer">{footer}</div>
|
||
) : null}
|
||
<div ref={bottomAnchorRef} />
|
||
{showComposer ? (
|
||
<div
|
||
aria-hidden
|
||
data-testid="issue-chat-bottom-spacer"
|
||
style={{ height: bottomSpacerHeight }}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</IssueChatErrorBoundary>
|
||
|
||
{showComposer && composerAccessory ? (
|
||
<div data-testid="issue-chat-composer-accessory" className="mb-2">
|
||
{composerAccessory}
|
||
</div>
|
||
) : null}
|
||
|
||
{showComposer ? (
|
||
<div
|
||
ref={composerViewportAnchorRef}
|
||
data-testid="issue-chat-composer-dock"
|
||
className="sticky bottom-(--sz-calc-8) z-20 space-y-2 bg-gradient-to-t from-background via-background/95 to-background/0 pt-6"
|
||
>
|
||
<IssueChatComposer
|
||
ref={composerRef}
|
||
onSend={sendComposerComment}
|
||
onImageUpload={imageUploadHandler}
|
||
onAttachImage={onAttachImage}
|
||
draftKey={draftKey}
|
||
confirmedSubmissionIds={new Set(comments.filter((comment) =>
|
||
comment.authorUserId === currentUserId && comment.clientRequestId &&
|
||
!("clientStatus" in comment && comment.clientStatus)
|
||
).map((comment) => comment.clientRequestId!))}
|
||
enableReassign={enableReassign}
|
||
reassignOptions={reassignOptions}
|
||
currentAssigneeValue={currentAssigneeValue}
|
||
companyId={companyId}
|
||
assigneeAdapterOverrides={assigneeAdapterOverrides}
|
||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||
mentions={mentions}
|
||
agentMap={agentMap}
|
||
hasActiveRun={!!hasActiveRun}
|
||
onStop={hasActiveRun ? onCancelRun : undefined}
|
||
stopPending={stopPending}
|
||
stopScope={stopScope}
|
||
currentUserId={currentUserId}
|
||
userLabelMap={userLabelMap}
|
||
composerPause={composerPause}
|
||
composerDisabledReason={composerDisabledReason}
|
||
composerHint={composerHint}
|
||
issueStatus={issueStatus}
|
||
issueWorkMode={issueWorkMode}
|
||
onWorkModeChange={onWorkModeChange}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</IssueChatCtx.Provider>
|
||
</AssistantRuntimeProvider>
|
||
);
|
||
}
|