AI 对话时间线 AI Conversation Timeline
用紧凑刻度浏览长对话,悬停预览并快速定位到指定轮次。
基础示例
pnpm dlx wui@latest add @wui/ai-conversation-timeline"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。页面只有少量消息、不需要跨段定位时,不应额外加入时间线。
组件属性
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | readonly AiConversationTimelineItem[] | — | Ordered conversation turns. |
| activeId | string | — | Controlled active item. |
| defaultActiveId | string | — | Initial active item in uncontrolled mode. |
| previewSide | "left" | "right" | right | Side on which the hover preview appears. |
| preview | boolean | true | Render 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. |
事件
使用 activeId 与 onActiveChange 同步当前阅读轮次。用户点击刻度时,onNavigate 会返回完整条目和序号,业务层可以调用 scrollIntoView、虚拟列表定位或路由跳转。
组件不会接管滚动容器,也不假设消息 DOM 结构。这样既可以接入 AiChatMessages,也可以用于已有聊天页面或 Agent 运行记录。
动效与无障碍
放大量由指针在刻度列内的纵向位置连续计算:以指针为中心做高斯衰减(半径约 10px,略大于 9px 的刻度间距),所以峰值恒定落在指针所在的那一条,左右各约两条依次递减。长度与不透明度各走一条弹簧,全程走 motion value,不触发 React 重渲染。
坐标取自刻度列(
<ol>)的 border box,与刻度中心PAD + i × ROW + ROW / 2同源。若改用offsetTop测量会漏掉容器的padding(offsetTop相对的是 offsetParent 的 padding 边),峰值会整体偏移一行。摘要卡片只有一张:在刻度之间滑动到目标位置,内容做 120ms 交叉淡入,靠近顶部/底部时自动收边不出容器。
当前轮次不用高亮色,只用略高的不透明度暗示,保持“静止即安静”。系统开启“减少动态效果”后弹簧全部退化为直接切换。每个刻度都是可聚焦按钮,键盘聚焦同样触发放大与摘要,当前项通过 aria-current="step" 暴露给辅助技术。