wui
组件

AI 对话时间线 AI Conversation Timeline

用紧凑刻度浏览长对话与 Agent 执行轨迹,悬停即时预览并快速定位到指定轮次。

第三方依赖 · motion

基础用法

在较长的对话页面或 Agent 执行记录中,使用时间线刻度压缩全部轮次。指针靠近时刻度连续放大,悬停展开详细摘要卡片,点击即可平滑跳转:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/ai-conversation-timeline
安装基础依赖与动效库
pnpm add motion lucide-react clsx tailwind-merge
复制组件源码到 components/ui/ai-conversation-timeline.tsx
components/ui/ai-conversation-timeline.tsx
"use client"

import * as React from "react"
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react"

import { cn } from "@/lib/utils"

export interface AiConversationTimelineItem {
  /** Stable item identifier. */
  id: string
  /** Primary text shown in the preview. */
  title: string
  /** Optional response or contextual summary. */
  description?: string
  /** Optional compact metadata, such as a time or model name. */
  meta?: string
  /** Visual depth of the tick; deeper levels render shorter. @default 1 */
  level?: 1 | 2 | 3
}

export interface AiConversationTimelineProps
  extends Omit<React.ComponentProps<"nav">, "children"> {
  /** Ordered conversation turns. */
  items: readonly AiConversationTimelineItem[]
  /** Controlled active item. */
  activeId?: string
  /** Initial active item in uncontrolled mode. */
  defaultActiveId?: string
  /** Side on which the hover preview appears. @default "right" */
  previewSide?: "left" | "right"
  /** Render the hover preview card. @default true */
  preview?: boolean
  /** Called when a marker becomes active. */
  onActiveChange?: (id: string) => void
  /** Called when the user selects a marker. */
  onNavigate?: (item: AiConversationTimelineItem, index: number) => void
}

/** Vertical pitch of one tick row, in pixels. */
const ROW = 9
/** Vertical padding inside the strip, in pixels. */
const PAD = 6
/**
 * Falloff radius of the pointer magnifier, in pixels. Wide enough for the
 * ticks immediately above and below the pointer to follow with a small lift.
 */
const SPREAD = 14
/** Resting and magnified tick lengths per level. */
const LENGTHS = {
  1: { rest: 12, peak: 46 },
  2: { rest: 9, peak: 38 },
  3: { rest: 6, peak: 30 },
} as const
/** Width of the strip; fits the longest magnified tick. */
const STRIP = 54

const spring = { stiffness: 700, damping: 34, mass: 0.35 } as const

const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect

/** A compact, hover-expandable navigator for long AI conversations. */
function AiConversationTimeline({
  className,
  style,
  items,
  activeId: controlledActiveId,
  defaultActiveId,
  previewSide = "right",
  preview = true,
  onActiveChange,
  onNavigate,
  "aria-label": ariaLabel = "Conversation timeline",
  ...props
}: AiConversationTimelineProps) {
  const reduceMotion = useReducedMotion()
  const [internalActiveId, setInternalActiveId] = React.useState(
    defaultActiveId ?? items[0]?.id
  )
  const [previewId, setPreviewId] = React.useState<string>()
  const [cardHeight, setCardHeight] = React.useState(0)
  const cardRef = React.useRef<HTMLDivElement>(null)
  const stripRef = React.useRef<HTMLOListElement>(null)
  const activeId = controlledActiveId ?? internalActiveId
  const previewIndex = items.findIndex((item) => item.id === previewId)
  const previewItem = previewIndex >= 0 ? items[previewIndex] : undefined

  // Keep the last previewed turn rendered so the card fades out with content.
  const shown = React.useRef({ item: previewItem, index: previewIndex })
  if (previewItem) shown.current = { item: previewItem, index: previewIndex }
  const cardItem = previewItem ?? shown.current.item
  const cardIndex = previewItem ? previewIndex : shown.current.index

  useIsomorphicLayoutEffect(() => {
    setCardHeight(cardRef.current?.offsetHeight ?? 0)
  }, [previewId, cardItem?.description])

  // Pointer offset from the top of the strip; -1 parks every tick at rest.
  const pointer = useMotionValue(-1)
  const stripHeight = items.length * ROW + PAD * 2

  function selectItem(item: AiConversationTimelineItem, index: number) {
    if (controlledActiveId === undefined) setInternalActiveId(item.id)
    onActiveChange?.(item.id)
    onNavigate?.(item, index)
  }

  const cardY = Math.min(
    Math.max(PAD + Math.max(cardIndex, 0) * ROW + ROW / 2, cardHeight / 2),
    Math.max(stripHeight - cardHeight / 2, cardHeight / 2)
  )

  return (
    <nav
      data-slot="ai-conversation-timeline"
      data-preview-side={previewSide}
      aria-label={ariaLabel}
      className={cn("relative isolate", className)}
      style={{ width: STRIP, ...style }}
      onPointerMove={(event) => {
        if (event.pointerType === "touch") return
        const strip = stripRef.current
        if (!strip) return
        // Measure against the list box, so outer padding never skews the peak.
        pointer.set(event.clientY - strip.getBoundingClientRect().top)
      }}
      onPointerLeave={() => {
        pointer.set(-1)
        setPreviewId(undefined)
      }}
      {...props}
    >
      <ol
        ref={stripRef}
        className={cn(
          "flex w-full flex-col",
          previewSide === "right" ? "items-end" : "items-start"
        )}
        style={{ paddingBlock: PAD }}
      >
        {items.map((item, index) => (
          <Tick
            key={item.id}
            item={item}
            index={index}
            active={item.id === activeId}
            anchor={previewSide}
            pointer={pointer}
            stripRef={stripRef}
            reduceMotion={!!reduceMotion}
            onSelect={() => selectItem(item, index)}
            onPreview={(next) => setPreviewId(next ? item.id : undefined)}
            onFocusCenter={(center) => pointer.set(center)}
          />
        ))}
      </ol>

      {preview ? (
        <motion.div
          aria-hidden
          data-slot="ai-conversation-timeline-preview"
          className={cn(
            "pointer-events-none absolute top-0 z-20 w-64",
            previewSide === "right" ? "left-full ml-2" : "right-full mr-2"
          )}
          initial={false}
          animate={{
            y: cardY,
            opacity: previewItem ? 1 : 0,
            x: previewItem ? 0 : previewSide === "right" ? -6 : 6,
          }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : {
                  y: { type: "spring", ...spring },
                  default: { duration: 0.18, ease: [0.22, 1, 0.36, 1] },
                }
          }
          style={{ visibility: previewItem ? "visible" : "hidden" }}
        >
          <div
            ref={cardRef}
            className="relative -translate-y-1/2 rounded-lg border bg-popover p-3 text-popover-foreground shadow-md"
          >
            <span
              className={cn(
                "absolute top-1/2 h-px w-2 bg-border",
                previewSide === "right" ? "right-full" : "left-full"
              )}
            />
            <AnimatePresence initial={false} mode="wait">
              <motion.div
                key={cardItem?.id ?? "empty"}
                initial={reduceMotion ? false : { opacity: 0, y: 4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
                transition={reduceMotion ? { duration: 0 } : { duration: 0.12 }}
              >
                <div className="flex items-center gap-2">
                  <span className="rounded-sm bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-4 tabular-nums text-muted-foreground">
                    {String(Math.max(cardIndex, 0) + 1).padStart(2, "0")}
                  </span>
                  <span className="text-[11px] tabular-nums text-muted-foreground/70">
                    / {items.length}
                  </span>
                  {cardItem?.meta ? (
                    <span className="ml-auto text-[11px] tabular-nums text-muted-foreground">
                      {cardItem.meta}
                    </span>
                  ) : null}
                </div>
                <p className="mt-2 line-clamp-2 text-[13px] font-medium leading-5">
                  {cardItem?.title}
                </p>
                {cardItem?.description ? (
                  <p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
                    {cardItem.description}
                  </p>
                ) : null}
              </motion.div>
            </AnimatePresence>
          </div>
        </motion.div>
      ) : null}
    </nav>
  )
}

function Tick({
  item,
  index,
  active,
  anchor,
  pointer,
  stripRef,
  reduceMotion,
  onSelect,
  onPreview,
  onFocusCenter,
}: {
  item: AiConversationTimelineItem
  index: number
  active: boolean
  anchor: "left" | "right"
  pointer: MotionValue<number>
  stripRef: React.RefObject<HTMLOListElement | null>
  reduceMotion: boolean
  onSelect: () => void
  onPreview: (previewing: boolean) => void
  onFocusCenter: (center: number) => void
}) {
  const { rest, peak } = LENGTHS[item.level ?? 1]
  // Start from the intended pitch, then replace it with the rendered center.
  // This keeps pointer and tick positions in the same coordinate system even
  // when surrounding styles alter the actual row layout.
  const tickRef = React.useRef<HTMLButtonElement>(null)
  const center = useMotionValue(PAD + index * ROW + ROW / 2)

  const measureCenter = React.useCallback(() => {
    const strip = stripRef.current
    const tick = tickRef.current
    if (!strip || !tick) return center.get()

    const stripRect = strip.getBoundingClientRect()
    const tickRect = tick.getBoundingClientRect()
    const nextCenter = tickRect.top - stripRect.top + tickRect.height / 2
    center.set(nextCenter)
    return nextCenter
  }, [center, stripRef])

  useIsomorphicLayoutEffect(() => {
    measureCenter()

    const strip = stripRef.current
    const tick = tickRef.current
    if (!strip || !tick || typeof ResizeObserver === "undefined") return

    const observer = new ResizeObserver(measureCenter)
    observer.observe(strip)
    observer.observe(tick)
    return () => observer.disconnect()
  }, [measureCenter, stripRef])

  // Gaussian falloff: the tick under the pointer peaks, neighbours trail off.
  const proximity = useTransform(() => {
    const y = pointer.get()
    const tickCenter = center.get()
    return y < 0 ? 0 : Math.exp(-((y - tickCenter) ** 2) / (2 * SPREAD ** 2))
  })
  // The active turn rests a little longer and fully opaque, so moving the
  // reading position springs one tick out while the previous one settles back.
  const base = active ? rest + 6 : rest
  const widthTarget = useTransform(proximity, (p) => base + (peak - base) * p)
  const opacityTarget = useTransform(proximity, (p) =>
    Math.min(1, (active ? 1 : 0.24) + p * 0.76)
  )
  const springWidth = useSpring(widthTarget, spring)
  const springOpacity = useSpring(opacityTarget, spring)

  return (
    <li className="flex w-full" style={{ height: ROW }}>
      <button
        ref={tickRef}
        type="button"
        aria-label={item.title}
        aria-current={active ? "step" : undefined}
        data-slot="ai-conversation-timeline-tick"
        data-active={active || undefined}
        className={cn(
          "group flex w-full items-center outline-none",
          anchor === "right" ? "justify-end" : "justify-start"
        )}
        style={{ height: ROW }}
        onClick={onSelect}
        onPointerEnter={() => onPreview(true)}
        onFocus={() => {
          onPreview(true)
          onFocusCenter(measureCenter())
        }}
        onBlur={() => onPreview(false)}
      >
        <motion.span
          aria-hidden
          className={cn(
            "block h-0.5 rounded-full bg-foreground group-focus-visible:bg-primary group-focus-visible:opacity-100",
            anchor === "right" ? "origin-right" : "origin-left"
          )}
          style={{
            width: reduceMotion ? widthTarget : springWidth,
            opacity: reduceMotion ? opacityTarget : springOpacity,
          }}
        />
      </button>
    </li>
  )
}

export { AiConversationTimeline }

属性 Props

AiConversationTimeline 支持以下配置属性,并继承原生 <nav> 元素的 HTML 属性:

属性类型默认值说明
itemsreadonly AiConversationTimelineItem[]—时间线轮次条目数组,包含 id、title、description、meta 与 level 等字段。
activeIdstring—受控模式下当前激活选中的条目 id。
defaultActiveIdstring—非受控模式下默认激活选中的条目 id,缺省为首项条目 id。
previewSide"left" | "right""right"悬停预览摘要卡片展开的方向侧,时间线靠右侧放置时应设为 left。
previewbooleantrue是否在鼠标悬停或按键聚焦时展示浮动摘要卡片。
onActiveChange(id: string) => void—当前激活轮次发生变化时的回调函数。
onNavigate(item: AiConversationTimelineItem, index: number) => void—用户点击或键盘选择某个刻度轮次时的导航回调函数。
classNamestring—应用于时间线导航容器的额外 CSS 类名。

AiConversationTimelineItem 数据结构

属性类型默认值说明
idstring—条目的唯一稳定标识符。
titlestring—主要标题文案,在预览卡片中作为核心内容展示。
descriptionstring—可选的上下文补充描述或助手回答摘要。
metastring—可选的紧凑元数据信息,如发生时间(09:42)或模型名称(GPT-4o)。
level1 | 2 | 31刻度的视觉层级深度。主任务设为 1,子调用或工具执行设为 2 或 3,刻度长度依次递减。

事件 Events

属性类型默认值说明
onActiveChange(id: string) => void—当选中的轮次项发生改变时触发,返回当前激活的 item.id。
onNavigate(item: AiConversationTimelineItem, index: number) => void—用户点击刻度或按回车确认选择时触发,提供完整的条目对象和索引序号,便于调用 scrollIntoView。
onPointerMove(event: React.PointerEvent<HTMLElement>) => void—指针在时间线刻度列滑动时触发,内部通过 MotionValue 计算高斯邻近放大系数。
onPointerLeave() => void—指针离开时间线区域时触发,刻度平滑回缩至静止静默状态。

使用场景与设计规范

AiConversationTimeline 专为长篇幅 AI 会话、多步 Agent 工作流与复杂链路追踪设计。

  • 静止时克制,交互时细腻:静止状态下所有轮次收敛为等距的灰色细短线,绝不喧宾夺主抢占正文注意力;鼠标靠近时,通过高斯衰减算法(Gaussian falloff)平滑放大邻近刻度,提供流畅的鱼眼放大镜探寻体验。
  • 导航组件而非列表呈现:本组件是视口定位的快捷导航控件,不是用于完整罗列事件历史的审计日志 Timeline。当对话仅有 2~3 轮或屏幕高度足够展示全部内容时,无需使用。
  • 层级与结构映射:善用 level 属性表达调用链嵌套关系(如 Level 1 表示用户提问/主意图,Level 2 表示工具调用/思维推演,Level 3 表示底层细粒度日志),帮助用户一目了然把握对话脉络。

场景示例

右侧停靠与向左展开预览

当时间线布局在屏幕或卡片右侧边缘时,设置 previewSide="left",预览卡片将向左侧空白区域弹出,避免遮挡屏幕右边缘:

Loading…

多层级 Agent 执行链路追踪

通过为关键节点设置 level={1},中间推理与工具调用设置 level={2} 或 level={3},直观呈现复杂的任务拆解与推演过程:

Loading…

无障碍与交互 Accessibility

  • 语义结构:外层使用 <nav aria-label="..."> 语义标签,内层使用 <ol> 有序列表组织刻度。
  • 键盘导航:
    • Tab / Shift + Tab:在各个轮次刻度按钮之间依次切换焦点。
    • 聚焦至刻度时自动触发该项的放大与摘要卡片展开,完全无需依赖鼠标。
    • Enter / Space:触发 onNavigate 回调执行跳转。
  • ARIA 状态:当前激活项会自动挂载 aria-current="step",向辅助技术精准表达当前阅读进度。
  • 动效降级:内置弹簧物理动画自动监听 prefers-reduced-motion 设置。开启后,刻度直接跳变至目标尺寸,浮动预览卡片立即显隐,保障无障碍体验。