{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "dependencies": [],
  "description": "A children-only conversation viewport that preserves reading intent during content and viewport changes.",
  "files": [
    {
      "content": "\"use client\";\n\nimport { useId, useLayoutEffect, useRef, useState, type ComponentPropsWithoutRef, type Ref } from \"react\";\nimport styles from \"./conversation-viewport.module.css\";\nimport accessibility from \"./accessibility.module.css\";\n\nexport type ConversationViewportProps = Omit<ComponentPropsWithoutRef<\"section\">, \"aria-label\" | \"role\"> & {\n  announcement?: { id: string; text: string } | null;\n  initialPosition?: \"start\" | \"end\";\n  label: string;\n  onFollowingChange?: (following: boolean) => void;\n  returnLabel?: string;\n  viewportRef?: Ref<HTMLElement>;\n};\n\nfunction hasNestedScrollOwner(event: WheelEvent, viewport: HTMLElement) {\n  for (const target of event.composedPath()) {\n    if (target === viewport) break;\n    if (!(target instanceof HTMLElement)) continue;\n    const style = getComputedStyle(target);\n    if (/^(auto|scroll)$/.test(style.overflowY) &&\n      (target.scrollHeight > target.clientHeight + 1 || style.overscrollBehaviorY !== \"auto\")) return true;\n  }\n  return false;\n}\n\n/** Content stays host-owned. Key this component when switching conversations. */\nexport function ConversationViewport({\n  announcement, children, className, initialPosition = \"end\", label,\n  onFollowingChange, returnLabel = \"Jump to latest\", style, viewportRef, ...props\n}: ConversationViewportProps) {\n  const generatedId = useId();\n  const id = props.id ?? generatedId;\n  const viewport = useRef<HTMLElement | null>(null);\n  const content = useRef<HTMLDivElement | null>(null);\n  const followingRef = useRef(initialPosition === \"end\");\n  const callback = useRef(onFollowingChange);\n  const programmaticTop = useRef<number | null>(null);\n  const explicitReturn = useRef(false);\n  const lastTop = useRef(0);\n  const lastGeometry = useRef({ height: 0, scrollHeight: 0 });\n  const unclaimedForwardScroll = useRef<{ at: number; height: number; scrollHeight: number; top: number } | null>(null);\n  const navigation = useRef<{ expiresAt: number; forwardWheel: boolean; height: number; scrollHeight: number; startTop: number } | null>(null);\n  const [following, setFollowing] = useState(initialPosition === \"end\");\n  const [overflowing, setOverflowing] = useState(false);\n\n  useLayoutEffect(() => { callback.current = onFollowingChange; });\n\n  function changeFollowing(next: boolean) {\n    if (followingRef.current === next) return;\n    followingRef.current = next;\n    setFollowing(next);\n    callback.current?.(next);\n  }\n\n  function moveToEnd() {\n    const node = viewport.current;\n    if (node == null) return;\n    node.scrollTop = node.scrollHeight;\n    programmaticTop.current = node.scrollTop;\n    lastTop.current = node.scrollTop;\n    navigation.current = null;\n  }\n\n  function beginNavigation(node: HTMLElement, forwardWheel = false) {\n    explicitReturn.current = false;\n    navigation.current = { expiresAt: performance.now() + 800, forwardWheel, height: node.clientHeight, scrollHeight: node.scrollHeight, startTop: node.scrollTop };\n    programmaticTop.current = null;\n  }\n\n  useLayoutEffect(() => {\n    const node = viewport.current;\n    const inner = content.current;\n    if (node == null || inner == null) return;\n    const resize = () => {\n      if (lastGeometry.current.height === 0) lastGeometry.current = { height: node.clientHeight, scrollHeight: node.scrollHeight };\n      const intent = navigation.current;\n      if (intent && (intent.height !== node.clientHeight || intent.scrollHeight !== node.scrollHeight)) navigation.current = null;\n      setOverflowing(node.scrollHeight > node.clientHeight + 1);\n      if (followingRef.current) moveToEnd();\n    };\n    resize();\n    const observer = new ResizeObserver(resize);\n    observer.observe(node);\n    observer.observe(inner);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <div className={[styles.root, className].filter(Boolean).join(\" \")} data-following={following ? \"true\" : \"false\"} data-hui-part=\"conversation-root\" style={style}>\n      {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- A focusable scroll region tracks native navigation without pretending to be an application widget. */}\n      <section\n        {...props}\n        aria-label={label}\n        className={styles.viewport}\n        data-hui-part=\"conversation-viewport\"\n        id={id}\n        onFocus={event => {\n          props.onFocus?.(event);\n          if (event.target !== event.currentTarget) explicitReturn.current = false;\n        }}\n        onKeyDown={event => {\n          props.onKeyDown?.(event);\n          if (event.defaultPrevented || event.target !== event.currentTarget) return;\n          if ([\"ArrowUp\", \"ArrowDown\", \"PageUp\", \"PageDown\", \"Home\", \"End\", \" \"].includes(event.key)) beginNavigation(event.currentTarget);\n          if ([\"ArrowUp\", \"PageUp\", \"Home\"].includes(event.key)) changeFollowing(false);\n        }}\n        onPointerDown={event => {\n          props.onPointerDown?.(event);\n          if (!event.defaultPrevented) beginNavigation(event.currentTarget);\n        }}\n        onPointerMove={event => {\n          props.onPointerMove?.(event);\n          if (!event.defaultPrevented && event.buttons !== 0 && navigation.current != null) navigation.current.expiresAt = performance.now() + 800;\n        }}\n        onScroll={event => {\n          props.onScroll?.(event);\n          const node = event.currentTarget;\n          const top = node.scrollTop;\n          // ponytail: Chromium can keep delivering an earlier native keyboard\n          // scroll after a jump, even with behavior: \"instant\". Preserve the\n          // explicit return until fresh input takes ownership; a one-time\n          // scroll assignment lets the old animation detach the reader again.\n          if (explicitReturn.current && node.scrollHeight - node.clientHeight - top > 2) {\n            moveToEnd();\n            return;\n          }\n          const geometryChanged = lastGeometry.current.height !== node.clientHeight || lastGeometry.current.scrollHeight !== node.scrollHeight;\n          lastGeometry.current = { height: node.clientHeight, scrollHeight: node.scrollHeight };\n          if (programmaticTop.current != null && Math.abs(top - programmaticTop.current) < 1) {\n            programmaticTop.current = null;\n            lastTop.current = top;\n            return;\n          }\n          const previousTop = lastTop.current;\n          const moved = Math.abs(top - previousTop) > 0.5;\n          lastTop.current = top;\n          unclaimedForwardScroll.current = !geometryChanged && top > previousTop + 0.5\n            ? { at: performance.now(), height: node.clientHeight, scrollHeight: node.scrollHeight, top }\n            : null;\n          const atEnd = node.scrollHeight - node.clientHeight - top <= 2;\n          const intent = navigation.current;\n          // ponytail: geometry seen by the last scroll can predate a fresh\n          // wheel/End after detached output grows. An input snapshot plus\n          // forward movement proves that return; a timer alone also admits\n          // browser clamping. Do not overwrite this evidence during resize.\n          const deliberateReturn = intent != null && performance.now() <= intent.expiresAt &&\n            intent.height === node.clientHeight && intent.scrollHeight === node.scrollHeight &&\n            (top > intent.startTop + 0.5 || (intent.forwardWheel && top > previousTop + 0.5));\n          if (moved && atEnd && deliberateReturn) {\n            navigation.current = null;\n            changeFollowing(true);\n            return;\n          }\n          if (!moved || geometryChanged) return;\n          if (!atEnd) changeFollowing(false);\n        }}\n        onWheel={event => {\n          props.onWheel?.(event);\n          if (event.defaultPrevented || event.deltaY === 0) return;\n          const node = event.currentTarget;\n          const precedingScroll = unclaimedForwardScroll.current;\n          unclaimedForwardScroll.current = null;\n          beginNavigation(node, event.deltaY > 0);\n          // ponytail: a tool result's wheel bubbles even when it consumes or\n          // contains the gesture. Only actual outer scrolling proves chaining;\n          // inspecting its post-compositor boundary would mistake consumption\n          // that just reached an edge for a second, outer navigation intent.\n          if (hasNestedScrollOwner(event.nativeEvent, node)) {\n            // Firefox can deliver the actual outer scroll before its passive\n            // wheel. Reconcile only an unclaimed forward movement at identical\n            // geometry; a clamped tail or an inner-only scroll is insufficient.\n            if (event.deltaY > 0 && precedingScroll != null && performance.now() - precedingScroll.at < 150 &&\n              precedingScroll.height === node.clientHeight && precedingScroll.scrollHeight === node.scrollHeight &&\n              precedingScroll.top === node.scrollTop && node.scrollHeight - node.clientHeight - node.scrollTop <= 2) changeFollowing(true);\n            return;\n          }\n          if (event.deltaY < 0 && overflowing) changeFollowing(false);\n          // ponytail: passive wheel delivery can arrive AFTER compositor\n          // scrolling. In that case the sampled top is already the tail;\n          // requiring another positive delta loses a real one-shot return.\n          // An explicit downward wheel at the tail is itself a resume choice.\n          if (event.deltaY > 0 && node.scrollHeight - node.clientHeight - node.scrollTop <= 2) {\n            navigation.current = null;\n            changeFollowing(true);\n          }\n        }}\n        ref={element => {\n          viewport.current = element;\n          if (typeof viewportRef === \"function\") return viewportRef(element);\n          if (viewportRef != null) viewportRef.current = element;\n        }}\n        tabIndex={props.tabIndex ?? 0}\n      >\n        <div className={styles.content} data-hui-part=\"conversation-content\" ref={content}>{children}</div>\n      </section>\n      {!following && overflowing ? (\n        <button\n          aria-controls={id}\n          className={styles.returnButton}\n          data-hui-part=\"conversation-return\"\n          onClick={() => {\n            explicitReturn.current = true;\n            changeFollowing(true);\n            moveToEnd();\n            viewport.current?.focus({ preventScroll: true });\n          }}\n          type=\"button\"\n        >{returnLabel}</button>\n      ) : null}\n      <output aria-live=\"polite\" className={accessibility.visuallyHidden}>{announcement ? <span key={announcement.id}>{announcement.text}</span> : null}</output>\n    </div>\n  );\n}\n",
      "path": "src/components/heidi-agent-ui/conversation-viewport.tsx",
      "target": "components/heidi-agent-ui/conversation-viewport.tsx",
      "type": "registry:file"
    },
    {
      "content": ".root { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; overflow: hidden; }\n.viewport {\n  box-sizing: border-box;\n  min-height: 0;\n  min-width: 0;\n  flex: 1 1 auto;\n  overflow: auto;\n  overflow-anchor: none;\n  overscroll-behavior: contain;\n  scrollbar-gutter: stable;\n  scroll-behavior: auto;\n  padding: 1.5rem;\n}\n.viewport:focus-visible { outline: 2px solid var(--hui-color-focus-ring, Highlight); outline-offset: -3px; border-radius: 0.5rem; }\n.root[data-following=\"false\"] .viewport { overflow-anchor: auto; }\n.content { display: flow-root; min-width: 0; overflow-wrap: anywhere; }\n.content > * { min-width: 0; max-width: 100%; }\n.content :where(img, video, svg, iframe) { max-width: 100%; }\n.content :where(pre) { max-width: 100%; overflow: auto; white-space: pre; }\n.content :where(table) { display: block; max-width: 100%; overflow: auto; }\n.returnButton {\n  position: absolute;\n  bottom: 0.75rem;\n  inset-inline-end: 1rem;\n  max-width: calc(100% - 2rem);\n  min-height: 2.5rem;\n  padding: 0.5rem 0.875rem;\n  border: 1px solid var(--hui-color-border-default, color-mix(in srgb, CanvasText 18%, Canvas));\n  border-radius: 999px;\n  background: var(--hui-color-bg-elevated, Canvas);\n  color: var(--hui-color-fg-default, CanvasText);\n  box-shadow: 0 2px 8px color-mix(in srgb, CanvasText 5%, transparent);\n  font: inherit;\n  font-size: calc(var(--hui-text-body-size, 1rem) * 0.8125);\n  overflow-wrap: anywhere;\n  cursor: pointer;\n}\n.returnButton:focus-visible { outline: 2px solid var(--hui-color-focus-ring, Highlight); outline-offset: 3px; }\n@media (forced-colors: active) { .returnButton { border-color: ButtonText; box-shadow: none; } }\n",
      "path": "src/components/heidi-agent-ui/conversation-viewport.module.css",
      "target": "components/heidi-agent-ui/conversation-viewport.module.css",
      "type": "registry:file"
    },
    {
      "content": "/** GENERATED from src/components/heidi-agent-ui/conversation-viewport.module.css — do not edit by hand.\n * Rebuild: `bun run tokens:build`. */\ndeclare const styles: {\n  readonly \"content\": string;\n  readonly \"returnButton\": string;\n  readonly \"root\": string;\n  readonly \"viewport\": string;\n};\nexport default styles;\n",
      "path": "src/components/heidi-agent-ui/conversation-viewport.module.css.d.ts",
      "target": "components/heidi-agent-ui/conversation-viewport.module.css.d.ts",
      "type": "registry:file"
    },
    {
      "content": "/* Shared by independently copied blocks; no host stylesheet is required. */\n.visuallyHidden {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: nowrap;\n  border: 0;\n}\n",
      "path": "src/components/heidi-agent-ui/accessibility.module.css",
      "target": "components/heidi-agent-ui/accessibility.module.css",
      "type": "registry:file"
    },
    {
      "content": "/** GENERATED from src/components/heidi-agent-ui/accessibility.module.css — do not edit by hand.\n * Rebuild: `bun run tokens:build`. */\ndeclare const styles: {\n  readonly \"visuallyHidden\": string;\n};\nexport default styles;\n",
      "path": "src/components/heidi-agent-ui/accessibility.module.css.d.ts",
      "target": "components/heidi-agent-ui/accessibility.module.css.d.ts",
      "type": "registry:file"
    }
  ],
  "name": "heidi-conversation-viewport",
  "registryDependencies": [],
  "title": "Conversation viewport",
  "type": "registry:block"
}
