import { AssistantRuntimeProvider, useAui, } from "@assistant-ui/react"; import type { ReasoningMessagePart, TextMessagePart, ThreadMessage, ToolCallMessagePart, } from "@assistant-ui/react"; import { createContext, Component, forwardRef, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent as ReactDragEvent, type ErrorInfo, type Ref, type ReactNode, } from "react"; import { Link, useLocation } from "@/lib/router"; import type { Agent, FeedbackDataSharingPreference, FeedbackVote, FeedbackVoteValue, IssueRelationIssueSummary, } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime"; import { buildIssueChatMessages, formatDurationWords, stabilizeThreadMessages, type IssueChatComment, type IssueChatLinkedRun, type StableThreadMessageCacheEntry, type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction, RequestConfirmationInteraction, SuggestTasksInteraction, } from "../lib/issue-thread-interactions"; import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions"; import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { MarkdownBody } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; import { AgentIcon } from "./AgentIconPicker"; 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 { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { timeAgo } from "../lib/timeAgo"; import { describeToolInput, displayToolName, formatToolPayload, isCommandTool, parseToolPayload, summarizeToolInput, summarizeToolResult, } from "../lib/transcriptPresentation"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; import { IssueLinkQuicklook } from "./IssueLinkQuicklook"; interface IssueChatMessageContext { feedbackVoteByTargetId: Map; feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackTermsUrl: string | null; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; userProfileMap?: ReadonlyMap | null; activeRunIds: ReadonlySet; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onStopRun?: (runId: string) => Promise; stoppingRunId?: string | null; onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; onImageClick?: (src: string) => void; onAcceptInteraction?: ( interaction: SuggestTasksInteraction | RequestConfirmationInteraction, selectedClientKeys?: string[], ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction | RequestConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; } const IssueChatCtx = createContext({ feedbackVoteByTargetId: new Map(), feedbackDataSharingPreference: "prompt", feedbackTermsUrl: null, activeRunIds: new Set(), }); 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; }) { 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 { const [, rerender] = useState(0); useEffect(() => { if (!active || !startMs) return; const interval = setInterval(() => rerender((n) => n + 1), 1000); return () => clearInterval(interval); }, [active, startMs]); if (!active || !startMs) return null; return formatDurationWords(Date.now() - startMs); } interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; } export interface IssueChatComposerHandle { focus: () => void; restoreDraft: (submittedBody: string) => void; } interface IssueChatComposerProps { onImageUpload?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; draftKey?: string; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; agentMap?: Map; composerDisabledReason?: string | null; composerHint?: string | null; issueStatus?: string; } interface IssueChatThreadProps { comments: IssueChatComment[]; interactions?: IssueThreadInteraction[]; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; linkedRuns?: IssueChatLinkedRun[]; timelineEvents?: IssueTimelineEvent[]; liveRuns?: LiveRunForIssue[]; activeRun?: ActiveRunForIssue | null; blockedBy?: IssueRelationIssueSummary[]; companyId?: string | null; projectId?: string | null; issueStatus?: string; agentMap?: Map; currentUserId?: string | null; userLabelMap?: ReadonlyMap | null; userProfileMap?: ReadonlyMap | null; onVote?: ( commentId: string, vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; onCancelRun?: () => Promise; onStopRun?: (runId: string) => Promise; imageUploadHandler?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; draftKey?: string; enableReassign?: boolean; reassignOptions?: InlineEntityOption[]; currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; composerDisabledReason?: string | null; composerHint?: string | null; showComposer?: boolean; showJumpToLatest?: boolean; emptyMessage?: string; variant?: "full" | "embedded"; enableLiveTranscriptPolling?: boolean; transcriptsByRunId?: ReadonlyMap; hasOutputForRun?: (runId: string) => boolean; includeSucceededRunsWithoutOutput?: boolean; onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; interruptingQueuedRunId?: string | null; stoppingRunId?: string | null; onImageClick?: (src: string) => void; onAcceptInteraction?: ( interaction: SuggestTasksInteraction | RequestConfirmationInteraction, selectedClientKeys?: string[], ) => Promise | void; onRejectInteraction?: ( interaction: SuggestTasksInteraction | RequestConfirmationInteraction, reason?: string, ) => Promise | void; onSubmitInteractionAnswers?: ( interaction: AskUserQuestionsInteraction, answers: AskUserQuestionsAnswer[], ) => Promise | void; composerRef?: Ref; } type IssueChatErrorBoundaryProps = { resetKey: string; messages: readonly ThreadMessage[]; emptyMessage: string; variant: "full" | "embedded"; children: ReactNode; }; type IssueChatErrorBoundaryState = { hasError: boolean; }; class IssueChatErrorBoundary extends Component { 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 ( ); } return this.props.children; } } function IssueBlockedNotice({ issueStatus, blockers, }: { issueStatus?: string; blockers: IssueRelationIssueSummary[]; }) { if (blockers.length === 0 && issueStatus !== "blocked") return null; const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues"; return (

{blockers.length > 0 ? <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage. : <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.}

{blockers.length > 0 ? (
{blockers.map((blocker) => { const issuePathId = blocker.identifier ?? blocker.id; return ( {blocker.identifier ?? blocker.id.slice(0, 8)} {blocker.title} ); })}
) : null}
); } function IssueAssigneePausedNotice({ agent }: { agent: Agent | null }) { if (!agent || agent.status !== "paused") return null; const pauseDetail = agent.pauseReason === "budget" ? "It was paused by a budget hard stop." : agent.pauseReason === "system" ? "It was paused by the system." : "It was paused manually."; return (

{agent.name} is paused. New runs will not start until the agent is resumed. {pauseDetail}

); } function fallbackAuthorLabel(message: ThreadMessage) { const custom = message.metadata?.custom as Record | 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 | undefined; if (contentLines.length === 0 && typeof custom?.["waitingText"] === "string" && custom["waitingText"].trim()) { contentLines.push(custom["waitingText"]); } return contentLines; } function IssueChatFallbackThread({ messages, emptyMessage, variant, }: { messages: readonly ThreadMessage[]; emptyMessage: string; variant: "full" | "embedded"; }) { return (

Chat renderer hit an internal state error.

Showing a safe fallback transcript instead of crashing the issues page.

{messages.length === 0 ? (
{emptyMessage}
) : (
{messages.map((message) => { const lines = fallbackTextParts(message); return (
{fallbackAuthorLabel(message)} {message.createdAt ? ( {commentDateLabel(message.createdAt)} ) : null}
{lines.length > 0 ? lines.map((line, index) => ( {line} )) : (

No message content.

)}
); })}
)}
); } const DRAFT_DEBOUNCE_MS = 800; const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96; const SUBMIT_SCROLL_RESERVE_VH = 0.4; function hasFilePayload(evt: ReactDragEvent) { return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } function toIsoString(value: string | Date | null | undefined): string | null { if (!value) return null; return typeof value === "string" ? value : value.toISOString(); } function loadDraft(draftKey: string): string { try { return localStorage.getItem(draftKey) ?? ""; } catch { return ""; } } function saveDraft(draftKey: string, value: string) { try { if (value.trim()) { localStorage.setItem(draftKey, value); } else { localStorage.removeItem(draftKey); } } catch { // Ignore localStorage failures. } } function clearDraft(draftKey: string) { try { localStorage.removeItem(draftKey); } catch { // Ignore localStorage failures. } } 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:"); } 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); } function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { const { onImageClick } = useContext(IssueChatCtx); return ( {text} ); } function humanizeValue(value: string | null) { if (!value) return "None"; return value.replace(/_/g, " "); } function formatTimelineAssigneeLabel( assignee: IssueTimelineAssignee, agentMap?: Map, currentUserId?: string | null, userLabelMap?: ReadonlyMap | null, ) { if (assignee.agentId) { return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8); } if (assignee.userId) { return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board"; } return "Unassigned"; } 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; currentUserId?: string | null; userLabelMap?: ReadonlyMap | 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 function resolveIssueChatHumanAuthor(args: { authorName?: string | null; authorUserId?: string | null; currentUserId?: string | null; userProfileMap?: ReadonlyMap | null; }) { const { authorName, authorUserId, currentUserId, userProfileMap } = args; const profile = authorUserId ? userProfileMap?.get(authorUserId) ?? null : null; const isCurrentUser = Boolean(authorUserId && currentUserId && authorUserId === currentUserId); const resolvedAuthorName = profile?.label?.trim() || authorName?.trim() || (authorUserId === "local-board" ? "Board" : (isCurrentUser ? "You" : "User")); return { isCurrentUser, authorName: resolvedAuthorName, avatarUrl: profile?.image ?? null, }; } function formatRunStatusLabel(status: string) { switch (status) { case "timed_out": return "timed out"; default: return status.replace(/_/g, " "); } } function runStatusClass(status: string) { switch (status) { case "succeeded": return "text-green-700 dark:text-green-300"; case "failed": case "error": return "text-red-700 dark:text-red-300"; case "timed_out": return "text-orange-700 dark:text-orange-300"; case "running": return "text-cyan-700 dark:text-cyan-300"; case "queued": case "pending": return "text-amber-700 dark:text-amber-300"; case "cancelled": return "text-muted-foreground"; default: return "text-foreground"; } } 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; 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; 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 hasActiveTool = toolParts.some((t) => t.result === undefined); const isActive = isMessageRunning && hasActiveTool; const [expanded, setExpanded] = useState(isActive); const rawSegments = Array.isArray(custom.chainOfThoughtSegments) ? (custom.chainOfThoughtSegments as SegmentTiming[]) : []; const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null; 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 (
{expanded && hasContent ? (
{isActive ? ( <> {allReasoningText ? : null} {toolParts.length > 0 ? : null} ) : ( <> {allReasoningText ? : null} {toolParts.map((tool) => ( ))} )}
) : null}
); } 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 (
{ticker.exiting !== null && ( setTicker((t) => ({ ...t, exiting: null }))} > {ticker.exiting} )} 0 && "cot-line-enter", )} > {ticker.current}
); } 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 (
{isRunning ? ( ) : ( )}
{ticker.exiting !== null && ( setTicker((t) => ({ ...t, exiting: null }))} > {ticker.exiting} )} 0 && "cot-line-enter", )} > {ticker.current}
); } function CopyablePreBlock({ children, className }: { children: string; className?: string }) { const [copied, setCopied] = useState(false); return (
{children}
); } const TOOL_ICON_MAP: Record> = { // Extend with specific tool icons as they become known }; 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); 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 (
{open ?
: null}
{open ? (
{nonIntentDetails.length > 0 ? (
Input
{nonIntentDetails.map((detail) => (
{detail.label}
{detail.value}
))}
) : rawArgsText ? (
Input
{rawArgsText}
) : null} {result !== undefined ? (
Result
{resultText}
) : null}
) : null}
); } function getThreadMessageCopyText(message: ThreadMessage) { return message.content .filter((part): part is TextMessagePart => part.type === "text") .map((part) => part.text) .join("\n\n"); } function IssueChatTextParts({ message, recessed = false, }: { message: ThreadMessage; recessed?: boolean; }) { return ( <> {message.content .filter((part): part is TextMessagePart => part.type === "text") .map((part, index) => ( ))} ); } 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; } function IssueChatAssistantParts({ message, hasCoT, }: { message: ThreadMessage; hasCoT: boolean; }) { return ( <> {groupAssistantParts(message.content).map((group) => { if (group.type === "text") { return ( ); } return ( ); })} ); } function IssueChatUserMessage({ message }: { message: ThreadMessage }) { const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId, currentUserId, userProfileMap, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; 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 queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null; const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued"; const pending = custom.clientStatus === "pending"; const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; const [copied, setCopied] = useState(false); const { isCurrentUser, authorName: resolvedAuthorName, avatarUrl, } = resolveIssueChatHumanAuthor({ authorName, authorUserId, currentUserId, userProfileMap, }); const authorAvatar = ( {avatarUrl ? : null} {initialsForName(resolvedAuthorName)} ); const messageBody = (
{resolvedAuthorName}
{queued ? (
{queueBadgeLabel} {queueTargetRunId && onInterruptQueued ? ( ) : null} {onCancelQueued ? ( ) : null}
) : null}
{pending ? (
Sending...
) : (
{message.createdAt ? commentDateLabel(message.createdAt) : ""} {message.createdAt ? formatDateTime(message.createdAt) : ""}
)}
); return (
{isCurrentUser ? ( <> {messageBody} {authorAvatar} ) : ( <> {authorAvatar} {messageBody} )}
); } function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { const { feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl, onVote, agentMap, activeRunIds, onStopRun, stoppingRunId, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; 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 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 = canStopIssueChatRun({ runId, runStatus, activeRunIds }); const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null; const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call"); const isFoldable = !isRunning && !!chainOfThoughtLabel; const [folded, setFolded] = useState(isFoldable); const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable }); const [copied, setCopied] = useState(false); const copyText = 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 activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null; return (
{agentIcon ? ( ) : ( {initialsForName(authorName)} )}
{isFoldable ? ( ) : (
{authorName} {isRunning ? ( Running ) : null}
)} {!folded ? ( <>
{message.content.length === 0 && waitingText ? (
{agentIcon ? ( ) : ( )} {waitingText}
) : null} {notices.length > 0 ? (
{notices.map((notice, index) => (
{notice}
))}
) : null}
{commentId && onVote ? ( ) : null} {message.createdAt ? commentDateLabel(message.createdAt) : ""} {message.createdAt ? formatDateTime(message.createdAt) : ""} { void navigator.clipboard.writeText(copyText); }} > Copy message {canStopRun && onStopRun && runId ? ( { void onStopRun(runId); }} > {stoppingRunId === runId ? "Stopping…" : "Stop run"} ) : null} {runHref ? ( View run ) : null}
) : null}
); } function IssueChatFeedbackButtons({ activeVote, sharingPreference = "prompt", termsUrl, onVote, }: { activeVote: FeedbackVoteValue | null; sharingPreference: FeedbackDataSharingPreference; termsUrl: string | null; onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise; }) { const [isSaving, setIsSaving] = useState(false); const [optimisticVote, setOptimisticVote] = useState(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 ( <>
What could have been better?