"use client" /** * Shared Leo conversation transcript — Message / Bubble / Marker / MessageScroller. * Used by the Ask Leo rail, floating window, and focused Leo landing canvas. * * Scroll behaviors (shadcn MessageScroller): turn anchoring, live-edge autoScroll, * last-anchor open, circular jump buttons, and a vertical transcript outline * driven by useMessageScrollerVisibility + Marker jump rows. */ import * as React from "react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Bubble, BubbleContent } from "@/components/ui/bubble" import { HoverCard, HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card" import { LeoIcon } from "@/components/ui/leo-icon" import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker" import { Message, MessageAvatar, MessageContent, } from "@/components/ui/message" import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller, useMessageScrollerVisibility, } from "@/components/ui/message-scroller" import type { LeoThreadMessage } from "@/lib/use-leo-thread" import { NAV_USER } from "@/lib/mock/navigation" import { cn } from "@/lib/utils" export interface LeoThreadMessagesProps { messages: LeoThreadMessage[] isThinking?: boolean /** Center content when the thread is empty (hero, suggestion chips). */ emptyState?: React.ReactNode className?: string contentClassName?: string /** Wider max width for the landing canvas. */ maxWidthClassName?: string ariaLabel?: string /** * Vertical tick rail + Marker outline for jumping between user turns. * Defaults on when the thread has at least two user anchors. */ transcriptOutline?: boolean } function LeoThinkingMarker() { return ( {/* The star's own `working` gesture instead of a generic spinner. A spinner says "a process is running"; the turning, pulsing star says *Leo* is the one working, and it is the same motion the launcher and the toggle show while busy — one vocabulary for one state. `size-5` because the star is 20px at its smallest and the slot defaults to 16px. */} Leo is thinking… ) } function LeoThreadTurn({ message }: { message: LeoThreadMessage }) { if (message.role === "user") { return ( {NAV_USER.name.slice(0, 2).toUpperCase()} {message.content} ) } // No avatar on Leo's side. There is only ever one assistant in the thread and // its turns are already unmistakable — plain text, left-aligned, against the // user's filled right-aligned bubbles. The 32px avatar column bought nothing // and cost every answer a third of a line of width, which in a 420px window is // the difference between a table that fits and one that wraps. return ( {message.pending ? ( ) : ( {message.content} )} ) } function trimmedAnchorLabel(content: string) { return content.length > 42 ? `${content.slice(0, 39)}…` : content } /** * Vertical position rail + Marker jump menu (shadcn Transcript Outline). * Must render inside MessageScrollerProvider. */ function LeoTranscriptOutline({ anchors, }: { anchors: LeoThreadMessage[] }) { const { scrollToMessage } = useMessageScroller() const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility() if (anchors.length === 0) return null const visibleIds = new Set(visibleMessageIds) return ( {anchors.map((message) => { const current = message.id === currentAnchorId return (