import { Fragment, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { MessageBubble } from "@/components/MessageBubble"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; interface ThreadMessagesProps { messages: UIMessage[]; /** When true, agent turn still in flight — keeps activity timeline expanded. */ isStreaming?: boolean; hiddenUserMessageCount?: number; cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; forkBoundaryMessageCount?: number | null; onOpenFilePreview?: (path: string) => void; onForkFromMessage?: (beforeUserIndex: number) => void; } export type DisplayUnit = TurnUnit; /** True when this unit index is the last assistant text slice before the next user message (or end of thread). */ export function isFinalAssistantSliceBeforeNextUser( units: DisplayUnit[], index: number, ): boolean { const u = units[index]; if (u.type !== "message" || u.message.role !== "assistant") return true; for (let j = index + 1; j < units.length; j++) { const v = units[j]; if (v.type === "message" && v.message.role === "user") break; return false; } return true; } export function buildDisplayUnits( messages: UIMessage[], isStreaming = false, ): DisplayUnit[] { const units = normalizeActivityTimeline(messages, { preserveTrailingActivity: isStreaming, }); return isStreaming ? placeLiveActivityAtTurnBottom(units) : units; } export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { const flags = new Array(units.length).fill(true); let hasLaterUnitBeforeUser = false; for (let i = units.length - 1; i >= 0; i -= 1) { const unit = units[i]; if (unit.type === "message" && unit.message.role === "user") { hasLaterUnitBeforeUser = false; continue; } if (unit.type === "message" && unit.message.role === "assistant") { flags[i] = !hasLaterUnitBeforeUser; } hasLaterUnitBeforeUser = true; } return flags; } export function ThreadMessages({ messages, isStreaming = false, hiddenUserMessageCount = 0, cliApps = [], mcpPresets = [], forkBoundaryMessageCount = null, onOpenFilePreview, onForkFromMessage, }: ThreadMessagesProps) { const { t } = useTranslation(); const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]); const forkBoundaryAfterUnitIndex = useMemo( () => unitIndexAfterMessageCount(units, forkBoundaryMessageCount), [forkBoundaryMessageCount, units], ); const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); const liveActivityClusterIndices = useMemo( () => isStreaming ? currentActivityClusterIndices(units) : new Set(), [isStreaming, units], ); const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]); let nextUserIndex = hiddenUserMessageCount; return (
{units.map((unit, index) => { const prev = units[index - 1]; const marginTop = index > 0 ? marginAfterPrevUnit(prev) : ""; const next = units[index + 1]; const hasBodyBelow = unit.type === "activity" && next?.type === "message" && next.message.role === "assistant"; const userPromptId = unit.type === "message" && unit.message.role === "user" ? unit.message.id : undefined; const forkIndex = unit.type === "message" && unit.message.role === "assistant" && copyFlags[index] ? nextUserIndex : undefined; if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1; return (
{unit.type === "activity" ? ( ) : ( onForkFromMessage(forkIndex) : undefined } /> )}
{index === forkBoundaryAfterUnitIndex ? ( ) : null}
); })}
); } function unitIndexAfterMessageCount( units: DisplayUnit[], messageCount: number | null | undefined, ): number | null { if (messageCount == null || messageCount <= 0) return null; let seen = 0; for (let i = 0; i < units.length; i += 1) { const unit = units[i]; seen += unit.type === "activity" ? unit.messages.length : 1; if (seen >= messageCount) return i; } return null; } function ForkBoundaryDivider({ label }: { label: string }) { return (
{label}
); } function currentActivityClusterIndices(units: DisplayUnit[]): Set { const indices = new Set(); let markedCurrentActivity = false; for (let i = units.length - 1; i >= 0; i -= 1) { const unit = units[i]; if (unit.type === "activity") { if (!markedCurrentActivity) { indices.add(i); markedCurrentActivity = true; } continue; } if (unit.message.role === "assistant" && unit.message.isStreaming) continue; if (unit.message.role === "user") break; } return indices; } function placeLiveActivityAtTurnBottom(units: DisplayUnit[]): DisplayUnit[] { if (units.length < 2) return units; let turnStart = units.length - 1; while (turnStart > 0) { const previous = units[turnStart - 1]; if (previous.type === "message" && previous.message.role === "user") break; turnStart -= 1; } const trailingTurn = units.slice(turnStart); let liveActivityIndex = -1; for (let i = trailingTurn.length - 1; i >= 0; i -= 1) { if (trailingTurn[i]?.type === "activity") { liveActivityIndex = i; break; } } if (liveActivityIndex === -1 || liveActivityIndex === trailingTurn.length - 1) { return units; } const hasNonActivityAfterLiveActivity = trailingTurn .slice(liveActivityIndex + 1) .some((unit) => unit.type !== "activity"); if (hasNonActivityAfterLiveActivity) { return units; } const liveActivityUnit = trailingTurn[liveActivityIndex]; if (!liveActivityUnit || liveActivityUnit.type !== "activity") return units; const reorderedTurn = [ ...trailingTurn.slice(0, liveActivityIndex), ...trailingTurn.slice(liveActivityIndex + 1), liveActivityUnit, ]; return [...units.slice(0, turnStart), ...reorderedTurn]; } export function unitKeysForDisplay(units: DisplayUnit[]): string[] { const occurrences = new Map(); return units.map((unit, index) => { const base = unitKeyBase(unit, index); if (!base.startsWith("turn-") || base.endsWith("-user")) return base; const next = (occurrences.get(base) ?? 0) + 1; occurrences.set(base, next); return `${base}-${next}`; }); } function unitKeyBase(unit: DisplayUnit, index: number): string { if (unit.type === "activity") { const anchor = unit.messages[0]; const turnKey = stableTurnMessageKey(anchor, "activity"); if (turnKey) return turnKey; const anchorId = anchor?.id; return anchorId != null ? `activity-${anchorId}` : `activity-idx-${index}`; } const turnKey = stableTurnMessageKey(unit.message); if (turnKey) return turnKey; return unit.message.id; } function stableTurnMessageKey(message: UIMessage | undefined, fallbackPhase?: string): string | null { if (!message?.turnId) return null; const phase = message.turnPhase ?? fallbackPhase ?? message.kind ?? message.role; if (message.role === "user") return `turn-${message.turnId}-user`; if (message.kind === "trace") { return `turn-${message.turnId}-${phase}-${message.activitySegmentId ?? "activity"}`; } return `turn-${message.turnId}-${phase}`; } function marginAfterPrevUnit(prev: DisplayUnit): string { if (prev.type === "activity") { return "mt-4"; } const p = prev.message; const denseP = p.kind === "trace" || ( p.role === "assistant" && p.content.trim().length === 0 && (!!p.reasoning || !!p.reasoningStreaming) ); if (denseP) { return "mt-2"; } return "mt-5"; }