组件
AI 对话时间线 AI Conversation Timeline
用紧凑刻度浏览长对话与 Agent 执行轨迹,悬停即时预览并快速定位到指定轮次。
基础用法
在较长的对话页面或 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"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 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| items | readonly AiConversationTimelineItem[] | — | 时间线轮次条目数组,包含 id、title、description、meta 与 level 等字段。 |
| activeId | string | — | 受控模式下当前激活选中的条目 id。 |
| defaultActiveId | string | — | 非受控模式下默认激活选中的条目 id,缺省为首项条目 id。 |
| previewSide | "left" | "right" | "right" | 悬停预览摘要卡片展开的方向侧,时间线靠右侧放置时应设为 left。 |
| preview | boolean | true | 是否在鼠标悬停或按键聚焦时展示浮动摘要卡片。 |
| onActiveChange | (id: string) => void | — | 当前激活轮次发生变化时的回调函数。 |
| onNavigate | (item: AiConversationTimelineItem, index: number) => void | — | 用户点击或键盘选择某个刻度轮次时的导航回调函数。 |
| className | string | — | 应用于时间线导航容器的额外 CSS 类名。 |
AiConversationTimelineItem 数据结构
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| id | string | — | 条目的唯一稳定标识符。 |
| title | string | — | 主要标题文案,在预览卡片中作为核心内容展示。 |
| description | string | — | 可选的上下文补充描述或助手回答摘要。 |
| meta | string | — | 可选的紧凑元数据信息,如发生时间(09:42)或模型名称(GPT-4o)。 |
| level | 1 | 2 | 3 | 1 | 刻度的视觉层级深度。主任务设为 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设置。开启后,刻度直接跳变至目标尺寸,浮动预览卡片立即显隐,保障无障碍体验。