// Owns: message-timestamp helpers, SelectionAttachedPill, UserMessage, // AssistantMessage, MessageBranchPicker, CheckpointContext, MessageActionsContext, // RunningActivityStatus, ThinkingIndicator, and displayableUserMessageText. import { isPastedTextAttachmentName } from "@agent-native/toolkit/composer/pasted-text"; import { PastedTextChip } from "@agent-native/toolkit/composer/PastedTextChip"; import { useThread, useMessageRuntime, useComposer, MessagePrimitive, ActionBarPrimitive, BranchPickerPrimitive, ComposerPrimitive, useMessagePartReasoning, useMessagePartRuntime, useAuiState, } from "@assistant-ui/react"; import type { Attachment } from "@assistant-ui/react"; import { IconX, IconCheck, IconCopy, IconChevronDown, IconChevronLeft, IconChevronRight, IconDots, IconGitFork, IconId, IconQuote, IconRefresh, IconArrowBackUp, IconFile, IconFolder, IconFileText, IconCheckbox, IconMail, IconUser, IconPresentation, IconStack2, IconMessageChatbot, IconPencil, IconAlertTriangle, IconLoader2, } from "@tabler/icons-react"; import React, { useState, useEffect, useCallback, useRef } from "react"; import { getActiveRun } from "../active-run-state.js"; import { agentNativePath } from "../api-path.js"; import { writeClipboardText } from "../clipboard.js"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../components/ui/dropdown-menu.js"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../components/ui/tooltip.js"; import { ThumbsFeedback } from "../observability/ThumbsFeedback.js"; import { McpConnectionSuggestion } from "../resources/McpConnectionSuggestion.js"; import type { ContentPart } from "../sse-event-processor.js"; import { isCallAgentToolCallShadowed, shadowedCallAgentToolCallIds, } from "../tool-display.js"; import { cn } from "../utils.js"; import { MarkdownText, renderMarkdownToClipboardHtml, } from "./markdown-renderer.js"; import { getAssistantRunDurationMs } from "./repo-helpers.js"; import { ToolCallFallback, ToolActivityPresentation, FilesChangedSummary, ASSISTANT_VISIBLE_TOOL_CALL_LIMIT, ChatRunningContext, ChatRunDurationContext, RanToolsSummary, ReasoningCell, WorkedForSummary, } from "./tool-call-display.js"; // ─── Pending selection context key ─────────────────────────────────────────── // Mirrored from AssistantChat to avoid a cross-import on a private constant. const PENDING_SELECTION_KEY = "pending-selection-context"; // ─── displayableUserMessageText ─────────────────────────────────────────────── export function displayableUserMessageText(text: string): string { return text .replace(/]*>[\s\S]*?<\/context>\n?/gi, "") .replace(/]*>[\s\S]*$/gi, "") .replace(/<\/context>/gi, "") .trim(); } export function isHiddenUserMessage(message: unknown): boolean { const meta = (message as { metadata?: unknown })?.metadata as | { custom?: { agentNativeHiddenUserMessage?: unknown; agentNativeRecoveryAction?: unknown; }; } | undefined; return ( meta?.custom?.agentNativeHiddenUserMessage === true || meta?.custom?.agentNativeRecoveryAction === "continue" || meta?.custom?.agentNativeRecoveryAction === "retry" ); } // ─── Message timestamp helpers ──────────────────────────────────────────────── interface FormattedMessageTimestamp { short: string; full: string; } const messageFooterFadeClassName = "opacity-0 transition-[color,opacity] duration-150 group-hover:opacity-100 group-focus-within:opacity-100"; function coerceMessageDate(value: unknown): Date | null { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value; } if (typeof value === "string" || typeof value === "number") { const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } return null; } function isSameCalendarDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } export function formatMessageTimestamp( value: unknown, ): FormattedMessageTimestamp | null { const date = coerceMessageDate(value); if (!date) return null; const now = new Date(); const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); const time = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }).format(date); let short: string; if (isSameCalendarDay(date, now)) { short = time; } else if (isSameCalendarDay(date, yesterday)) { short = `Yesterday ${time}`; } else if (date.getFullYear() === now.getFullYear()) { short = `${new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", }).format(date)}, ${time}`; } else { short = `${new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", }).format(date)}, ${time}`; } return { short, full: new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }).format(date), }; } export function MessageTimestamp({ timestamp, className, }: { timestamp: FormattedMessageTimestamp; className?: string; }) { return ( {timestamp.short} ); } // ─── SelectionAttachedPill ──────────────────────────────────────────────────── export function SelectionAttachedPill() { const [length, setLength] = useState(null); useEffect(() => { let cancelled = false; fetch( agentNativePath( `/_agent-native/application-state/${PENDING_SELECTION_KEY}`, ), ) .then((r) => (r.ok && r.status !== 204 ? r.json() : null)) .then((data) => { if (cancelled) return; const text = (data?.value?.text as string | undefined) ?? (data?.text as string | undefined); if (text) setLength(text.length); }) .catch(() => {}); return () => { cancelled = true; }; }, []); useEffect(() => { function onAttached(e: Event) { const detail = (e as CustomEvent).detail; if (typeof detail?.length === "number") setLength(detail.length); } function onCleared() { setLength(null); } window.addEventListener("agent-panel:selection-attached", onAttached); window.addEventListener("agent-panel:selection-cleared", onCleared); return () => { window.removeEventListener("agent-panel:selection-attached", onAttached); window.removeEventListener("agent-panel:selection-cleared", onCleared); }; }, []); if (length === null || length === 0) return null; return (
{length.toLocaleString()} chars of selection attached
); } // ─── CheckpointContext / MessageActionsContext ──────────────────────────────── export const CheckpointContext = React.createContext<{ apiUrl: string; devMode: boolean; threadId?: string; // Run ids that actually have a saved checkpoint. Restore is only offered for // these — auto-checkpointing skips turns that started from a dirty tree or a // non-git cwd, and without this the menu item appears on every turn and does // nothing when clicked. checkpointRunIds?: ReadonlySet; } | null>(null); export const MessageActionsContext = React.createContext<{ onForkChat?: () => void | boolean | Promise; } | null>(null); /** * Restore rewrites the working tree, so only offer it when the server actually * has a checkpoint for this turn. Auto-checkpointing skips turns that started * from a dirty tree or a non-git cwd; gating on Code mode alone put a * "Revert to here" item on turns where clicking it could do nothing. */ export function shouldOfferRestore(args: { devMode: boolean | undefined; isComplete: boolean; isLast: boolean; runId: string | undefined; checkpointRunIds: ReadonlySet | undefined; }): boolean { return Boolean( args.devMode && args.isComplete && !args.isLast && args.runId && args.checkpointRunIds?.has(args.runId), ); } /** * Live yields put the run id at `metadata.custom.runId`; server-persisted * messages put it at `metadata.runId`. */ export function assistantMessageRunId(message: unknown): string | undefined { const metadata = (message as { metadata?: unknown })?.metadata as | { custom?: { runId?: unknown }; runId?: unknown } | undefined; return typeof metadata?.custom?.runId === "string" ? metadata.custom.runId : typeof metadata?.runId === "string" ? metadata.runId : undefined; } // ─── MessageBranchPicker ────────────────────────────────────────────────────── export function MessageBranchPicker() { return ( {"/"} ); } // ─── Mention rendering ──────────────────────────────────────────────────────── const mentionIconProps = { size: 14, className: "shrink-0 text-muted-foreground", }; function MentionChipIcon({ icon }: { icon?: string }) { switch (icon) { case "folder": return ; case "document": return ; case "form": return ; case "email": return ; case "user": return ; case "deck": return ; case "agent": return ; case "file": return ; default: return ; } } // Matches rich mention format: @[label|icon] or plain @word const richMentionPattern = /@\[([^\]|]+)\|([^\]]+)\]/g; const plainMentionPattern = /((?:^|(?<=\s))@(\w+))/g; function UserMessageText({ text }: { text: string }) { // Strip injected ... blocks before display const displayText = displayableUserMessageText(text); const parts: React.ReactNode[] = []; let lastIndex = 0; let match: RegExpExecArray | null; let hasRichMentions = false; // First try rich mentions (@[label|icon]) richMentionPattern.lastIndex = 0; while ((match = richMentionPattern.exec(displayText)) !== null) { hasRichMentions = true; const matchStart = match.index; if (matchStart > lastIndex) { parts.push(displayText.slice(lastIndex, matchStart)); } const label = match[1]; const icon = match[2]; parts.push( {label} , ); lastIndex = matchStart + match[0].length; } if (hasRichMentions) { if (lastIndex < displayText.length) { parts.push(displayText.slice(lastIndex)); } return <>{parts}; } // Fallback: plain @word mentions (for older messages) plainMentionPattern.lastIndex = 0; while ((match = plainMentionPattern.exec(displayText)) !== null) { const matchStart = match.index; if (matchStart > lastIndex) { parts.push(displayText.slice(lastIndex, matchStart)); } const mentionName = match[2]; parts.push( @{mentionName} , ); lastIndex = matchStart + match[0].length; } if (lastIndex < displayText.length) { parts.push(displayText.slice(lastIndex)); } return <>{parts.length > 0 ? parts : displayText}; } // ─── UserMessageAttachments ─────────────────────────────────────────────────── function UserMessageAttachments() { const messageRuntime = useMessageRuntime(); const msg = messageRuntime.getState(); // assistant-ui stores user attachments on msg.attachments (separate from content). // Each attachment has: { id, type, name, contentType?, content: MessagePart[] }. // Image adapters put a {type:"image", image:"data:..."} part in content; text // adapters put a {type:"text", text:"..."} part. Fall back to a // file chip when there's no inline image. const attachments = (msg as { attachments?: readonly Attachment[] }) .attachments; if (!attachments || attachments.length === 0) return null; return (
{attachments.map((att) => { if (isPastedTextAttachmentName(att.name)) { return ; } // Prefer the hosted upload URL when available (set by the server after // preUploadAttachments). This avoids re-shipping base64 in each poll // and lets the browser cache the image via a stable URL. const uploadUrl = ( att as unknown as { metadata?: { uploadUrl?: string } } ).metadata?.uploadUrl; const imagePart = att.content?.find( (p): p is { type: "image"; image: string } => p.type === "image" && "image" in p && !!(p as { image?: string }).image, ); const imageSrc = uploadUrl || imagePart?.image || null; if (imageSrc) { return (
{att.name}
); } return (
{att.name || "file"}
); })}
); } // ─── UserMessageEditComposer ────────────────────────────────────────────────── function UserMessageEditComposer() { return (
); } // ─── MessageActionsMenu ──────────────────────────────────────────────────────── export function MessageActionsMenu({ showRevert, onRevert, }: { showRevert?: boolean; onRevert?: () => void; } = {}) { const [open, setOpen] = useState(false); const [copied, setCopied] = useState(null); const messageRuntime = useMessageRuntime(); const actionsCtx = React.useContext(MessageActionsContext); const timestamp = formatMessageTimestamp(messageRuntime.getState().createdAt); const handleCopyMessage = useCallback(() => { const m = messageRuntime.getState(); const text = m.content .filter((p) => p.type === "text") .map((p) => (p as { text: string }).text) .join("\n"); // Rich flavor keeps formatting in targets that read text/html (e.g. Slack); // null when the markdown renderer isn't ready yet, so we copy plain markdown. const html = renderMarkdownToClipboardHtml(text); void writeClipboardText(text, html ? { html } : undefined).then((ok) => { if (!ok) return; setCopied("message"); setTimeout(() => { setCopied(null); setOpen(false); }, 1000); }); }, [messageRuntime]); const handleCopyRequestId = useCallback(() => { const m = messageRuntime.getState(); // If the message carries no run id (e.g. the run is still in flight and // this is the first message), fall back to the active-run state so a hung / // mid-stream chat still surfaces a usable trace ID. Last resort is the // assistant-ui local message id. const runId = assistantMessageRunId(m) || (typeof window !== "undefined" ? getActiveRun()?.runId : null) || m.id || ""; void writeClipboardText(runId).then((ok) => { if (!ok) return; setCopied("id"); setTimeout(() => { setCopied(null); setOpen(false); }, 1000); }); }, [messageRuntime]); const handleForkChat = useCallback(() => { setOpen(false); actionsCtx?.onForkChat?.(); }, [actionsCtx]); const handleRevert = useCallback(() => { setOpen(false); onRevert?.(); }, [onRevert]); return ( {actionsCtx?.onForkChat && ( Fork Chat )} { e.preventDefault(); handleCopyMessage(); }} > {copied === "message" ? ( ) : ( )} {copied === "message" ? "Copied!" : "Copy Message"} { e.preventDefault(); handleCopyRequestId(); }} > {copied === "id" ? ( ) : ( )} {copied === "id" ? "Copied!" : "Copy Request ID"} {showRevert && ( Revert to here )} {timestamp && ( <> Sent {timestamp.short} )} ); } // ─── UserMessage ────────────────────────────────────────────────────────────── export function UserMessage() { const [expanded, setExpanded] = useState(false); const [isExpandable, setIsExpandable] = useState(false); const contentRef = useRef(null); const messageRuntime = useMessageRuntime(); const message = messageRuntime.getState(); const timestamp = formatMessageTimestamp(message.createdAt); const isEditing = useComposer((state) => state.isEditing); const chatRunning = React.useContext(ChatRunningContext); const hidden = isHiddenUserMessage(message); const hasDisplayableText = !hidden && (message.content ?.filter((part): part is { type: "text"; text: string } => { return part.type === "text" && typeof part.text === "string"; }) .some((part) => displayableUserMessageText(part.text).length > 0) ?? false); useEffect(() => { const el = contentRef.current; if (!el || !hasDisplayableText) return; const measure = () => { setIsExpandable(el.scrollHeight > 200); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, [hasDisplayableText]); if (hidden) return null; // When in edit mode, show the inline edit composer instead of the message bubble. if (isEditing) { return (
); } return (
{hasDisplayableText && (
{ const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const fragment = selection.getRangeAt(0).cloneContents(); const mentions = fragment.querySelectorAll( "[data-mention-label]", ); if (mentions.length === 0) return; e.preventDefault(); mentions.forEach((el) => { el.textContent = `@${el.getAttribute("data-mention-label")}`; }); const div = document.createElement("div"); div.appendChild(fragment); e.clipboardData.setData("text/plain", div.textContent || ""); }} >
{!expanded && isExpandable && (
)} {/* Edit hover affordance — appears on hover when not running */} {!chatRunning && hasDisplayableText && ( Edit message )}
)} {hasDisplayableText && isExpandable && ( )}
{timestamp && ( )}
); } // ─── AssistantMessage ───────────────────────────────────────────────────────── function assistantMessageHasRenderableContent(message: { content?: unknown; }): boolean { const content = message.content; if (typeof content === "string") return content.trim().length > 0; if (!Array.isArray(content)) return false; return content.some((part) => { if (!part || typeof part !== "object") return false; const type = (part as { type?: unknown }).type; if (type === "text" || type === "reasoning") { const text = (part as { text?: unknown }).text; return typeof text === "string" && text.trim().length > 0; } return true; }); } function assistantMessageStatusIsTerminal(message: { status?: { type?: unknown }; }): boolean { const statusType = message.status?.type; return statusType === "complete" || statusType === "incomplete"; } export function messageTextFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .flatMap((part) => { if (!part || typeof part !== "object") return []; const record = part as { type?: unknown; text?: unknown; }; return record.type === "text" && typeof record.text === "string" ? [record.text] : []; }) .join("\n"); } export function latestUserMessageText(messages: readonly unknown[]): string { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (!message || typeof message !== "object") continue; const record = message as { role?: unknown; content?: unknown }; if (record.role !== "user" || isHiddenUserMessage(message)) continue; return displayableUserMessageText(messageTextFromContent(record.content)); } return ""; } export function userMessageTextBeforeAssistant( messages: readonly unknown[], assistantMessageId: string, ): string { const assistantIndex = messages.findIndex((message) => { if (!message || typeof message !== "object") return false; return (message as { id?: unknown }).id === assistantMessageId; }); if (assistantIndex < 1) return ""; for (let index = assistantIndex - 1; index >= 0; index -= 1) { const message = messages[index]; if (!message || typeof message !== "object") continue; const record = message as { role?: unknown; content?: unknown }; if (record.role !== "user" || isHiddenUserMessage(message)) continue; return displayableUserMessageText(messageTextFromContent(record.content)); } return ""; } export function assistantMessageHasUnresolvedTool(content: unknown): boolean { if (!Array.isArray(content)) return false; return content.some((part): boolean => { if (!part || typeof part !== "object") return false; const record = part as { type?: unknown; result?: unknown }; return record.type === "tool-call" && record.result === undefined; }); } export function assistantMessageHasCompletedCustomUi( content: unknown, ): boolean { if (!Array.isArray(content)) return false; let lastTextIndex = -1; for (let index = 0; index < content.length; index++) { const part = content[index]; if ( part && typeof part === "object" && (part as { type?: unknown }).type === "text" ) { lastTextIndex = index; } } let hasCompletedTool = false; let lastCompletedToolIsCustomUi = false; for (let index = lastTextIndex + 1; index < content.length; index++) { const part = content[index]; if (!part || typeof part !== "object") continue; const record = part as { type?: unknown; result?: unknown; isError?: unknown; activity?: unknown; chatUI?: unknown; mcpApp?: unknown; }; if ( record.type !== "tool-call" || record.activity === true || record.result === undefined || record.isError === true ) { continue; } hasCompletedTool = true; lastCompletedToolIsCustomUi = record.chatUI !== undefined || record.mcpApp !== undefined; } return hasCompletedTool && lastCompletedToolIsCustomUi; } export function assistantMessageHasCustomUi(content: unknown): boolean { if (!Array.isArray(content)) return false; return content.some((part) => { if (!part || typeof part !== "object") return false; const record = part as { type?: unknown; chatUI?: unknown; mcpApp?: unknown; }; return ( record.type === "tool-call" && (record.chatUI !== undefined || record.mcpApp !== undefined) ); }); } // Only the last assistant message may shimmer as "the currently running // tool" — an older message's dangling unresolved tool-call must never // shimmer once a later run is active. export function computeActiveTailToolCallId( content: ContentPart[] | undefined, { chatRunning, isLast }: { chatRunning: boolean; isLast: boolean }, ): string | null { if (!isLast) return null; return ( content?.reduce( (latestToolCallId, part, index) => part.type === "tool-call" && !isCallAgentToolCallShadowed(content, index) && (chatRunning || part.activity === true) ? part.toolCallId : latestToolCallId, null as string | null, ) ?? null ); } export function shouldShowAssistantMessageFooter({ isLast, chatRunning, hasRenderableContent, statusIsTerminal, hasUnresolvedTool, }: { isLast: boolean; chatRunning: boolean; hasRenderableContent: boolean; statusIsTerminal: boolean; hasUnresolvedTool?: boolean; }): boolean { if (!hasRenderableContent) return false; if (!isLast) return true; if (chatRunning) return false; if (hasUnresolvedTool) return false; return statusIsTerminal; } /** * Server-authoritative "a run for this thread is still active and running". * Local `chatRunning` dips to not-running at every chunk boundary and transport * re-attach while the turn is alive server-side, so it cannot decide on its own * that the agent stopped. */ export const ServerRunActiveContext = React.createContext(false); export function shouldShowMissingFinalResponse({ isCurrentTurnRunning, serverRunActive, statusIsTerminal, hasAssistantText, hasUnresolvedTool, hasCompletedCustomUi, }: { isCurrentTurnRunning: boolean; serverRunActive?: boolean; statusIsTerminal: boolean; hasAssistantText: boolean; hasUnresolvedTool: boolean; hasCompletedCustomUi?: boolean; }): boolean { if (serverRunActive) return false; // A completed tool can make the latest message look terminal before the // active turn attaches its follow-up text. return ( !isCurrentTurnRunning && statusIsTerminal && !hasAssistantText && !hasUnresolvedTool && !hasCompletedCustomUi ); } /** * "The agent stopped" is derived from local client state, which dips to * not-running at every chunk boundary and transport re-attach while the turn is * still alive server-side. Requiring the shape to hold for a beat keeps the * notice off the screen for those gaps without hiding a real stop for long. */ const MISSING_FINAL_RESPONSE_SETTLE_MS = 3_000; export function useSettledFlag(active: boolean, delayMs: number): boolean { const [settled, setSettled] = useState(false); useEffect(() => { if (!active || delayMs <= 0) { setSettled(false); return; } const timer = window.setTimeout(() => setSettled(true), delayMs); return () => window.clearTimeout(timer); }, [active, delayMs]); return active && (delayMs <= 0 || settled); } export function shouldShowAssistantWorkSummary({ isLast, isComplete, hasCollapsibleWork, hasUnresolvedTool, }: { isLast: boolean; isComplete: boolean; hasCollapsibleWork: boolean; hasUnresolvedTool: boolean; }): boolean { if (!hasCollapsibleWork || hasUnresolvedTool) return false; // Keep completed historical work wrapped while a later turn is running. // Removing the wrapper exposes/remounts ReasoningCell and resets its // disclosure state to the default-open value on every new submission. return isComplete || !isLast; } function ReasoningMessagePart() { const part = useMessagePartReasoning(); const partRuntime = useMessagePartRuntime(); const messageParts = useAuiState((state) => state.message.parts); const isStreaming = part.status?.type === "running"; const partIndex = partRuntime.path.messagePartSelector.type === "index" ? partRuntime.path.messagePartSelector.index : -1; const latestReasoningPartIndex = messageParts.reduce( (latestIndex, messagePart, index) => messagePart.type === "reasoning" ? index : latestIndex, -1, ); // Time thinking client-side: record the moment streaming first starts and // the moment it stops so the cell can show "Thought for Xs". Historical // messages that were never observed streaming in this session never get a // start time, so they correctly fall back to a plain "Thought" label. const startedAtRef = useRef(null); const [durationMs, setDurationMs] = useState(null); useEffect(() => { if (isStreaming) { startedAtRef.current ??= Date.now(); return; } if (startedAtRef.current != null) { setDurationMs(Date.now() - startedAtRef.current); startedAtRef.current = null; } }, [isStreaming]); return ( ); } const ALWAYS_VISIBLE_ASSISTANT_TOOLS = new Set(["connect-builder"]); export function isCollapsibleAssistantWorkPart(part: { type?: string; toolName?: string; chatUI?: unknown; mcpApp?: unknown; }): boolean { if (part.type === "reasoning") return true; return ( part.type === "tool-call" && !ALWAYS_VISIBLE_ASSISTANT_TOOLS.has(part.toolName ?? "") && part.chatUI === undefined && part.mcpApp === undefined ); } export function getAssistantToolSummaryInfo( parts: readonly { type?: string; toolCallId?: string; toolName?: string; args?: Record; chatUI?: unknown; mcpApp?: unknown; }[], ): { startIndex: number; hiddenToolCount: number } { const toolCallIndices = parts.reduce((indices, part, index) => { if ( part.type === "tool-call" && !isCallAgentToolCallShadowed(parts, index) && isCollapsibleAssistantWorkPart(part) ) { indices.push(index); } return indices; }, []); if (toolCallIndices.length <= ASSISTANT_VISIBLE_TOOL_CALL_LIMIT) { return { startIndex: -1, hiddenToolCount: 0 }; } const startIndex = toolCallIndices[ toolCallIndices.length - ASSISTANT_VISIBLE_TOOL_CALL_LIMIT ]!; return { startIndex, hiddenToolCount: toolCallIndices.length - ASSISTANT_VISIBLE_TOOL_CALL_LIMIT, }; } function groupAssistantWorkParts( part: { type?: string; toolCallId?: string; toolName?: string; args?: Record; chatUI?: unknown; mcpApp?: unknown; }, index: number, parts: readonly { type?: string; toolCallId?: string; toolName?: string; args?: Record; chatUI?: unknown; mcpApp?: unknown; }[], ): ["group-work"] | ["group-work", "group-ran-tools"] | null { if (isCallAgentToolCallShadowed(parts, index)) return null; if (isCollapsibleAssistantWorkPart(part)) { const { startIndex } = getAssistantToolSummaryInfo(parts); if (isAssistantToolSummaryPart(parts, index, startIndex)) { return ["group-work", "group-ran-tools"]; } return ["group-work"]; } return null; } function isAssistantToolSummaryPart( parts: readonly { type?: string; toolCallId?: string; toolName?: string; args?: Record; chatUI?: unknown; mcpApp?: unknown; }[], index: number, startIndex: number, ): boolean { if (startIndex < 0 || index >= startIndex) return false; if ( isCallAgentToolCallShadowed(parts, index) || !isCollapsibleAssistantWorkPart(parts[index]!) ) { return false; } let segmentStart = index; while ( segmentStart > 0 && !isCallAgentToolCallShadowed(parts, segmentStart - 1) && isCollapsibleAssistantWorkPart(parts[segmentStart - 1]!) ) { segmentStart--; } let segmentEnd = index + 1; while ( segmentEnd < startIndex && !isCallAgentToolCallShadowed(parts, segmentEnd) && isCollapsibleAssistantWorkPart(parts[segmentEnd]!) ) { segmentEnd++; } return parts .slice(segmentStart, segmentEnd) .some((candidate) => candidate.type === "tool-call"); } export function AssistantMessage() { const [restoreState, setRestoreState] = useState< "idle" | "confirming" | "restoring" | "error" >("idle"); const [restoreError, setRestoreError] = useState(null); const messageRuntime = useMessageRuntime(); const thread = useThread(); const chatRunning = React.useContext(ChatRunningContext); const lastRunDurationMs = React.useContext(ChatRunDurationContext); const msg = messageRuntime.getState(); const persistedDurationMs = getAssistantRunDurationMs(msg); const timestamp = formatMessageTimestamp(msg.createdAt); const isLast = thread.messages.length > 0 && thread.messages[thread.messages.length - 1].id === msg.id; const hasRenderableContent = assistantMessageHasRenderableContent(msg); const hasUnresolvedTool = assistantMessageHasUnresolvedTool(msg.content); const responseConnectionText = messageTextFromContent(msg.content); const statusIsTerminal = assistantMessageStatusIsTerminal(msg); const hasCompletedCustomUi = assistantMessageHasCompletedCustomUi( msg.content, ); const hasCustomUi = assistantMessageHasCustomUi(msg.content); const serverRunActive = React.useContext(ServerRunActiveContext); const showMissingFinalResponse = useSettledFlag( shouldShowMissingFinalResponse({ isCurrentTurnRunning: isLast && chatRunning, serverRunActive: isLast && serverRunActive, statusIsTerminal, hasAssistantText: responseConnectionText.trim().length > 0, hasUnresolvedTool, hasCompletedCustomUi, }), isLast ? MISSING_FINAL_RESPONSE_SETTLE_MS : 0, ); const responseConnectionContext = userMessageTextBeforeAssistant( thread.messages, msg.id, ); const isComplete = shouldShowAssistantMessageFooter({ isLast, chatRunning, hasRenderableContent, statusIsTerminal, hasUnresolvedTool, }); const cpCtx = React.useContext(CheckpointContext); // Capture live run duration when this message finishes streaming. const runStartedAtRef = useRef(null); const [capturedDurationMs, setCapturedDurationMs] = useState( null, ); useEffect(() => { if (chatRunning && isLast) { if (runStartedAtRef.current == null) { runStartedAtRef.current = Date.now(); } return; } if (!chatRunning && isLast && capturedDurationMs == null) { const durationMs = lastRunDurationMs ?? (runStartedAtRef.current == null ? null : Date.now() - runStartedAtRef.current); if (durationMs == null) return; setCapturedDurationMs(durationMs); runStartedAtRef.current = null; } }, [chatRunning, isLast, capturedDurationMs, lastRunDurationMs]); // Animate collapse only when this message just finished running in-session. const wasRunningRef = useRef(false); const [animateCollapse, setAnimateCollapse] = useState(false); useEffect(() => { if (wasRunningRef.current && !chatRunning && isComplete && isLast) { setAnimateCollapse(true); } wasRunningRef.current = chatRunning && isLast; }, [chatRunning, isComplete, isLast]); const messageRunId = assistantMessageRunId(msg); const handleRestore = useCallback(async () => { if (restoreState === "idle" || restoreState === "error") { setRestoreError(null); setRestoreState("confirming"); return; } if (restoreState !== "confirming" || !cpCtx) return; if (!messageRunId) { setRestoreError("This message has no run to restore to."); setRestoreState("error"); return; } setRestoreState("restoring"); try { const restoreRes = await fetch(`${cpCtx.apiUrl}/checkpoints/restore`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ runId: messageRunId }), }); if (restoreRes.ok) { window.location.reload(); return; } const payload = (await restoreRes.json().catch(() => null)) as { error?: unknown; } | null; setRestoreError( typeof payload?.error === "string" ? payload.error : `Restore failed (${restoreRes.status}).`, ); setRestoreState("error"); } catch (err) { setRestoreError( err instanceof Error ? err.message : "Restore request failed.", ); setRestoreState("error"); } }, [restoreState, cpCtx, messageRunId]); const cancelRestore = useCallback(() => { setRestoreError(null); setRestoreState("idle"); }, []); const showRestore = shouldOfferRestore({ devMode: cpCtx?.devMode, isComplete, isLast, runId: messageRunId, checkpointRunIds: cpCtx?.checkpointRunIds, }); // Collect parts for the files-changed summary (code-agent turns only). const msgContent = msg.content as ContentPart[] | undefined; const hasCodeAgentTools = Array.isArray(msgContent) && msgContent.some( (p) => p.type === "tool-call" && p.structuredMeta && (p.structuredMeta.toolKind === "edit" || p.structuredMeta.toolKind === "write"), ); const hasCollapsibleWork = Array.isArray(msgContent) && msgContent.some( (p, index) => !isCallAgentToolCallShadowed(msgContent, index) && (p.type !== "tool-call" || p.activity !== true) && isCollapsibleAssistantWorkPart(p), ); const shadowedToolCallIds = Array.isArray(msgContent) ? shadowedCallAgentToolCallIds(msgContent) : new Set(); const activeTailToolCallId = computeActiveTailToolCallId(msgContent, { chatRunning, isLast, }); if (!hasRenderableContent) return null; return (
{({ part, children }) => { switch (part.type) { case "group-work": { const showSummary = shouldShowAssistantWorkSummary({ isLast, isComplete, hasCollapsibleWork, hasUnresolvedTool, }); if (!showSummary) return <>{children}; return ( {children} ); } case "group-ran-tools": { const toolCount = part.indices.filter( (index) => msgContent?.[index]?.type === "tool-call", ).length; return ( {children} ); } case "text": return ; case "reasoning": return ; case "tool-call": if (shadowedToolCallIds.has(part.toolCallId)) return null; return part.toolUI ? ( {part.toolUI} ) : ( ); default: return null; } }} {showMissingFinalResponse && (

The agent stopped without sending a final message. Ask it to continue or retry.

)} {isComplete && hasCodeAgentTools && msgContent && ( )} {isComplete && ( )} {isLast && hasUnresolvedTool && !chatRunning && ( )}
{isComplete && (
{/* Regenerate button — only on the last assistant message, auto-disabled while running */} {isLast && ( Regenerate response )} {timestamp && ( )}
{showRestore && restoreState === "confirming" ? (
) : showRestore && restoreState === "restoring" ? ( Restoring... ) : restoreState === "error" ? ( {restoreError} ) : ( m.id === msg.id)} /> )}
)}
); } // ─── RunningActivityStatus / ThinkingIndicator ──────────────────────────────── export function RunningActivityStatus({ label }: { label: string }) { return (
); } export function ThinkingIndicator({ label = "Thinking", }: { label?: string } = {}) { return (
{label}
); }