import * as React from "react"; import { motion, useReducedMotion } from "motion/react"; import { CheckCheck, Clock, Radio } from "lucide-react"; import { useActiveAgentTurns, type ActiveTurnSummary, } from "@/features/agents/activeAgentTurnsStore"; import { subscribeAgentObserverStore, getLatestLiveSessionId, } from "@/features/agents/observerRelayStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useAnchoredScroll } from "@/features/messages/ui/useAnchoredScroll"; import { cn } from "@/shared/lib/cn"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; import { Toggle } from "@/shared/ui/toggle"; import { AnimatedCount } from "@/shared/ui/AnimatedCount"; import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; import { TurnLivenessIndicator } from "./TurnLivenessIndicator"; import { PromptSectionList as PromptContextSections } from "./PromptSectionAccordion"; import { AgentSessionTranscriptVariantProvider, type AgentSessionTranscriptVariant, useAgentSessionTranscriptVariant, } from "./agentSessionTranscriptContext"; import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference"; import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference"; import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; import { ActivityRow, ActivityRowContent, ActivityRowLabel, type ActivityRowStats, splitActivityRowCountedObject, splitActivityRowLabel, } from "./activityRenderClasses/ActivityRow"; import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp"; import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types"; import type { FileEditDiff } from "./agentSessionFileEditDiff"; import { buildTranscriptDisplayBlocks, formatTurnSetupLabel, turnSetupDetail, turnSetupTimestamp, type TranscriptDisplayBlock, type TranscriptTurnSegment, } from "./agentSessionTranscriptGrouping"; import { buildCompactToolSummary } from "./agentSessionToolSummary"; import { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation"; import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; import { hasFileEditLineDiff } from "./FileEditDiffView"; import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble"; const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source"; const ROW_ENTER_SPRING = { damping: 38, stiffness: 480, type: "spring", } as const; const ROW_ENTER_FROM = { opacity: 0, y: 12 } as const; const ROW_ENTER_TO = { opacity: 1, y: 0 } as const; /** * False during the mount commit, true afterwards. Children mounted with the * initial batch (history load) read false and skip their enter animation; * children appended later read true and animate in. */ function useHasCompletedInitialRender() { const ref = React.useRef(false); React.useEffect(() => { ref.current = true; }, []); return ref; } /** * Opt-in only: source pills are useful while iterating on observer parsing, but * they should not appear for every local dev session. */ const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource(); export type AgentSessionTranscriptEmptyState = "idle" | "loading"; function shouldShowTranscriptAcpSource() { const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE; if (envValue === "1" || envValue === "true") { return true; } if (typeof window === "undefined") { return false; } try { return ( window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1" ); } catch { return false; } } export function AgentSessionTranscriptList({ agentAvatarUrl, agentName, agentPubkey, autoTail = false, channelId = null, emptyDescription, emptyState = "idle", items, profiles, contentContainerClassName, scrollScopeKey, variant = "default", }: AgentTranscriptIdentityProps & { autoTail?: boolean; channelId?: string | null; emptyDescription: string; emptyState?: AgentSessionTranscriptEmptyState; items: TranscriptItem[]; profiles?: UserProfileLookup; contentContainerClassName?: string; scrollScopeKey?: string | null; variant?: AgentSessionTranscriptVariant; }) { const activeTurns = useActiveAgentTurns(agentPubkey); const isTurnLive = React.useMemo( () => isAgentTurnLive(activeTurns, channelId), [activeTurns, channelId], ); // Subscribe to the observer relay store so we read the latest-live-session-id // reactively. We don't need the full snapshot — only the key for boundary labeling. const getLatestLive = React.useCallback( () => getLatestLiveSessionId(agentPubkey, channelId), [agentPubkey, channelId], ); const latestLiveSessionId = React.useSyncExternalStore( subscribeAgentObserverStore, getLatestLive, ); const displayBlocks = React.useMemo( () => buildTranscriptDisplayBlocks(items, latestLiveSessionId), [items, latestLiveSessionId], ); const scrollContainerRef = React.useRef(null); const contentRef = React.useRef(null); const anchoredScroll = useAnchoredScroll({ channelId: autoTail ? (scrollScopeKey ?? agentPubkey) : null, contentRef, isLoading: false, messages: items, scrollContainerRef, }); const isCompactPreview = variant === "compactPreview"; const animationPreferenceEnabled = useTranscriptAnimationEnabled(); const shouldReduceMotion = useReducedMotion(); const animationsDisabled = Boolean(shouldReduceMotion) || !animationPreferenceEnabled; // Position (layout) animations are only safe when this component owns the // scroll container: `layoutScroll` below tells motion to subtract our scroll // offset when measuring rows. When an ancestor scrolls instead (autoTail // off), scrolling would register as false position deltas and rows would // visibly spring back toward their pre-scroll position, so only the enter // animation runs there. const layoutAnimationsEnabled = !animationsDisabled && autoTail; const hasCompletedInitialRenderRef = useHasCompletedInitialRender(); const hasRenderableContent = items.length > 0 && hasRenderableDisplayContent(displayBlocks, variant); const scrollContainerClassNames = cn( "w-full", autoTail ? "h-full overflow-y-auto" : null, ); if (!hasRenderableContent) { const isLoading = emptyState === "loading" || isTurnLive; return (
{isLoading ? ( ) : ( <>

No ACP activity yet

{emptyDescription}

)}
); } return (
{displayBlocks.map((block) => { const blockKey = getDisplayBlockKey(block); return ( {/* content-visibility stays on a non-animated child: motion measures the outer wrapper for layout animations, which would otherwise force skipped offscreen rows to render. */}
); })} {isTurnLive && !isCompactPreview ? : null}
); } function isAgentTurnLive( activeTurns: ActiveTurnSummary[], channelId: string | null, ) { if (activeTurns.length === 0) { return false; } if (!channelId) { return true; } return activeTurns.some((turn) => turn.channelId === channelId); } function hasRenderableDisplayContent( displayBlocks: TranscriptDisplayBlock[], variant: AgentSessionTranscriptVariant, ) { if (variant !== "compactPreview") { return displayBlocks.length > 0; } return displayBlocks.some(hasRenderableCompactBlock); } function hasRenderableCompactBlock(block: TranscriptDisplayBlock) { if (block.kind === "single") { return isRenderableCompactItem(block.item); } // session-boundary dividers are not renderable content in compact view. if (block.kind === "session-boundary") { return false; } return block.segments.some((segment) => { if (segment.kind === "item") { return isRenderableCompactItem(segment.item); } if (segment.kind === "prompt") { return true; } if (segment.kind === "summary") { return segment.summary.items.some(isRenderableCompactItem); } return false; }); } function isRenderableCompactItem(item: TranscriptItem) { return item.renderClass !== "raw-rail" && item.renderClass !== "suppressed"; } function TranscriptAcpSourceBadge({ source }: { source: string }) { return ( {source} ); } function getDisplayBlockKey(block: TranscriptDisplayBlock) { if (block.kind === "single") { return block.item.id; } if (block.kind === "session-boundary") { return `session-boundary:${block.sessionId}`; } return `turn:${block.turnId}`; } function TranscriptDisplayBlockView({ agentAvatarUrl, agentName, agentPubkey, block, profiles, }: AgentTranscriptIdentityProps & { block: TranscriptDisplayBlock; profiles?: UserProfileLookup; }) { const variant = useAgentSessionTranscriptVariant(); const isCompactPreview = variant === "compactPreview"; const animationPreferenceEnabled = useTranscriptAnimationEnabled(); const shouldReduceMotion = useReducedMotion(); // Streaming tool calls land as new segments inside the current turn block // (the block key stays `turn:`), so the list-level enter animation // never fires for them — each segment animates in here instead. Segments // present when the block mounts (history load, or the first paint of a new // turn — the block wrapper already animates that) skip the transition. const hasCompletedInitialRenderRef = useHasCompletedInitialRender(); const animateSegmentEnter = animationPreferenceEnabled && !shouldReduceMotion; if (block.kind === "session-boundary") { return ( ); } if (block.kind === "single") { return ( ); } return (
{block.segments.map((segment) => ( ))}
); } function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { if (segment.kind === "setup") { return `turn:${turnId}:setup`; } if (segment.kind === "prompt") { // A turn can hold multiple prompt segments (initial prompt + mid-turn // steers), so key on the user message id rather than the bare turn id. return `turn:${turnId}:prompt:${segment.user.id}`; } if (segment.kind === "summary") { return segment.summary.id; } return segment.item.id; } function TranscriptTurnSegmentView({ agentAvatarUrl, agentName, agentPubkey, profiles, segment, }: AgentTranscriptIdentityProps & { profiles?: UserProfileLookup; segment: TranscriptTurnSegment; }) { if (segment.kind === "prompt") { return ( ); } if (segment.kind === "setup") { return ; } if (segment.kind === "summary") { return ( ); } return ( ); } function SameKindSummaryItem({ agentAvatarUrl, agentName, agentPubkey, profiles, summary, }: AgentTranscriptIdentityProps & { profiles?: UserProfileLookup; summary: Extract["summary"]; }) { const groupedFileEditDiffs = React.useMemo( () => summary.renderClass === "file-edit" || summary.variant === "mixed" ? getGroupedFileEditDiffs(summary.items) : [], [summary.items, summary.renderClass, summary.variant], ); const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs); const expandsToToolItems = summary.items.every( (item) => item.type === "tool", ); const variant = useAgentSessionTranscriptVariant(); const timestampsEnabled = useTranscriptTimestampsEnabled(); const showTimestamp = timestampsEnabled && variant !== "compactPreview"; // Mixed bursts expand to their child segments in original order: raw tool // rows plus nested same-kind summaries that joined the burst (which stay // expandable to their own child rows). const childSegments = summary.segments ?? null; return ( <> {childSegments ? childSegments.map((child) => child.kind === "summary" ? ( ) : ( ), ) : expandsToToolItems ? summary.items.map((item) => ( )) : summary.items.map((item) => (

{item.type === "tool" ? item.descriptor.preview || item.descriptor.label : item.title}

))}
{showTimestamp ? ( ) : null} ); } function getGroupedFileEditDiffs(items: TranscriptItem[]): FileEditDiff[] { return items.flatMap((item) => { if (item.type !== "tool" || item.isError) { return []; } const diff = buildCompactToolSummary(item).fileEditDiff; return diff && hasFileEditLineDiff(diff) ? [diff] : []; }); } function summarizeFileEditDiffs( diffs: FileEditDiff[], ): ActivityRowStats | null { if (diffs.length === 0) { return null; } return diffs.reduce( (stats, diff) => ({ additions: stats.additions + diff.additions, deletions: stats.deletions + diff.deletions, }), { additions: 0, deletions: 0 }, ); } function ToolRunSummaryLabel({ label, stats, }: { label: string; stats?: ActivityRowStats | null; }) { const animationPreferenceEnabled = useTranscriptAnimationEnabled(); const parts = splitActivityRowLabel(label); if (!parts) { return {label}; } // Streaming bursts grow their count in place ("Ran 16 tool calls" → // "Ran 17 tool calls"); rolling the digits odometer-style makes the // increment legible. AnimatedCount keeps an sr-only static value and // falls back to static text under prefers-reduced-motion. const countedObject = animationPreferenceEnabled && typeof parts.object === "string" ? splitActivityRowCountedObject(parts.object) : null; const object = countedObject ? ( <> {countedObject.rest} ) : ( parts.object ); return ( ); } function TurnPromptBlock({ context, profiles, setup, systemPrompt, user, }: { context: Extract | null; profiles?: UserProfileLookup; setup: Extract[]; systemPrompt: Extract | null; user: Extract; }) { return (
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
{context ? ( ) : null}
) : null}
); } function PromptUserMessage({ context = null, item, profiles, setup = [], systemPrompt = null, }: { context?: Extract | null; item: Extract; profiles?: UserProfileLookup; setup?: Extract[]; systemPrompt?: Extract | null; }) { const [contextOpen, setContextOpen] = React.useState(false); const contextSections = React.useMemo( () => [...(systemPrompt?.sections ?? []), ...(context?.sections ?? [])], [context, systemPrompt], ); return ( <> 0} items={setup} messageLink={getTranscriptMessageLink(item)} onContextOpenChange={setContextOpen} timestamp={item.timestamp} /> } item={item} profiles={profiles} /> ); } function PromptContextDialog({ onOpenChange, open, sections, setup, }: { onOpenChange: (open: boolean) => void; open: boolean; sections: PromptSection[]; setup: Extract[]; }) { if (!open || sections.length === 0) { return null; } const setupText = formatPromptSetupSummary(setup); return (
Prompt context {setupText ? (
{setupText}
) : null}
); } function formatPromptSetupSummary( items: Extract[], ) { const label = formatTurnSetupLabel(items); const detail = turnSetupDetail(items); return [label, detail].filter(Boolean).join(" · "); } function TurnSetupFooter({ contextOpen = false, hasContext = false, items, messageLink = null, onContextOpenChange, showTimestamp = true, timestamp, }: { contextOpen?: boolean; hasContext?: boolean; items: Extract[]; messageLink?: { channelId: string; messageId: string } | null; onContextOpenChange?: (open: boolean) => void; showTimestamp?: boolean; timestamp: string; }) { const label = formatTurnSetupLabel(items); const detail = turnSetupDetail(items); const tooltipText = [label, detail].filter(Boolean).join(" · "); const showSetup = items.length > 0; const showContext = hasContext && onContextOpenChange != null; if (!showSetup && !showContext) { return showTimestamp ? ( ) : null; } return (
{showContext ? ( ) : ( {tooltipText} )} {showTimestamp ? ( ) : null}
); } function getTranscriptMessageLink( item: Extract, ) { if (!item.channelId || !item.messageId) return null; return { channelId: item.channelId, messageId: item.messageId, }; } function TranscriptItemRow({ agentAvatarUrl, agentName, agentPubkey, item, profiles, }: AgentTranscriptIdentityProps & { item: TranscriptItem; profiles?: UserProfileLookup; }) { const variant = useAgentSessionTranscriptVariant(); const timestampsEnabled = useTranscriptTimestampsEnabled(); const showTimestamp = shouldShowTranscriptRowTimestamp(item, { enabled: timestampsEnabled, variant, }); return (
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( ) : null} {showTimestamp ? ( ) : null}
); } /** * Opt-in per-row timestamp, anchored bottom-left under the row content and * styled to match the chat/transcript timestamps. */ function TranscriptRowTimestamp({ messageLink = null, timestamp, }: { messageLink?: { channelId: string; messageId: string } | null; timestamp: string; }) { return (
); } function TurnSetupStatus({ items, }: { items: Extract[]; }) { const timestamp = turnSetupTimestamp(items); if (items.length === 0 || !timestamp) { return null; } return (
); } /** * Horizontal rule rendered between session runs in the observer transcript. * * Three label states: * - `"current"` — live session, agent is actively running in this context. * - `"most-recent"` — newest visible session but no live match (archived-only * or session ended). Shown as "Most recent observed session" * so users know it is history, not active context. * - `"earlier"` — an older session. Always labeled with the archive signal. */ function SessionBoundaryDivider({ labelState, sessionStartTimestamp, }: { labelState: "current" | "most-recent" | "earlier"; sessionStartTimestamp: string; }) { const label = labelState === "current" ? "Current session" : labelState === "most-recent" ? "Most recent observed session — not in current context" : "Earlier session — not in current context"; const formattedDate = new Date(sessionStartTimestamp).toLocaleString(); return (
{labelState === "current" ? (
); } const TranscriptItemView = React.memo(function TranscriptItemView({ agentAvatarUrl, agentName, agentPubkey, item, profiles, }: AgentTranscriptIdentityProps & { item: TranscriptItem; profiles?: UserProfileLookup; }) { return ( ); });