{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"agent-playback","type":"registry:component","title":"Agent playback","description":"Your AI product working, hands-off and on a loop: the hero demo built from the real components.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/prompt-composer.json","https://hairlineui.com/r/thinking-trace.json","https://hairlineui.com/r/web-research.json","https://hairlineui.com/r/tool-call.json","https://hairlineui.com/r/streaming-answer.json"],"files":[{"path":"registry/agent-playback.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { Pause, Play } from \"lucide-react\";\nimport { PromptComposer, type ComposerStatus } from \"./prompt-composer\";\nimport { AnswerActions, StreamingText, type AnswerSegment, type AnswerSource } from \"./streaming-answer\";\nimport { ThinkingTrace } from \"./thinking-trace\";\nimport { ToolCall, type ToolCallStatus } from \"./tool-call\";\nimport { WebResearch, type ResearchSource, type ResearchStatus } from \"./web-research\";\n\n/* ─────────────────────────────────────────────────────────\n * AGENT PLAYBACK: your product, working, on a loop\n *\n *   typing     the prompt types itself into the composer, with\n *              a person's uneven rhythm\n *   sent       the text lifts out of the composer and glides\n *              into the message bubble; Send becomes a spinner\n *   working    the agent's blocks arrive one by one, each opening\n *              its own room so what's above glides up: a thinking\n *              trace ticking off steps, web research stacking\n *              sources, a tool call filling in its arguments\n *   answering  the answer streams in word by word, citations and\n *              all, then its actions settle in underneath\n *   next       a beat to read it, then the conversation dissolves\n *              and the next prompt starts typing\n *\n * Built from the real components (composer, thinking trace, web\n * research, tool call, streaming answer), so the hero shows\n * exactly what the product does. It pauses off-screen and in a\n * hidden tab, has its own pause button, and with reduced motion\n * shows the finished first conversation, still.\n * ───────────────────────────────────────────────────────── */\n\nexport type PlaybackStep =\n  | { type: \"thinking\"; steps: string[]; label?: string; variant?: \"steps\" | \"reasoning\" }\n  | { type: \"search\"; queries: { query: string; sources: ResearchSource[] }[] }\n  | {\n      type: \"tool\";\n      name: string;\n      title?: string;\n      icon?: React.ReactNode;\n      /** Fills in field by field, as a model writes the arguments. */\n      input: Record<string, unknown>;\n      output?: unknown;\n      renderOutput?: (output: unknown) => React.ReactNode;\n      /** How long it runs (ms); shown as its run time. */\n      duration?: number;\n      /** Show the arguments and result. Default true. */\n      open?: boolean;\n    }\n  | { type: \"answer\"; content: AnswerSegment[] | string; sources?: AnswerSource[] };\n\nexport interface PlaybackTurn {\n  prompt: string;\n  steps: PlaybackStep[];\n}\n\nexport interface AgentPlaybackProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The conversations to play, in order. */\n  turns: PlaybackTurn[];\n  /** Pace: 2 plays twice as fast. */\n  speed?: number;\n  /** How long a finished answer stays before the next prompt (ms). */\n  hold?: number;\n  /** Start again after the last turn. */\n  loop?: boolean;\n  /** Pause from outside, e.g. while a dialog is open. */\n  paused?: boolean;\n  placeholder?: string;\n  /** Above the conversation, e.g. your product's name. */\n  header?: React.ReactNode;\n  /** The window's height; it never changes, so the page around it never moves. */\n  height?: number;\n}\n\ntype Block =\n  | { id: number; type: \"thinking\"; labels: string[]; status: \"running\" | \"done\"; startedAt: number; label?: string; variant: \"steps\" | \"reasoning\" }\n  | { id: number; type: \"search\"; status: ResearchStatus; queries: string[]; sources: ResearchSource[] }\n  | { id: number; type: \"tool\"; step: Extract<PlaybackStep, { type: \"tool\" }>; status: ToolCallStatus; input: Record<string, unknown> }\n  | { id: number; type: \"answer\"; content: AnswerSegment[]; status: \"streaming\" | \"done\"; sources?: AnswerSource[] };\n\ntype View = { turn: number; draft: string; sent: string | null; composer: ComposerStatus; blocks: Block[]; leaving: boolean };\n\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst EMPTY: View = { turn: 0, draft: \"\", sent: null, composer: \"ready\", blocks: [], leaving: false };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** Words and spaces as separate tokens, citations whole, so an answer can stream a few at a time. */\nfunction tokens(content: AnswerSegment[] | string): AnswerSegment[] {\n  const parts: AnswerSegment[] = typeof content === \"string\" ? [content] : content;\n  return parts.flatMap((p): AnswerSegment[] => (typeof p === \"string\" ? p.split(/(\\s+)/).filter(Boolean) : [p]));\n}\n\n/** The finished state of a turn, for reduced motion. */\nfunction settled(turn: PlaybackTurn, index: number): View {\n  let id = 0;\n  const blocks: Block[] = turn.steps.map((step) => {\n    id += 1;\n    if (step.type === \"thinking\") return { id, type: \"thinking\", labels: step.steps, status: \"done\", startedAt: 0, label: step.label, variant: step.variant ?? \"steps\" };\n    if (step.type === \"search\")\n      return { id, type: \"search\", status: \"done\", queries: step.queries.map((q) => q.query), sources: step.queries.flatMap((q) => q.sources) };\n    if (step.type === \"tool\") return { id, type: \"tool\", step, status: \"done\", input: step.input };\n    return { id, type: \"answer\", content: tokens(step.content), status: \"done\", sources: step.sources };\n  });\n  return { turn: index, draft: \"\", sent: turn.prompt, composer: \"ready\", blocks, leaving: false };\n}\n\n/**\n * A clock whose waits can be paused mid-way and resumed for the time that was left,\n * so going off-screen (or pressing pause) freezes the story exactly where it is.\n */\nfunction createClock() {\n  let paused = false;\n  let wait: { resolve: () => void; left: number; since: number; timer: number } | null = null;\n  const start = () => {\n    if (!wait) return;\n    wait.since = performance.now();\n    wait.timer = window.setTimeout(() => {\n      const done = wait?.resolve;\n      wait = null;\n      done?.();\n    }, Math.max(0, wait.left));\n  };\n  return {\n    sleep(ms: number) {\n      return new Promise<void>((resolve) => {\n        wait = { resolve, left: ms, since: 0, timer: 0 };\n        if (!paused) start();\n      });\n    },\n    pause() {\n      if (paused) return;\n      paused = true;\n      if (wait?.timer) {\n        window.clearTimeout(wait.timer);\n        wait.left -= performance.now() - wait.since;\n        wait.timer = 0;\n      }\n    },\n    resume() {\n      if (!paused) return;\n      paused = false;\n      if (wait && !wait.timer) start();\n    },\n    stop() {\n      if (wait?.timer) window.clearTimeout(wait.timer);\n      wait = null;\n    },\n  };\n}\n\n/** A block that opens its own room as it arrives, so everything above glides up instead of jumping. */\nfunction Arrive({ reduced, children }: { reduced: boolean; children: React.ReactNode }) {\n  const [open, setOpen] = React.useState(reduced);\n  React.useEffect(() => {\n    if (open) return;\n    const frame = requestAnimationFrame(() => requestAnimationFrame(() => setOpen(true)));\n    return () => cancelAnimationFrame(frame);\n  }, [open]);\n  return (\n    <div\n      className=\"grid\"\n      style={{\n        gridTemplateRows: open ? \"1fr\" : \"0fr\",\n        opacity: open ? 1 : 0,\n        transform: open ? \"none\" : \"translateY(8px)\",\n        filter: open ? \"none\" : \"blur(4px)\",\n        transition: reduced ? \"none\" : `grid-template-rows 520ms ${MORPH}, opacity 420ms ${MORPH} 80ms, transform 520ms ${MORPH}, filter 420ms ${MORPH} 80ms`,\n      }}\n    >\n      <div className=\"min-h-0\">{children}</div>\n    </div>\n  );\n}\n\nexport function AgentPlayback({\n  turns,\n  speed = 1,\n  hold = 4200,\n  loop = true,\n  paused = false,\n  placeholder = \"Ask anything\",\n  header,\n  height = 540,\n  className = \"\",\n  ...props\n}: AgentPlaybackProps) {\n  const reduced = useReducedMotion();\n  const [view, setView] = React.useState<View>(EMPTY);\n  const [userPaused, setUserPaused] = React.useState(false);\n  const [offscreen, setOffscreen] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const composerRef = React.useRef<HTMLDivElement>(null);\n  const bubbleRef = React.useRef<HTMLDivElement>(null);\n  const fromRect = React.useRef<DOMRect | null>(null);\n  const clock = React.useMemo(createClock, []);\n  const pausedAt = React.useRef<number | null>(null);\n  const halted = paused || userPaused || offscreen || hidden;\n  // Restart only when the script itself changes, so an inline array in a parent that re-renders is fine.\n  const latest = React.useRef(turns);\n  React.useLayoutEffect(() => {\n    latest.current = turns;\n  });\n  const scriptKey = turns.map((t) => `${t.prompt}\\u0000${t.steps.length}`).join(\"\\u0001\");\n\n  // Off-screen or in a background tab, the story waits.\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting), { threshold: 0.15 });\n    observer.observe(el);\n    const onVisibility = () => setHidden(document.visibilityState === \"hidden\");\n    onVisibility();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, []);\n\n  // Pausing freezes the clock; on resume, live timers skip the time they were paused for.\n  React.useEffect(() => {\n    if (halted) {\n      clock.pause();\n      pausedAt.current ??= Date.now();\n      return;\n    }\n    const since = pausedAt.current;\n    pausedAt.current = null;\n    clock.resume();\n    if (since !== null) {\n      const gap = Date.now() - since;\n      setView((v) => ({ ...v, blocks: v.blocks.map((b) => (b.type === \"thinking\" && b.status === \"running\" ? { ...b, startedAt: b.startedAt + gap } : b)) }));\n    }\n  }, [halted, clock]);\n\n  // The script.\n  React.useEffect(() => {\n    const turns = latest.current;\n    if (!turns.length) return;\n    if (reduced) {\n      setView(settled(turns[0], 0));\n      return;\n    }\n    let alive = true;\n    let id = 0;\n    const pace = (ms: number) => clock.sleep(ms / Math.max(0.1, speed));\n    const update = (fn: (v: View) => View) => alive && setView(fn);\n    const patch = (blockId: number, fn: (b: Block) => Block) => update((v) => ({ ...v, blocks: v.blocks.map((b) => (b.id === blockId ? fn(b) : b)) }));\n\n    const play = async () => {\n      for (let t = 0; alive; t = (t + 1) % turns.length) {\n        const turn = turns[t];\n        update(() => ({ ...EMPTY, turn: t }));\n        await pace(700);\n\n        // A person typing: quick runs, small pauses after words and punctuation.\n        for (let i = 1; i <= turn.prompt.length && alive; i++) {\n          update((v) => ({ ...v, draft: turn.prompt.slice(0, i) }));\n          const ch = turn.prompt[i - 1];\n          await pace(/[,.?!]/.test(ch) ? 180 : ch === \" \" ? 70 + Math.random() * 60 : 26 + Math.random() * 34);\n        }\n        if (!alive) return;\n        await pace(420);\n\n        // Send: note where the text sits in the composer so the bubble can glide out of it.\n        const field = composerRef.current?.querySelector(\"textarea\");\n        fromRect.current = field?.getBoundingClientRect() ?? null;\n        update((v) => ({ ...v, draft: \"\", sent: turn.prompt, composer: \"submitted\" }));\n        await pace(650);\n\n        for (const step of turn.steps) {\n          if (!alive) return;\n          const blockId = ++id;\n          if (step.type === \"thinking\") {\n            update((v) => ({\n              ...v,\n              composer: \"streaming\",\n              blocks: [...v.blocks, { id: blockId, type: \"thinking\", labels: [], status: \"running\", startedAt: Date.now(), label: step.label, variant: step.variant ?? \"steps\" }],\n            }));\n            for (const label of step.steps) {\n              await pace(650 + Math.random() * 350);\n              patch(blockId, (b) => (b.type === \"thinking\" ? { ...b, labels: [...b.labels, label] } : b));\n            }\n            await pace(900);\n            patch(blockId, (b) => (b.type === \"thinking\" ? { ...b, status: \"done\" } : b));\n            await pace(700);\n          } else if (step.type === \"search\") {\n            update((v) => ({ ...v, composer: \"streaming\", blocks: [...v.blocks, { id: blockId, type: \"search\", status: \"searching\", queries: [], sources: [] }] }));\n            for (const group of step.queries) {\n              patch(blockId, (b) => (b.type === \"search\" ? { ...b, status: \"searching\", queries: [...b.queries, group.query] } : b));\n              await pace(1150);\n              for (const source of group.sources) {\n                patch(blockId, (b) => (b.type === \"search\" ? { ...b, status: \"reading\", sources: [...b.sources, source] } : b));\n                await pace(360 + Math.random() * 160);\n              }\n              await pace(300);\n            }\n            patch(blockId, (b) => (b.type === \"search\" ? { ...b, status: \"done\" } : b));\n            await pace(650);\n          } else if (step.type === \"tool\") {\n            update((v) => ({ ...v, composer: \"streaming\", blocks: [...v.blocks, { id: blockId, type: \"tool\", step, status: \"preparing\", input: {} }] }));\n            for (const [key, value] of Object.entries(step.input)) {\n              await pace(280 + Math.random() * 140);\n              patch(blockId, (b) => (b.type === \"tool\" ? { ...b, input: { ...b.input, [key]: value } } : b));\n            }\n            await pace(350);\n            patch(blockId, (b) => (b.type === \"tool\" ? { ...b, status: \"running\" } : b));\n            await pace(step.duration ?? 1300);\n            patch(blockId, (b) => (b.type === \"tool\" ? { ...b, status: \"done\" } : b));\n            await pace(800);\n          } else {\n            const all = tokens(step.content);\n            update((v) => ({ ...v, composer: \"streaming\", blocks: [...v.blocks, { id: blockId, type: \"answer\", content: [], status: \"streaming\", sources: step.sources }] }));\n            await pace(250);\n            for (let i = 0; i < all.length && alive; ) {\n              i = Math.min(all.length, i + 1 + Math.floor(Math.random() * 3));\n              const shown = all.slice(0, i);\n              patch(blockId, (b) => (b.type === \"answer\" ? { ...b, content: shown } : b));\n              await pace(38 + Math.random() * 46);\n            }\n            patch(blockId, (b) => (b.type === \"answer\" ? { ...b, status: \"done\" } : b));\n          }\n        }\n        update((v) => ({ ...v, composer: \"ready\" }));\n        await pace(hold);\n        if (!loop && t === turns.length - 1) return;\n        // The conversation dissolves before the next one starts.\n        update((v) => ({ ...v, leaving: true }));\n        await pace(520);\n      }\n    };\n    play();\n    return () => {\n      alive = false;\n      clock.stop();\n    };\n  }, [scriptKey, speed, hold, loop, reduced, clock]);\n\n  // The sent text glides from where it was typed into its bubble.\n  React.useLayoutEffect(() => {\n    const bubble = bubbleRef.current;\n    const from = fromRect.current;\n    fromRect.current = null;\n    if (!bubble || !from || reduced) return;\n    const to = bubble.getBoundingClientRect();\n    const dx = from.left + 14 - (to.left + 16);\n    const dy = from.top + 12 - (to.top + 10);\n    bubble.animate(\n      [\n        { transform: `translate(${dx}px, ${dy}px)`, backgroundColor: \"transparent\" },\n        { transform: \"none\", backgroundColor: getComputedStyle(bubble).backgroundColor },\n      ],\n      { duration: 620, easing: MORPH }\n    );\n  }, [view.sent, reduced]);\n\n  const turn = turns[view.turn];\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"region\"\n      aria-roledescription=\"demo\"\n      aria-label=\"Product demo\"\n      className={`relative flex flex-col overflow-hidden rounded-[22px] border border-border bg-background ${className}`}\n      style={{ height }}\n      {...props}\n    >\n      {/* What the demo shows, for people who can't watch it; the moving copy is hidden from them. */}\n      <p className=\"sr-only\">\n        {turn ? `A looping demo. Someone asks: “${turn.prompt}”, and the assistant works through it and answers.` : \"\"}\n      </p>\n\n      {header && <div className=\"flex h-11 shrink-0 items-center border-b border-border px-4 pr-12 text-[13px] font-medium text-foreground\">{header}</div>}\n\n      <button\n        type=\"button\"\n        aria-label={userPaused ? \"Play demo\" : \"Pause demo\"}\n        aria-pressed={userPaused}\n        onClick={() => setUserPaused((p) => !p)}\n        className={`absolute right-2.5 top-2 z-10 flex size-7 items-center justify-center rounded-full bg-background text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:text-foreground ${FOCUS}`}\n      >\n        <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(!userPaused, reduced)}>\n          <Pause className=\"size-3\" fill=\"currentColor\" strokeWidth={0} />\n        </span>\n        <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(userPaused, reduced)}>\n          <Play className=\"size-3 translate-x-px\" fill=\"currentColor\" strokeWidth={0} />\n        </span>\n      </button>\n\n      <div aria-hidden=\"true\" inert className=\"flex min-h-0 flex-1 flex-col\">\n        {/* The conversation sits on the composer and grows upwards; older lines fade out at the top. */}\n        <div className=\"relative min-h-0 flex-1 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,#000_56px)]\">\n          <div\n            key={view.turn}\n            className=\"absolute inset-x-0 bottom-0 flex flex-col gap-4 px-4 pb-4 sm:px-5\"\n            style={{\n              opacity: view.leaving ? 0 : 1,\n              filter: view.leaving ? \"blur(6px)\" : \"none\",\n              transform: view.leaving ? \"translateY(-12px)\" : \"none\",\n              transition: reduced ? \"none\" : `opacity 480ms ${MORPH}, filter 480ms ${MORPH}, transform 520ms ${MORPH}`,\n            }}\n          >\n            {view.sent && (\n              // Not wrapped in Arrive: its own glide out of the composer is the entrance.\n              <div className=\"flex justify-end\">\n                <div ref={bubbleRef} className=\"max-w-[85%] whitespace-pre-wrap rounded-[20px] bg-muted px-4 py-2.5 text-[14px] leading-relaxed text-foreground\">\n                  {view.sent}\n                </div>\n              </div>\n            )}\n            {view.blocks.map((b) => (\n              <Arrive key={b.id} reduced={reduced}>\n                {b.type === \"thinking\" ? (\n                  <ThinkingTrace\n                    variant={b.variant}\n                    status={b.status}\n                    label={b.label}\n                    startedAt={reduced ? undefined : b.startedAt}\n                    steps={b.labels.map((label) => ({ label }))}\n                  />\n                ) : b.type === \"search\" ? (\n                  <WebResearch status={b.status} queries={b.queries} sources={b.sources} />\n                ) : b.type === \"tool\" ? (\n                  <ToolCall\n                    name={b.step.name}\n                    title={b.step.title}\n                    icon={b.step.icon}\n                    status={b.status}\n                    input={b.input}\n                    output={b.status === \"done\" ? b.step.output : undefined}\n                    renderOutput={b.step.renderOutput}\n                    duration={b.step.duration ?? 1300}\n                    defaultOpen={b.step.open ?? true}\n                  />\n                ) : (\n                  <div className=\"flex flex-col gap-2\">\n                    <StreamingText content={b.content} status={b.status} className=\"text-[14px]\" />\n                    <div\n                      className=\"grid\"\n                      style={{\n                        gridTemplateRows: b.status === \"done\" ? \"1fr\" : \"0fr\",\n                        opacity: b.status === \"done\" ? 1 : 0,\n                        transition: reduced ? \"none\" : `grid-template-rows 420ms ${MORPH}, opacity 360ms ${MORPH} 120ms`,\n                      }}\n                    >\n                      <div className=\"min-h-0 overflow-hidden\">\n                        <AnswerActions copyText=\"\" sources={b.sources} />\n                      </div>\n                    </div>\n                  </div>\n                )}\n              </Arrive>\n            ))}\n          </div>\n        </div>\n\n        <div ref={composerRef} className=\"shrink-0 px-3 pb-3\">\n          <PromptComposer value={view.draft} status={view.composer} placeholder={placeholder} onSubmit={() => {}} onStop={() => {}} />\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/agent-playback"}