"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 (
)
})}
)
}
export function LeoThreadMessages({
messages,
isThinking: _isThinking,
emptyState,
className,
contentClassName,
maxWidthClassName = "max-w-none",
ariaLabel = "Conversation with Leo",
transcriptOutline,
}: LeoThreadMessagesProps) {
const isEmpty = messages.length === 0
const userAnchors = React.useMemo(
() => messages.filter((m) => m.role === "user"),
[messages],
)
const showOutline =
transcriptOutline ?? (!isEmpty && userAnchors.length >= 2)
return (