wui
组件

AI 对话时间线 AI Conversation Timeline

用紧凑刻度浏览长对话,悬停预览并快速定位到指定轮次。

基础示例

Loading…
pnpm dlx wui@latest add @wui/ai-conversation-timeline
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. Kept just above ROW so
 * the tick under the pointer clearly peaks and only ~2 neighbours follow it.
 */
const SPREAD = 10
/** 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}
            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-xl border border-border/60 bg-popover/85 p-3 text-popover-foreground shadow-[0_8px_28px_-14px_rgb(0_0_0/0.5)] backdrop-blur-md supports-[backdrop-filter]:bg-popover/70"
          >
            <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-[5px] 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,
  reduceMotion,
  onSelect,
  onPreview,
  onFocusCenter,
}: {
  item: AiConversationTimelineItem
  index: number
  active: boolean
  anchor: "left" | "right"
  pointer: MotionValue<number>
  reduceMotion: boolean
  onSelect: () => void
  onPreview: (previewing: boolean) => void
  onFocusCenter: (center: number) => void
}) {
  const { rest, peak } = LENGTHS[item.level ?? 1]
  // The strip owns its own pitch, so the center is exact — and it is measured
  // from the same box (the list border box) the pointer offset comes from.
  const center = PAD + index * ROW + ROW / 2

  // Gaussian falloff: the tick under the pointer peaks, neighbours trail off.
  const proximity = useTransform(pointer, (y) =>
    y < 0 ? 0 : Math.exp(-((y - center) ** 2) / (2 * SPREAD ** 2))
  )
  const widthTarget = useTransform(proximity, (p) => rest + (peak - rest) * p)
  const opacityTarget = useTransform(proximity, (p) =>
    Math.min(1, (active ? 0.5 : 0.22) + p * 0.78)
  )
  const springWidth = useSpring(widthTarget, spring)
  const springOpacity = useSpring(opacityTarget, spring)

  return (
    <li className="flex w-full" style={{ height: ROW }}>
      <button
        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(center)
        }}
        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 }

组件作用

AiConversationTimeline 用于内容较长的 AI 对话、Agent 执行记录和连续工作流。静止时全部轮次聚拢成一列等距、同色的灰刻度,只用 level 区分层级长度,不抢内容注意力;指针靠近时,光标所在刻度与相邻刻度按高斯衰减依次变长、变清晰(类似 Dock 放大),并在旁边浮出该轮对话摘要卡片。

它是导航控件,不是用于展示审计日志的普通 Timeline。页面只有少量消息、不需要跨段定位时,不应额外加入时间线。

组件属性

PropTypeDefaultDescription
items *readonly AiConversationTimelineItem[]Ordered conversation turns.
activeIdstringControlled active item.
defaultActiveIdstringInitial active item in uncontrolled mode.
previewSide"left" | "right"rightSide on which the hover preview appears.
previewbooleantrueRender the hover preview card.
onActiveChange((id: string) => void)Called when a marker becomes active.
onNavigate((item: AiConversationTimelineItem, index: number) => void)Called when the user selects a marker.

事件

使用 activeIdonActiveChange 同步当前阅读轮次。用户点击刻度时,onNavigate 会返回完整条目和序号,业务层可以调用 scrollIntoView、虚拟列表定位或路由跳转。

组件不会接管滚动容器,也不假设消息 DOM 结构。这样既可以接入 AiChatMessages,也可以用于已有聊天页面或 Agent 运行记录。

动效与无障碍

放大量由指针在刻度列内的纵向位置连续计算:以指针为中心做高斯衰减(半径约 10px,略大于 9px 的刻度间距),所以峰值恒定落在指针所在的那一条,左右各约两条依次递减。长度与不透明度各走一条弹簧,全程走 motion value,不触发 React 重渲染。

坐标取自刻度列(<ol>)的 border box,与刻度中心 PAD + i × ROW + ROW / 2 同源。若改用 offsetTop 测量会漏掉容器的 paddingoffsetTop 相对的是 offsetParent 的 padding 边),峰值会整体偏移一行。摘要卡片只有一张:在刻度之间滑动到目标位置,内容做 120ms 交叉淡入,靠近顶部/底部时自动收边不出容器。

当前轮次不用高亮色,只用略高的不透明度暗示,保持“静止即安静”。系统开启“减少动态效果”后弹簧全部退化为直接切换。每个刻度都是可聚焦按钮,键盘聚焦同样触发放大与摘要,当前项通过 aria-current="step" 暴露给辅助技术。