{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "answer-01",
  "title": "Answer with citations",
  "description": "An answer that streams word by word under a steps line that opens, with numbered marks that preview their source and light its pill, then Copy, Regenerate, thumbs and follow-ups that fill the composer.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/agent-run.json",
    "https://ui.ndekocode.dev/r/animated-size.json",
    "https://ui.ndekocode.dev/r/brand-logos.json",
    "https://ui.ndekocode.dev/r/brand-tile.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/call-status.json",
    "https://ui.ndekocode.dev/r/chat-message.json",
    "https://ui.ndekocode.dev/r/chat-motion.json",
    "https://ui.ndekocode.dev/r/company-mark.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/live-duration.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-presets.json",
    "https://ui.ndekocode.dev/r/motion-word-roll.json",
    "https://ui.ndekocode.dev/r/popover.json",
    "https://ui.ndekocode.dev/r/recipes.json",
    "https://ui.ndekocode.dev/r/textarea.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-agent-run.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/answer/answer-01.tsx",
      "content": "'use client';\n\nimport { PreviewCard as PreviewCardPrimitive } from '@base-ui/react/preview-card';\nimport { AnimatePresence, motion, useInView } from 'motion/react';\nimport { useId, useRef, useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll, type WordRollWord } from '@/components/core/word-roll';\nimport {\n  useRunScript,\n  type RunScriptStep,\n} from '@/registry/ndeko/hooks/use-agent-run';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\nimport {\n  elapsedOf,\n  emptyRun,\n  formatDuration,\n  type CallStatus,\n  type PlanStep,\n  type RunEvent,\n  type RunSnapshot,\n  type StepStatus,\n} from '@/registry/ndeko/lib/agent-run';\nimport {\n  ArrowUpIcon,\n  ChevronDownIcon,\n  CircleDashedIcon,\n  RefreshCwIcon,\n  ThumbsDownIcon,\n  ThumbsUpIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { rowPresenceOf } from '@/registry/ndeko/lib/motion-presets';\nimport { POPUP_TRANSITION } from '@/registry/ndeko/lib/recipes';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport {\n  appendWord,\n  leadingSpaceOf,\n  nounOf,\n  wordsOf,\n} from '@/registry/ndeko/lib/words';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { CallStatusMark } from '@/registry/ndeko/ui/call-status';\nimport {\n  MessageAction,\n  MessageActions,\n} from '@/registry/ndeko/ui/chat-message';\nimport { Fold, Swap } from '@/registry/ndeko/ui/chat-motion';\nimport { CompanyMark } from '@/registry/ndeko/ui/company-mark';\nimport { LiveDuration } from '@/registry/ndeko/ui/live-duration';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { POPOVER_GLIDE } from '@/registry/ndeko/ui/popover';\nimport { Textarea } from '@/registry/ndeko/ui/textarea';\n\nimport { CopyAction } from './_parts/copy-action';\nimport { LabTile } from './_parts/lab-tile';\n\nexport type AnswerSource = {\n  id: string;\n  site: string;\n  title: string;\n  href: string;\n  /** The line of the page the answer leans on. */\n  quote: string;\n};\n\nexport type AnswerToken =\n  | { kind: 'word'; text: string }\n  | { kind: 'cite'; number: number }\n  | { kind: 'break' };\n\ntype PlacedToken = AnswerToken & { index: number };\n\ntype CitationPayload = {\n  number: number;\n  source: AnswerSource;\n};\n\nconst CITE_PATTERN = /\\[(\\d+)\\]/g;\n\nconst PARAGRAPH_BREAK = /\\n{2,}/;\n\nconst CARD_OPEN_DELAY_MS = 150;\n\nconst CARD_CLOSE_DELAY_MS = 90;\n\nfunction tokensOfPiece(piece: string): AnswerToken[] {\n  const firstMark = piece.indexOf('[');\n  const hasMarks = firstMark >= 0;\n  if (!hasMarks) {\n    return [{ kind: 'word', text: piece }];\n  }\n  const tokens: AnswerToken[] = [];\n  const text = piece.slice(0, firstMark);\n  if (text !== '') {\n    tokens.push({ kind: 'word', text });\n  }\n  for (const match of piece.matchAll(CITE_PATTERN)) {\n    const number = Number(match[1]);\n    tokens.push({ kind: 'cite', number });\n  }\n  return tokens;\n}\n\n/** \"fee.[1] Before\" gives the word \"fee.\", the mark 1 and the word \"Before\"; a blank line starts a paragraph. */\nexport function tokensOf(text: string): AnswerToken[] {\n  const tokens: AnswerToken[] = [];\n  const paragraphs = text.split(PARAGRAPH_BREAK);\n  paragraphs.forEach((paragraph, index) => {\n    if (index > 0) {\n      tokens.push({ kind: 'break' });\n    }\n    for (const piece of wordsOf(paragraph)) {\n      const pieceTokens = tokensOfPiece(piece);\n      tokens.push(...pieceTokens);\n    }\n  });\n  return tokens;\n}\n\nfunction paragraphsOf(tokens: AnswerToken[], shown: number) {\n  const paragraphs: PlacedToken[][] = [[]];\n  const visible = tokens.slice(0, shown);\n  visible.forEach((token, index) => {\n    if (token.kind === 'break') {\n      paragraphs.push([]);\n      return;\n    }\n    const current = paragraphs[paragraphs.length - 1];\n    current.push({ ...token, index });\n  });\n  return paragraphs.filter((paragraph) => paragraph.length > 0);\n}\n\nfunction citedNumbersOf(tokens: AnswerToken[], shown: number) {\n  const numbers = new Set<number>();\n  const visible = tokens.slice(0, shown);\n  for (const token of visible) {\n    if (token.kind === 'cite') {\n      numbers.add(token.number);\n    }\n  }\n  return numbers;\n}\n\nfunction plainTextOf(tokens: AnswerToken[]) {\n  const parts: string[] = [];\n  for (const token of tokens) {\n    if (token.kind === 'word') {\n      parts.push(` ${token.text}`);\n    }\n    if (token.kind === 'cite') {\n      parts.push(`[${token.number}]`);\n    }\n    if (token.kind === 'break') {\n      parts.push('\\n\\n');\n    }\n  }\n  const joined = parts.join('');\n  const tidied = joined.replace(/\\n\\n /g, '\\n\\n');\n  return tidied.trim();\n}\n\nfunction sourceLineOf(source: AnswerSource, index: number) {\n  const number = index + 1;\n  return `[${number}] ${source.title}, ${source.href}`;\n}\n\nfunction copyTextOf(tokens: AnswerToken[], sources: AnswerSource[]) {\n  const body = plainTextOf(tokens);\n  const lines = sources.map(sourceLineOf);\n  const list = lines.join('\\n');\n  return `${body}\\n\\nSources\\n${list}`;\n}\n\nfunction litOverlayOf(isLit: boolean) {\n  if (isLit) {\n    return 'opacity-100';\n  }\n  return 'opacity-0';\n}\n\nfunction litTextOf(isLit: boolean) {\n  if (isLit) {\n    return 'text-primary-ink';\n  }\n  return 'text-muted-foreground';\n}\n\ntype LightHandlers = {\n  litNumber: number | null;\n  onLight: (number: number | null) => void;\n};\n\ntype CitationMarkProps = LightHandlers & {\n  number: number;\n  source: AnswerSource;\n  handle: PreviewCardPrimitive.Handle<CitationPayload>;\n};\n\nfunction CitationMark({\n  number,\n  source,\n  handle,\n  litNumber,\n  onLight,\n}: CitationMarkProps) {\n  const isLit = litNumber === number;\n  const payload: CitationPayload = { number, source };\n\n  function light() {\n    onLight(number);\n  }\n\n  function dim() {\n    onLight(null);\n  }\n\n  return (\n    <PreviewCardPrimitive.Trigger\n      handle={handle}\n      payload={payload}\n      href={source.href}\n      target='_blank'\n      rel='noreferrer'\n      delay={CARD_OPEN_DELAY_MS}\n      closeDelay={CARD_CLOSE_DELAY_MS}\n      aria-label={`Source ${number}: ${source.title}, ${source.site}`}\n      onPointerEnter={light}\n      onPointerLeave={dim}\n      onFocus={light}\n      onBlur={dim}\n      className={cn(\n        'keycap-3d focus-visible:ring-ring/25 relative ms-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-[4px] px-1 align-[0.12em] text-[10px] leading-none font-medium tabular-nums no-underline transition-[color,opacity,scale] duration-200 ease-(--ease-out-strong) outline-none focus-visible:ring-[3px] starting:scale-75 starting:opacity-0',\n        litTextOf(isLit)\n      )}\n    >\n      <span\n        aria-hidden='true'\n        className={cn(\n          'bg-primary/15 pointer-events-none absolute inset-0 rounded-[inherit] transition-opacity duration-150',\n          litOverlayOf(isLit)\n        )}\n      />\n      <span className='relative'>{number}</span>\n    </PreviewCardPrimitive.Trigger>\n  );\n}\n\nfunction CitationBody({ payload }: { payload: CitationPayload }) {\n  const { source, number } = payload;\n\n  return (\n    <div className='flex flex-col gap-2'>\n      <span className='flex items-center gap-1.5'>\n        <CompanyMark seed={source.site} size='sm' />\n        <span className='text-primary-ink min-w-0 flex-1 truncate text-xs'>\n          {source.site}\n        </span>\n        <span className='text-muted-foreground text-xs tabular-nums'>\n          Source {number}\n        </span>\n      </span>\n      <span className='text-[13px] font-medium text-balance'>\n        {source.title}\n      </span>\n      <blockquote className='border-foreground/10 text-muted-foreground border-s-2 ps-3 text-[13px] leading-relaxed'>\n        &ldquo;{source.quote}&rdquo;\n      </blockquote>\n    </div>\n  );\n}\n\nfunction CitationPopup({ payload }: { payload: CitationPayload | undefined }) {\n  return (\n    <PreviewCardPrimitive.Portal>\n      <PreviewCardPrimitive.Positioner\n        side='top'\n        sideOffset={8}\n        align='center'\n        className={cn('z-50', POPOVER_GLIDE)}\n      >\n        <PreviewCardPrimitive.Popup\n          className={cn(\n            'surface-raised w-72 max-w-[calc(100vw-2rem)] rounded-xl p-3 outline-none',\n            POPUP_TRANSITION\n          )}\n        >\n          <AnimatedSize>\n            {payload && <CitationBody payload={payload} />}\n          </AnimatedSize>\n        </PreviewCardPrimitive.Popup>\n      </PreviewCardPrimitive.Positioner>\n    </PreviewCardPrimitive.Portal>\n  );\n}\n\ntype SourcePillsProps = LightHandlers & {\n  sources: AnswerSource[];\n  shownNumbers: Set<number>;\n};\n\nfunction SourcePills({\n  sources,\n  shownNumbers,\n  litNumber,\n  onLight,\n}: SourcePillsProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const pillMotion = pillMotionOf(shouldReduceMotion);\n\n  return (\n    <ul aria-label='Sources' className='flex flex-wrap gap-1.5'>\n      <AnimatePresence initial={false} mode='popLayout'>\n        {sources.map((source, index) => {\n          const number = index + 1;\n          const isShown = shownNumbers.has(number);\n          if (!isShown) {\n            return null;\n          }\n          const isLit = litNumber === number;\n          return (\n            <motion.li key={source.id} layout {...pillMotion}>\n              <a\n                href={source.href}\n                target='_blank'\n                rel='noreferrer'\n                onPointerEnter={() => onLight(number)}\n                onPointerLeave={() => onLight(null)}\n                onFocus={() => onLight(number)}\n                onBlur={() => onLight(null)}\n                className='bg-foreground/[0.05] focus-visible:ring-ring/25 relative flex h-6 max-w-52 items-center gap-1.5 overflow-hidden rounded-full ps-1 pe-1 text-xs no-underline outline-none focus-visible:ring-[3px]'\n              >\n                <span\n                  aria-hidden='true'\n                  className={cn(\n                    'bg-primary/12 pointer-events-none absolute inset-0 transition-opacity duration-150',\n                    litOverlayOf(isLit)\n                  )}\n                />\n                <CompanyMark\n                  seed={source.site}\n                  size='sm'\n                  className='relative rounded-full'\n                />\n                <span\n                  className={cn(\n                    'relative min-w-0 truncate transition-colors duration-150',\n                    litTextOf(isLit)\n                  )}\n                >\n                  {source.site}\n                </span>\n                <span className='keycap-3d text-muted-foreground relative inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] tabular-nums'>\n                  {number}\n                </span>\n              </a>\n            </motion.li>\n          );\n        })}\n      </AnimatePresence>\n    </ul>\n  );\n}\n\nconst PILL_ENTER = {\n  initial: { opacity: 0, scale: 0.9 },\n  animate: { opacity: 1, scale: 1 },\n  exit: { opacity: 0, scale: 0.9 },\n  transition: { type: 'spring', bounce: 0, duration: 0.3 },\n} as const;\n\nconst PILL_FADE = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0 },\n  transition: { duration: 0.15 },\n} as const;\n\nfunction pillMotionOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return PILL_FADE;\n  }\n  return PILL_ENTER;\n}\n\ntype AnswerParagraphProps = LightHandlers & {\n  tokens: PlacedToken[];\n  sources: AnswerSource[];\n  handle: PreviewCardPrimitive.Handle<CitationPayload>;\n  showsCaret: boolean;\n};\n\nfunction AnswerParagraph({\n  tokens,\n  sources,\n  handle,\n  showsCaret,\n  litNumber,\n  onLight,\n}: AnswerParagraphProps) {\n  return (\n    <p>\n      {tokens.map((token, position) => {\n        if (token.kind === 'cite') {\n          const cited = sources[token.number - 1];\n          if (!cited) {\n            return null;\n          }\n          return (\n            <CitationMark\n              key={token.index}\n              number={token.number}\n              source={cited}\n              handle={handle}\n              litNumber={litNumber}\n              onLight={onLight}\n            />\n          );\n        }\n        if (token.kind === 'word') {\n          return (\n            <span\n              key={token.index}\n              className='transition-opacity duration-300 ease-(--ease-out-strong) starting:opacity-0'\n            >\n              {leadingSpaceOf(position)}\n              {token.text}\n            </span>\n          );\n        }\n        return null;\n      })}\n      {showsCaret && (\n        <span\n          aria-hidden='true'\n          className='bg-foreground/50 ms-1 inline-block h-[1em] w-[2px] animate-pulse rounded-full align-[-0.15em]'\n        />\n      )}\n    </p>\n  );\n}\n\nexport type CitedAnswerProps = {\n  tokens: AnswerToken[];\n  /** The cited sources in mark order: mark 1 is the first. */\n  sources: AnswerSource[];\n  /** How many tokens have streamed so far; all of them when left out. */\n  shownTokens?: number;\n  isStreaming?: boolean;\n  className?: string;\n};\n\nexport function CitedAnswer({\n  tokens,\n  sources,\n  shownTokens,\n  isStreaming = false,\n  className,\n}: CitedAnswerProps) {\n  const [litNumber, setLitNumber] = useState<number | null>(null);\n  const [cardHandle] = useState(() =>\n    PreviewCardPrimitive.createHandle<CitationPayload>()\n  );\n  const shown = shownTokens ?? tokens.length;\n  const paragraphs = paragraphsOf(tokens, shown);\n  const shownNumbers = citedNumbersOf(tokens, shown);\n  const lastParagraph = paragraphs.length - 1;\n\n  return (\n    <div className={cn('flex flex-col gap-3', className)}>\n      <div className='text-foreground flex flex-col gap-2.5 text-sm leading-relaxed'>\n        {paragraphs.map((paragraph, paragraphIndex) => {\n          const isLast = paragraphIndex === lastParagraph;\n          const showsCaret = isStreaming && isLast;\n          return (\n            <AnswerParagraph\n              key={paragraph[0].index}\n              tokens={paragraph}\n              sources={sources}\n              handle={cardHandle}\n              showsCaret={showsCaret}\n              litNumber={litNumber}\n              onLight={setLitNumber}\n            />\n          );\n        })}\n      </div>\n      <SourcePills\n        sources={sources}\n        shownNumbers={shownNumbers}\n        litNumber={litNumber}\n        onLight={setLitNumber}\n      />\n      <PreviewCardPrimitive.Root handle={cardHandle}>\n        {({ payload }) => <CitationPopup payload={payload} />}\n      </PreviewCardPrimitive.Root>\n    </div>\n  );\n}\n\nexport type AnswerStage =\n  'idle' | 'searching' | 'reading' | 'writing' | 'done' | 'stopped';\n\ntype Span = { startedAt?: number; endedAt?: number };\n\nfunction stepOf(plan: PlanStep[], id: string) {\n  return plan.find((step) => step.id === id);\n}\n\nfunction stageOf(run: RunSnapshot): AnswerStage {\n  if (run.status === 'cancelled') {\n    return 'stopped';\n  }\n  if (run.status === 'done') {\n    return 'done';\n  }\n  const running = run.plan.find((step) => step.status === 'running');\n  if (!running) {\n    return 'idle';\n  }\n  if (running.id === 'search') {\n    return 'searching';\n  }\n  if (running.id === 'read') {\n    return 'reading';\n  }\n  return 'writing';\n}\n\nfunction searchSpanOf(plan: PlanStep[]): Span {\n  const search = stepOf(plan, 'search');\n  const read = stepOf(plan, 'read');\n  return { startedAt: search?.startedAt, endedAt: read?.endedAt };\n}\n\nfunction isLookingStage(stage: AnswerStage) {\n  const isSearching = stage === 'searching';\n  const isReading = stage === 'reading';\n  return isSearching || isReading;\n}\n\nfunction countWordOf(count: number): WordRollWord {\n  return {\n    key: 'count',\n    text: String(count),\n    node: <MotionNumber value={count} />,\n  };\n}\n\nfunction spanTextOf(span: Span) {\n  const end = span.endedAt ?? span.startedAt ?? 0;\n  const elapsed = elapsedOf(span, end);\n  return formatDuration(elapsed);\n}\n\nfunction summaryWordsOf(\n  stage: AnswerStage,\n  foundCount: number,\n  span: Span\n): WordRollWord[] {\n  const noun = nounOf(foundCount, 'source');\n  const count = countWordOf(foundCount);\n  if (stage === 'idle') {\n    return ['Getting', 'ready'];\n  }\n  if (stage === 'searching') {\n    return ['Searching,', count, noun, 'found'];\n  }\n  if (stage === 'reading') {\n    return ['Reading', count, noun];\n  }\n  const time = spanTextOf(span);\n  return ['Searched', count, noun, 'in', time];\n}\n\nconst STEP_AS_CALL: Record<Exclude<StepStatus, 'skipped'>, CallStatus> = {\n  upcoming: 'queued',\n  running: 'running',\n  done: 'done',\n  failed: 'failed',\n};\n\nfunction StepGlyph({ status }: { status: StepStatus }) {\n  if (status === 'skipped') {\n    return (\n      <span className='inline-flex size-4 shrink-0'>\n        <CircleDashedIcon className='text-muted-foreground size-4' />\n        <span className='sr-only'>Stopped</span>\n      </span>\n    );\n  }\n  return <CallStatusMark status={STEP_AS_CALL[status]} />;\n}\n\ntype StepWording = {\n  upcoming: string;\n  running: string;\n  done: string;\n  stopped: string;\n};\n\nfunction stepWordingOf(\n  id: string,\n  foundCount: number,\n  citeCount: number\n): StepWording {\n  const sources = `${foundCount} ${nounOf(foundCount, 'source')}`;\n  const citations = `${citeCount} ${nounOf(citeCount, 'citation')}`;\n  if (id === 'search') {\n    return {\n      upcoming: 'Search the web',\n      running: 'Searching the web',\n      done: 'Searched the web',\n      stopped: 'Stopped while searching',\n    };\n  }\n  if (id === 'read') {\n    return {\n      upcoming: 'Read the sources',\n      running: `Reading ${sources}`,\n      done: `Read ${sources}`,\n      stopped: 'Stopped while reading',\n    };\n  }\n  return {\n    upcoming: 'Write the answer',\n    running: 'Writing the answer',\n    done: `Wrote the answer with ${citations}`,\n    stopped: 'Stopped while writing',\n  };\n}\n\nfunction stepLabelOf(step: PlanStep, foundCount: number, citeCount: number) {\n  const wording = stepWordingOf(step.id, foundCount, citeCount);\n  if (step.status === 'running') {\n    return wording.running;\n  }\n  if (step.status === 'done') {\n    return wording.done;\n  }\n  if (step.status === 'skipped') {\n    return wording.stopped;\n  }\n  return wording.upcoming;\n}\n\nfunction chevronTurnOf(isOpen: boolean) {\n  if (isOpen) {\n    return 'rotate-180';\n  }\n  return 'rotate-0';\n}\n\nexport type AnswerStepsProps = {\n  plan: PlanStep[];\n  /** What the search query was, shown under the first step. */\n  query: string;\n  found: AnswerSource[];\n  citeCount: number;\n  stage: AnswerStage;\n};\n\nexport function AnswerSteps({\n  plan,\n  query,\n  found,\n  citeCount,\n  stage,\n}: AnswerStepsProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const panelId = useId();\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const rowPresence = rowPresenceOf(shouldReduceMotion);\n  const span = searchSpanOf(plan);\n  const words = summaryWordsOf(stage, found.length, span);\n  const isLooking = isLookingStage(stage);\n  const hasStarted = span.startedAt !== undefined;\n  const showsClock = isLooking && hasStarted;\n\n  function toggle() {\n    setIsOpen((current) => !current);\n  }\n\n  return (\n    <div className='@container flex flex-col'>\n      <button\n        type='button'\n        aria-expanded={isOpen}\n        aria-controls={panelId}\n        onClick={toggle}\n        className='text-muted-foreground hover:text-foreground focus-visible:ring-ring/25 -ms-1 flex h-7 w-fit max-w-full cursor-pointer items-center gap-1.5 rounded-md px-1 text-[13px] transition-colors outline-none focus-visible:ring-[3px]'\n      >\n        <span className='min-w-0 truncate'>\n          <WordRoll words={words} />\n        </span>\n        {showsClock && (\n          <LiveDuration\n            item={span}\n            isRunning={isLooking}\n            className='text-muted-foreground/80 text-xs'\n          />\n        )}\n        <ChevronDownIcon\n          className={cn(\n            'size-3.5 shrink-0 transition-transform duration-200 ease-(--ease-out-strong)',\n            chevronTurnOf(isOpen)\n          )}\n        />\n      </button>\n      <Fold open={isOpen} id={panelId}>\n        <ol className='flex flex-col gap-2 pt-2 pb-1'>\n          {plan.map((step) => {\n            const isSearch = step.id === 'search';\n            return (\n              <li key={step.id} className='flex flex-col gap-1.5'>\n                <span className='flex items-center gap-2 text-[13px]'>\n                  <Swap swapKey={step.status} className='size-4 shrink-0'>\n                    <StepGlyph status={step.status} />\n                  </Swap>\n                  <span className='min-w-0 truncate'>\n                    {stepLabelOf(step, found.length, citeCount)}\n                  </span>\n                </span>\n                {isSearch && (\n                  <div className='flex flex-col gap-1 ps-6'>\n                    <span className='text-muted-foreground truncate font-mono text-xs'>\n                      {query}\n                    </span>\n                    <ul aria-label='Sources found' className='flex flex-col'>\n                      <AnimatePresence initial={false}>\n                        {found.map((source) => (\n                          <motion.li\n                            key={source.id}\n                            className='overflow-hidden'\n                            {...rowPresence}\n                          >\n                            <span className='flex h-6 items-center gap-1.5 text-xs'>\n                              <CompanyMark seed={source.site} size='sm' />\n                              <span className='min-w-0 truncate'>\n                                {source.title}\n                              </span>\n                              <span className='text-muted-foreground hidden shrink-0 @sm:inline'>\n                                {source.site}\n                              </span>\n                            </span>\n                          </motion.li>\n                        ))}\n                      </AnimatePresence>\n                    </ul>\n                  </div>\n                )}\n              </li>\n            );\n          })}\n        </ol>\n      </Fold>\n    </div>\n  );\n}\n\ntype Feedback = 'good' | 'bad';\n\nfunction toggledFeedback(current: Feedback | null, choice: Feedback) {\n  if (current === choice) {\n    return null;\n  }\n  return choice;\n}\n\nconst ACTION_BUTTON =\n  'text-muted-foreground aria-pressed:bg-foreground/[0.07] aria-pressed:text-primary-ink';\n\ntype AnswerActionsProps = {\n  copyText: string;\n  feedback: Feedback | null;\n  onFeedback: (feedback: Feedback | null) => void;\n  onRegenerate: () => void;\n  onCopied: () => void;\n};\n\nfunction AnswerActions({\n  copyText,\n  feedback,\n  onFeedback,\n  onRegenerate,\n  onCopied,\n}: AnswerActionsProps) {\n  const isGood = feedback === 'good';\n  const isBad = feedback === 'bad';\n\n  return (\n    <MessageActions className='-ms-1.5'>\n      <MessageAction tooltip='Copy'>\n        <CopyAction\n          text={copyText}\n          label='Copy the answer'\n          onCopied={onCopied}\n        />\n      </MessageAction>\n      <MessageAction tooltip='Regenerate'>\n        <Button\n          type='button'\n          variant='ghost'\n          size='icon-sm'\n          aria-label='Regenerate the answer'\n          onClick={onRegenerate}\n          className={ACTION_BUTTON}\n        >\n          <RefreshCwIcon className='size-3.5' />\n        </Button>\n      </MessageAction>\n      <MessageAction tooltip='Good answer'>\n        <Button\n          type='button'\n          variant='ghost'\n          size='icon-sm'\n          aria-label='Good answer'\n          aria-pressed={isGood}\n          onClick={() => onFeedback(toggledFeedback(feedback, 'good'))}\n          className={ACTION_BUTTON}\n        >\n          <ThumbsUpIcon className='size-3.5' />\n        </Button>\n      </MessageAction>\n      <MessageAction tooltip='Bad answer'>\n        <Button\n          type='button'\n          variant='ghost'\n          size='icon-sm'\n          aria-label='Bad answer'\n          aria-pressed={isBad}\n          onClick={() => onFeedback(toggledFeedback(feedback, 'bad'))}\n          className={ACTION_BUTTON}\n        >\n          <ThumbsDownIcon className='size-3.5' />\n        </Button>\n      </MessageAction>\n    </MessageActions>\n  );\n}\n\ntype AnswerId = 'pallet-fee' | 'old-tiers' | 'harbourline' | 'announced';\n\ntype CannedAnswer = {\n  id: AnswerId;\n  question: string;\n  query: string;\n  text: string;\n  found: AnswerSource[];\n  /** Source ids in mark order. */\n  cited: string[];\n  followUps: AnswerId[];\n};\n\nfunction makeSource(\n  id: string,\n  site: string,\n  slug: string,\n  title: string,\n  quote: string\n): AnswerSource {\n  return { id, site, title, quote, href: `https://${site}/${slug}` };\n}\n\nconst ANSWERS: CannedAnswer[] = [\n  {\n    id: 'pallet-fee',\n    question: 'Did Brightmoor change its pallet fees this year?',\n    query: 'brightmoor pallet fee 2026',\n    text: 'Yes. Brightmoor moved bulk orders to a flat fee of 18.40 EUR per pallet on 1 October.[1] Before that, its tiered rate started at 15.90 EUR.[2]\\n\\nThe flat fee applies from 40 pallets, so smaller orders keep the old tiers.[1] Two regional carriers matched it within a month,[3] and contracts signed before September keep the old rate until they renew.[4]',\n    found: [\n      makeSource(\n        'fees',\n        'brightmoor-supply.example',\n        'pricing/pallets',\n        'Pallet fees from 1 October',\n        'From 1 October, bulk orders are billed a flat fee of 18.40 EUR per pallet. Orders under 40 pallets keep the tiers in Annex B.'\n      ),\n      makeSource(\n        'rates',\n        'harbourline-freight.example',\n        'news/autumn-rates',\n        'Regional pallet rates, autumn update',\n        'Until September, the Brightmoor tiered rate started at 15.90 EUR per pallet.'\n      ),\n      makeSource(\n        'index',\n        'freightdesk.example',\n        'index/q3',\n        'Q3 freight index',\n        'Pallet prices in the region rose 6% over the quarter.'\n      ),\n      makeSource(\n        'matched',\n        'palletwatch.example',\n        'flat-fees',\n        'Who matched the new flat fees',\n        'Two regional carriers matched the 18.40 EUR fee within a month of the change.'\n      ),\n      makeSource(\n        'waivers',\n        'buyers-circle.example',\n        't/brightmoor-waivers',\n        'Thread: Brightmoor fee waivers',\n        'Brightmoor confirmed that contracts signed before September keep the old rate until renewal.'\n      ),\n      makeSource(\n        'tracker',\n        'ledgerline.example',\n        'suppliers/brightmoor',\n        'Supplier fee tracker',\n        'Brightmoor: one fee change recorded this year.'\n      ),\n    ],\n    cited: ['fees', 'rates', 'matched', 'waivers'],\n    followUps: ['old-tiers', 'harbourline', 'announced'],\n  },\n  {\n    id: 'old-tiers',\n    question: 'Which orders keep the old tiers?',\n    query: 'brightmoor annex b tiered pallet rate',\n    text: 'Two kinds of orders keep the tiers. Orders under 40 pallets are still billed by tier, from 15.90 EUR for the first ten pallets.[1]\\n\\nContracts signed before 1 September also keep the tiered rate until their renewal date, whatever the order size.[2]',\n    found: [\n      makeSource(\n        'annex',\n        'brightmoor-supply.example',\n        'pricing/annex-b',\n        'Annex B: tiered pallet rates',\n        'Orders under 40 pallets are billed by tier, from 15.90 EUR for the first ten pallets.'\n      ),\n      makeSource(\n        'tracker',\n        'ledgerline.example',\n        'suppliers/brightmoor',\n        'Supplier fee tracker',\n        'Brightmoor: one fee change recorded this year.'\n      ),\n      makeSource(\n        'renewal',\n        'buyers-circle.example',\n        't/brightmoor-waivers',\n        'Thread: Brightmoor fee waivers',\n        'Contracts signed before 1 September keep the tiered rate until their renewal date.'\n      ),\n    ],\n    cited: ['annex', 'renewal'],\n    followUps: ['harbourline', 'announced'],\n  },\n  {\n    id: 'harbourline',\n    question: 'How does Harbourline price pallets now?',\n    query: 'harbourline freight pallet rate 2026',\n    text: 'Harbourline still prices by tier. Its first tier is 16.20 EUR per pallet and drops to 14.80 EUR above 60 pallets.[1]\\n\\nIt matched the Brightmoor flat fee of 18.40 EUR only on overnight routes.[2] For a regular order of 50 pallets, Harbourline comes out cheaper.[1]',\n    found: [\n      makeSource(\n        'tiers',\n        'harbourline-freight.example',\n        'rates',\n        'Pallet rates and tiers',\n        'Tier one is 16.20 EUR per pallet. Above 60 pallets, tier three is 14.80 EUR.'\n      ),\n      makeSource(\n        'index',\n        'freightdesk.example',\n        'index/q3',\n        'Q3 freight index',\n        'Pallet prices in the region rose 6% over the quarter.'\n      ),\n      makeSource(\n        'overnight',\n        'palletwatch.example',\n        'flat-fees',\n        'Who matched the new flat fees',\n        'Harbourline matched the 18.40 EUR flat fee on overnight routes only.'\n      ),\n    ],\n    cited: ['tiers', 'overnight'],\n    followUps: ['old-tiers', 'announced'],\n  },\n  {\n    id: 'announced',\n    question: 'When was the change announced?',\n    query: 'brightmoor flat pallet fee announcement',\n    text: 'Brightmoor announced the flat fee on 2 September in a news post.[1] The note reached account holders the same day,[2] four weeks before the fee took effect on 1 October.[3]',\n    found: [\n      makeSource(\n        'news',\n        'brightmoor-supply.example',\n        'news/flat-pallet-fee',\n        'News: a flat fee for bulk pallets',\n        'Today we are announcing a flat pallet fee for bulk orders, effective 1 October.'\n      ),\n      makeSource(\n        'coverage',\n        'palletwatch.example',\n        'brightmoor-goes-flat',\n        'Brightmoor goes flat',\n        'The note went to account holders on 2 September, four weeks ahead of the change.'\n      ),\n      makeSource(\n        'fees',\n        'brightmoor-supply.example',\n        'pricing/pallets',\n        'Pallet fees from 1 October',\n        'From 1 October, bulk orders are billed a flat fee of 18.40 EUR per pallet.'\n      ),\n    ],\n    cited: ['news', 'coverage', 'fees'],\n    followUps: ['old-tiers', 'harbourline'],\n  },\n];\n\nconst FIRST_ANSWER = ANSWERS[0];\n\nfunction answerOf(id: AnswerId) {\n  const answer = ANSWERS.find((candidate) => candidate.id === id);\n  return answer ?? FIRST_ANSWER;\n}\n\nfunction citedSourcesOf(answer: CannedAnswer) {\n  const cited: AnswerSource[] = [];\n  for (const id of answer.cited) {\n    const match = answer.found.find((candidate) => candidate.id === id);\n    if (match) {\n      cited.push(match);\n    }\n  }\n  return cited;\n}\n\nconst PLAN: PlanStep[] = [\n  { id: 'search', title: 'Searched the web', status: 'upcoming' },\n  { id: 'read', title: 'Read the sources', status: 'upcoming' },\n  { id: 'write', title: 'Wrote the answer', status: 'upcoming' },\n];\n\nconst IDLE_RUN = emptyRun({ id: 'answer', title: 'Answer', plan: PLAN });\n\nconst START_MS = 400;\n\nconst SOURCE_MS = 280;\n\nconst READ_MS = 900;\n\nconst TOKEN_MS = 55;\n\nfunction stepEvent(id: string, status: StepStatus): RunEvent {\n  return { type: 'step.updated', id, patch: { status } };\n}\n\nfunction scriptOf(answer: CannedAnswer): RunScriptStep[] {\n  const tokens = tokensOf(answer.text);\n  const steps: RunScriptStep[] = [\n    { after: START_MS, event: { type: 'run.started' } },\n    { after: 0, event: stepEvent('search', 'running') },\n  ];\n  for (const found of answer.found) {\n    steps.push({\n      after: SOURCE_MS,\n      event: {\n        type: 'call.started',\n        call: {\n          id: found.id,\n          tool: 'web_search',\n          label: found.title,\n          status: 'done',\n        },\n      },\n    });\n  }\n  steps.push({ after: 200, event: stepEvent('search', 'done') });\n  steps.push({ after: 0, event: stepEvent('read', 'running') });\n  steps.push({ after: READ_MS, event: stepEvent('read', 'done') });\n  steps.push({ after: 0, event: stepEvent('write', 'running') });\n  for (let index = 0; index < tokens.length; index += 1) {\n    steps.push({\n      after: TOKEN_MS,\n      event: { type: 'usage.added', outputTokens: 1 },\n    });\n  }\n  steps.push({ after: 0, event: stepEvent('write', 'done') });\n  steps.push({ after: 0, event: { type: 'run.finished', status: 'done' } });\n  return steps;\n}\n\nfunction scriptsOf(answers: CannedAnswer[]) {\n  const scripts = new Map<AnswerId, RunScriptStep[]>();\n  for (const answer of answers) {\n    scripts.set(answer.id, scriptOf(answer));\n  }\n  return scripts;\n}\n\nconst SCRIPTS = scriptsOf(ANSWERS);\n\nconst EMPTY_SCRIPT: RunScriptStep[] = [];\n\nfunction scriptFor(id: AnswerId) {\n  return SCRIPTS.get(id) ?? EMPTY_SCRIPT;\n}\n\nfunction tokensByAnswerOf(answers: CannedAnswer[]) {\n  const tokens = new Map<AnswerId, AnswerToken[]>();\n  for (const answer of answers) {\n    tokens.set(answer.id, tokensOf(answer.text));\n  }\n  return tokens;\n}\n\nconst TOKENS = tokensByAnswerOf(ANSWERS);\n\nconst NO_TOKENS: AnswerToken[] = [];\n\nfunction tokensFor(id: AnswerId) {\n  return TOKENS.get(id) ?? NO_TOKENS;\n}\n\nconst FOLLOW_UP_ORDER: AnswerId[] = ['old-tiers', 'harbourline', 'announced'];\n\nconst FILL_WORD_MS = 70;\n\nconst SEND_PAUSE_MS = 350;\n\ntype Turn = {\n  id: string;\n  question: string;\n  answerId: AnswerId;\n};\n\ntype TurnRecord = Turn & {\n  stage: AnswerStage;\n  plan: PlanStep[];\n  foundCount: number;\n  shownTokens: number;\n};\n\nfunction answerIdForQuestion(question: string, asked: AnswerId[]): AnswerId {\n  const cleaned = question.trim();\n  const trimmed = cleaned.toLowerCase();\n  const match = ANSWERS.find(\n    (answer) => answer.question.toLowerCase() === trimmed\n  );\n  if (match) {\n    return match.id;\n  }\n  const unasked = FOLLOW_UP_ORDER.find((id) => !asked.includes(id));\n  return unasked ?? FOLLOW_UP_ORDER[0];\n}\n\nfunction recordOf(turn: Turn, run: RunSnapshot): TurnRecord {\n  const stage = stageOf(run);\n  const isFinished = stage === 'done';\n  const tokens = tokensFor(turn.answerId);\n  const shownTokens = finishedCountOf(isFinished, tokens.length, run);\n  return {\n    ...turn,\n    stage,\n    plan: run.plan,\n    foundCount: run.calls.length,\n    shownTokens,\n  };\n}\n\nfunction finishedCountOf(isFinished: boolean, total: number, run: RunSnapshot) {\n  if (isFinished) {\n    return total;\n  }\n  return run.usage.outputTokens;\n}\n\nfunction announcementOf(stage: AnswerStage, notice: string) {\n  if (notice !== '') {\n    return notice;\n  }\n  if (stage === 'searching') {\n    return 'Searching sources';\n  }\n  if (stage === 'writing') {\n    return 'Writing the answer';\n  }\n  if (stage === 'done') {\n    return 'Answer ready';\n  }\n  if (stage === 'stopped') {\n    return 'Answer stopped';\n  }\n  return '';\n}\n\nfunction isAnsweringStage(stage: AnswerStage) {\n  const isDone = stage === 'done';\n  const isStopped = stage === 'stopped';\n  const isSettled = isDone || isStopped;\n  return !isSettled;\n}\n\nfunction isSendKey(event: React.KeyboardEvent<HTMLTextAreaElement>) {\n  const isEnter = event.key === 'Enter';\n  const isNewLine = event.shiftKey;\n  const isComposing = event.nativeEvent.isComposing;\n  const isPlainEnter = isEnter && !isNewLine;\n  return isPlainEnter && !isComposing;\n}\n\nfunction StopGlyph() {\n  return (\n    <span aria-hidden='true' className='size-2.5 rounded-[2px] bg-current' />\n  );\n}\n\ntype ComposerProps = {\n  value: string;\n  onChange: (value: string) => void;\n  onSend: () => void;\n  onStop: () => void;\n  isAnswering: boolean;\n  isFilling: boolean;\n};\n\nfunction Composer({\n  value,\n  onChange,\n  onSend,\n  onStop,\n  isAnswering,\n  isFilling,\n}: ComposerProps) {\n  const hasText = value.trim().length > 0;\n  const canSend = hasText && !isFilling;\n  const canPress = isAnswering || canSend;\n  const keyLabel = composerKeyLabelOf(isAnswering);\n\n  function handleKeys(event: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (!isSendKey(event)) {\n      return;\n    }\n    event.preventDefault();\n    if (isAnswering) {\n      return;\n    }\n    onSend();\n  }\n\n  function press() {\n    if (isAnswering) {\n      onStop();\n      return;\n    }\n    onSend();\n  }\n\n  return (\n    <div className='bg-card hairline-ring ring-foreground/12 dark:ring-foreground/15 has-[textarea:focus]:ring-ring/25 flex items-end gap-1.5 rounded-xl p-1.5 shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-shadow duration-150 has-[textarea:focus]:ring-[3px]'>\n      <Textarea\n        rows={1}\n        value={value}\n        onChange={(event) => onChange(event.target.value)}\n        onKeyDown={handleKeys}\n        readOnly={isFilling}\n        aria-label='Ask a follow-up'\n        placeholder='Ask a follow-up'\n        className='min-h-8 border-0 bg-transparent px-2 py-1.5 shadow-none ring-0 focus-visible:ring-0 dark:bg-transparent'\n      />\n      <Button\n        type='button'\n        size='icon'\n        aria-label={keyLabel}\n        disabled={!canPress}\n        onClick={press}\n      >\n        <Swap swapKey={String(isAnswering)}>\n          {isAnswering && <StopGlyph />}\n          {!isAnswering && <ArrowUpIcon />}\n        </Swap>\n      </Button>\n    </div>\n  );\n}\n\nfunction composerKeyLabelOf(isAnswering: boolean) {\n  if (isAnswering) {\n    return 'Stop the answer';\n  }\n  return 'Send the question';\n}\n\nfunction QuestionBubble({ question }: { question: string }) {\n  return (\n    <div className='flex justify-end'>\n      <p className='bg-foreground/[0.05] dark:bg-foreground/[0.07] max-w-[85%] rounded-xl px-3.5 py-2 text-sm leading-relaxed text-balance'>\n        {question}\n      </p>\n    </div>\n  );\n}\n\nfunction EarlierTurn({ record }: { record: TurnRecord }) {\n  const [isOpen, setIsOpen] = useState(false);\n  const panelId = useId();\n  const answer = answerOf(record.answerId);\n  const tokens = tokensFor(record.answerId);\n  const sources = citedSourcesOf(answer);\n  const noun = nounOf(sources.length, 'source');\n  const isStopped = record.stage === 'stopped';\n  const hasText = record.shownTokens > 0;\n\n  return (\n    <div className='flex flex-col'>\n      <button\n        type='button'\n        aria-expanded={isOpen}\n        aria-controls={panelId}\n        onClick={() => setIsOpen((current) => !current)}\n        className='hover:bg-foreground/[0.03] focus-visible:ring-ring/25 flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-2 text-start text-[13px] outline-none focus-visible:ring-[3px]'\n      >\n        <span className='min-w-0 flex-1 truncate'>{record.question}</span>\n        <span className='text-muted-foreground shrink-0 text-xs'>\n          {isStopped && 'Stopped'}\n          {!isStopped && `${sources.length} ${noun}`}\n        </span>\n        <ChevronDownIcon\n          className={cn(\n            'text-muted-foreground size-3.5 shrink-0 transition-transform duration-200 ease-(--ease-out-strong)',\n            chevronTurnOf(isOpen)\n          )}\n        />\n      </button>\n      <Fold open={isOpen} id={panelId}>\n        <div className='flex flex-col gap-3 px-2 pt-2 pb-3'>\n          <QuestionBubble question={record.question} />\n          {hasText && (\n            <CitedAnswer\n              tokens={tokens}\n              sources={sources}\n              shownTokens={record.shownTokens}\n            />\n          )}\n          {!hasText && (\n            <p className='text-muted-foreground text-[13px]'>\n              Stopped before the answer started\n            </p>\n          )}\n        </div>\n      </Fold>\n    </div>\n  );\n}\n\nexport default function Answer01() {\n  const [turn, setTurn] = useState<Turn>({\n    id: 'turn-0',\n    question: FIRST_ANSWER.question,\n    answerId: FIRST_ANSWER.id,\n  });\n  const [earlier, setEarlier] = useState<TurnRecord[]>([]);\n  const [asked, setAsked] = useState<AnswerId[]>([FIRST_ANSWER.id]);\n  const [isStopped, setIsStopped] = useState(false);\n  const [draft, setDraft] = useState('');\n  const [isFilling, setIsFilling] = useState(false);\n  const [feedback, setFeedback] = useState<Feedback | null>(null);\n  const [notice, setNotice] = useState('');\n  const [turnCount, setTurnCount] = useState(1);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const isInView = useInView(rootRef, { amount: 0.3 });\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const rowPresence = rowPresenceOf(shouldReduceMotion);\n  const { schedule, clearAll } = useTimeouts();\n  const isActive = isInView && !isStopped;\n  const { snapshot, store, restart } = useRunScript(scriptFor(turn.answerId), {\n    initial: IDLE_RUN,\n    isActive,\n  });\n  const answer = answerOf(turn.answerId);\n  const tokens = tokensFor(turn.answerId);\n  const cited = citedSourcesOf(answer);\n  const stage = stageOf(snapshot);\n  const found = answer.found.slice(0, snapshot.calls.length);\n  const isDone = stage === 'done';\n  const isStoppedStage = stage === 'stopped';\n  const isSettled = isDone || isStoppedStage;\n  const isAnswering = isAnsweringStage(stage);\n  const isWriting = stage === 'writing';\n  const shownTokens = finishedCountOf(isDone, tokens.length, snapshot);\n  const hasAnswerText = shownTokens > 0;\n  const hasEarlier = earlier.length > 0;\n  const followUps = answer.followUps.filter((id) => !asked.includes(id));\n  const hasFollowUps = followUps.length > 0;\n  const showsFollowUps = isDone && hasFollowUps;\n  const announcement = announcementOf(stage, notice);\n  const copyText = copyTextOf(tokens, cited);\n\n  function ask(question: string) {\n    const trimmed = question.trim();\n    if (trimmed === '') {\n      return;\n    }\n    const answerId = answerIdForQuestion(trimmed, asked);\n    const record = recordOf(turn, store.getSnapshot());\n    setEarlier((current) => [...current, record]);\n    setTurn({ id: `turn-${turnCount}`, question: trimmed, answerId });\n    setTurnCount((current) => current + 1);\n    setAsked((current) => [...current, answerId]);\n    setDraft('');\n    setIsStopped(false);\n    setFeedback(null);\n    setNotice('');\n    restart();\n  }\n\n  function fillAndAsk(question: string) {\n    if (isFilling) {\n      return;\n    }\n    clearAll();\n    const words = wordsOf(question);\n    setIsFilling(true);\n    setDraft('');\n    words.forEach((word, index) => {\n      const delay = (index + 1) * FILL_WORD_MS;\n      schedule(() => setDraft((current) => appendWord(current, word)), delay);\n    });\n    const typingTime = (words.length + 1) * FILL_WORD_MS;\n    const sendAt = typingTime + SEND_PAUSE_MS;\n    schedule(() => {\n      setIsFilling(false);\n      ask(question);\n    }, sendAt);\n  }\n\n  function send() {\n    if (isAnswering) {\n      return;\n    }\n    ask(draft);\n  }\n\n  function stop() {\n    const running = snapshot.plan.find((step) => step.status === 'running');\n    if (running) {\n      store.dispatch(stepEvent(running.id, 'skipped'));\n    }\n    store.dispatch({ type: 'run.finished', status: 'cancelled' });\n    setIsStopped(true);\n  }\n\n  function regenerate() {\n    setIsStopped(false);\n    setFeedback(null);\n    setNotice('');\n    restart();\n  }\n\n  function changeFeedback(next: Feedback | null) {\n    setFeedback(next);\n    if (next === 'good') {\n      setNotice('Marked as a good answer');\n      return;\n    }\n    if (next === 'bad') {\n      setNotice('Marked as a bad answer');\n      return;\n    }\n    setNotice('Feedback removed');\n  }\n\n  useKeptHeight(rootRef);\n\n  return (\n    <div\n      ref={rootRef}\n      className='bg-card hairline-ring ring-border flex w-full max-w-[36rem] flex-col gap-4 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n    >\n      {hasEarlier && (\n        <ul aria-label='Earlier questions' className='-mx-2 flex flex-col'>\n          <AnimatePresence initial={false}>\n            {earlier.map((record) => (\n              <motion.li\n                key={record.id}\n                className='overflow-hidden'\n                {...rowPresence}\n              >\n                <EarlierTurn record={record} />\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n      )}\n\n      <div className='relative'>\n        <AnimatePresence initial={false} mode='popLayout'>\n          <motion.div\n            key={turn.id}\n            className='flex flex-col gap-3'\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: 0.2 }}\n          >\n            <QuestionBubble question={turn.question} />\n            <div className='flex items-start gap-2.5'>\n              <LabTile lab='anthropic' className='mt-1' />\n              <div className='flex min-w-0 flex-1 flex-col gap-2'>\n                <AnswerSteps\n                  plan={snapshot.plan}\n                  query={answer.query}\n                  found={found}\n                  citeCount={cited.length}\n                  stage={stage}\n                />\n                <AnimatedSize>\n                  {hasAnswerText && (\n                    <CitedAnswer\n                      tokens={tokens}\n                      sources={cited}\n                      shownTokens={shownTokens}\n                      isStreaming={isWriting}\n                    />\n                  )}\n                </AnimatedSize>\n                <AnimatePresence initial={false}>\n                  {isSettled && (\n                    <motion.div\n                      key='actions'\n                      className='overflow-hidden'\n                      {...rowPresence}\n                    >\n                      <AnswerActions\n                        copyText={copyText}\n                        feedback={feedback}\n                        onFeedback={changeFeedback}\n                        onRegenerate={regenerate}\n                        onCopied={() => setNotice('Answer copied')}\n                      />\n                    </motion.div>\n                  )}\n                </AnimatePresence>\n              </div>\n            </div>\n          </motion.div>\n        </AnimatePresence>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {showsFollowUps && (\n          <motion.div\n            key={`follow-ups-${turn.id}`}\n            className='overflow-hidden'\n            {...rowPresence}\n          >\n            <div className='flex flex-col gap-2'>\n              <p className='text-muted-foreground text-xs'>Ask next</p>\n              <ul className='flex flex-wrap gap-1.5'>\n                {followUps.map((id) => {\n                  const followUp = answerOf(id);\n                  return (\n                    <li key={id} className='max-w-full'>\n                      <Button\n                        type='button'\n                        variant='outline'\n                        size='sm'\n                        disabled={isFilling}\n                        onClick={() => fillAndAsk(followUp.question)}\n                        className='max-w-full'\n                      >\n                        <span className='truncate'>{followUp.question}</span>\n                      </Button>\n                    </li>\n                  );\n                })}\n              </ul>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <Composer\n        value={draft}\n        onChange={setDraft}\n        onSend={send}\n        onStop={stop}\n        isAnswering={isAnswering}\n        isFilling={isFilling}\n      />\n      <LiveRegion message={announcement} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/answer/answer-01.tsx"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/copy-action.tsx",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\nimport { CheckIcon, CopyIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button, type ButtonProps } from '@/registry/ndeko/ui/button';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\n\nconst COPIED_FOR_MS = 1500;\n\nfunction accessibleLabelOf(isCopied: boolean, label: string) {\n  if (isCopied) {\n    return 'Copied';\n  }\n  return label;\n}\n\ntype CopyActionProps = Omit<ButtonProps, 'children'> & {\n  text: string;\n  /** What the button copies, as an action: \"Copy the answer\". */\n  label: string;\n  onCopied?: () => void;\n};\n\n/** A copy icon button that passes trigger props through, so a tooltip row can wrap it. */\nexport function CopyAction({\n  text,\n  label,\n  onCopied,\n  onClick,\n  className,\n  ...props\n}: CopyActionProps) {\n  const [isCopied, setIsCopied] = useState(false);\n  const { schedule } = useTimeouts();\n  const accessibleLabel = accessibleLabelOf(isCopied, label);\n  const glyphKey = String(isCopied);\n\n  async function copy() {\n    try {\n      await navigator.clipboard.writeText(text);\n    } catch {\n      return;\n    }\n    setIsCopied(true);\n    onCopied?.();\n    schedule(() => setIsCopied(false), COPIED_FOR_MS);\n  }\n\n  const handleClick: ButtonProps['onClick'] = (event) => {\n    onClick?.(event);\n    void copy();\n  };\n\n  return (\n    <Button\n      {...props}\n      type='button'\n      variant='ghost'\n      size='icon-sm'\n      aria-label={accessibleLabel}\n      onClick={handleClick}\n      className={cn('text-muted-foreground', className)}\n    >\n      <Swap swapKey={glyphKey}>\n        {isCopied && <CheckIcon className='size-3.5' />}\n        {!isCopied && <CopyIcon className='size-3.5' />}\n      </Swap>\n    </Button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/copy-action.tsx"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/lab-tile.tsx",
      "content": "import { BrandTile, type BrandTileSize } from '@/registry/ndeko/ui/brand-tile';\n\nimport { labOf, type LabId } from './labs';\n\ntype LabTileProps = {\n  lab: LabId;\n  size?: BrandTileSize;\n  className?: string;\n};\n\nexport function LabTile({ lab, size = 'md', className }: LabTileProps) {\n  const entry = labOf(lab);\n\n  return <BrandTile brand={entry.brand} size={size} className={className} />;\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/lab-tile.tsx"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/labs.ts",
      "content": "import type { BrandSlug } from '@/registry/ndeko/lib/brand-logos';\n\nexport type LabId = 'anthropic' | 'openai' | 'google';\n\nexport type Lab = {\n  id: LabId;\n  name: string;\n  brand: BrandSlug;\n};\n\nexport const LABS: Lab[] = [\n  { id: 'anthropic', name: 'Anthropic', brand: 'claude' },\n  { id: 'openai', name: 'OpenAI', brand: 'openai' },\n  { id: 'google', name: 'Google', brand: 'gemini' },\n];\n\nconst FALLBACK_LAB = LABS[0];\n\nexport function labOf(id: LabId) {\n  const lab = LABS.find((candidate) => candidate.id === id);\n  return lab ?? FALLBACK_LAB;\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/labs.ts"
    }
  ],
  "categories": [
    "components",
    "answer"
  ],
  "type": "registry:component"
}