{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "virtualize",
  "title": "Virtualize",
  "description": "Typed virtualization with measurement strategies, incremental caching, and scroll correction.",
  "files": [
    {
      "path": "lib/virtualize/types.ts",
      "content": "export type MeasureStrategy =\n  | FixedStrategy\n  | CappedStrategy\n  | ComputedStrategy\n\n// Height is always the same regardless of content or width.\n// Examples: buttons, pills, status indicators, headers.\nexport type FixedStrategy = {\n  kind: 'fixed'\n  height: number\n}\n\n// Content scrolls inside a capped container.\n// Height is min(estimatedContent, maxHeight).\n// Examples: JSON preview, log viewer, reasoning block.\nexport type CappedStrategy = {\n  kind: 'capped'\n  maxHeight: number\n  // If you can estimate content height cheaply (e.g. lineCount * lineHeight),\n  // provide it. Otherwise the cap is used as the height.\n  estimateContent?: number\n}\n\n// Height is deterministic from props — pure arithmetic, no DOM.\n// Examples: list of N items × row height, grid of N columns.\nexport type ComputedStrategy = {\n  kind: 'computed'\n  measure: (width: number) => number\n}\n\n// --- Virtualisable item ---\n\nexport type VirtualItem<T = unknown> = {\n  id: string | number\n  strategy: MeasureStrategy\n  data: T\n}\n\n// --- Resolved item (after measurement) ---\n\nexport type PositionedItem<T = unknown> = {\n  id: string | number\n  data: T\n  height: number\n  y: number\n}\n",
      "type": "registry:lib",
      "target": "lib/virtualize/types.ts"
    },
    {
      "path": "lib/virtualize/measure.ts",
      "content": "import type { MeasureStrategy, VirtualItem, PositionedItem } from './types'\n\n/**\n * Resolve a measurement strategy to a pixel height.\n */\nexport function resolveHeight(strategy: MeasureStrategy, width: number): number {\n  switch (strategy.kind) {\n    case 'fixed':\n      return strategy.height\n    case 'capped':\n      return strategy.estimateContent !== undefined\n        ? Math.min(strategy.estimateContent, strategy.maxHeight)\n        : strategy.maxHeight\n    case 'computed':\n      return strategy.measure(width)\n  }\n}\n\n/**\n * Resolve all items to positioned items with y offsets.\n * Pure arithmetic — no DOM, no side effects.\n */\nexport function positionItems<T>(\n  items: VirtualItem<T>[],\n  width: number,\n  gap: number = 0,\n  heightOverrides?: Map<string | number, number>,\n  strategyFallback?: Map<string | number, number>,\n): { positioned: PositionedItem<T>[]; totalHeight: number } {\n  const positioned: PositionedItem<T>[] = []\n  let y = 0\n\n  for (let i = 0; i < items.length; i++) {\n    const item = items[i]!\n    const height = heightOverrides?.get(item.id)\n      ?? strategyFallback?.get(item.id)\n      ?? resolveHeight(item.strategy, width)\n    positioned.push({ id: item.id, data: item.data, height, y })\n    y += height\n    if (i < items.length - 1) y += gap\n  }\n\n  return { positioned, totalHeight: y }\n}\n\n/**\n * Re-resolve a single item's height (e.g. after expand/collapse)\n * and recompute y offsets for all items from that index onward.\n * Returns the height delta for scroll position correction.\n */\nexport function updateItemHeight<T>(\n  positioned: PositionedItem<T>[],\n  index: number,\n  newHeight: number,\n  gap: number = 0,\n): number {\n  const old = positioned[index]!\n  const delta = newHeight - old.height\n  if (delta === 0) return 0\n\n  old.height = newHeight\n\n  // Recompute y offsets from index+1 onward\n  for (let i = index + 1; i < positioned.length; i++) {\n    positioned[i]!.y += delta\n  }\n\n  return delta\n}\n",
      "type": "registry:lib",
      "target": "lib/virtualize/measure.ts"
    },
    {
      "path": "lib/virtualize/use-virtualized-list.ts",
      "content": "'use client'\n\nimport { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react'\nimport { flushSync } from 'react-dom'\nimport type { VirtualItem, PositionedItem, MeasureStrategy } from './types'\nimport { positionItems, resolveHeight } from './measure'\nimport { useVirtualize, useScrollState } from '@/hooks/use-virtualize'\n\nexport type VirtualizedListResult<T> = {\n  items: PositionedItem<T>[]\n  totalHeight: number\n  renderedCount: number\n  totalCount: number\n  topSpacer: number\n  bottomSpacer: number\n  scrollRef: (el: HTMLDivElement | null) => void\n  /** Stable ref callback per item id. Attach to each item's wrapper element. */\n  getItemRef: (id: string | number) => (el: HTMLElement | null) => void\n}\n\nexport function useVirtualizedList<T>(\n  items: VirtualItem<T>[],\n  viewportHeight: number,\n  width: number,\n  gap: number = 0,\n): VirtualizedListResult<T> {\n  const scroll = useScrollState()\n  const getScrollEl = scroll.getElement\n\n  // --- Height tracking ---\n  // heightCache: RO-measured DOM heights, used by positionItems for accurate spacers.\n  // baselineCache: first measured height per mount (= collapsed state).\n  //\n  // Why baseline exists: when a user expands a collapsible (e.g. reasoning block),\n  // scrolls away (item unmounts), then scrolls back (item remounts collapsed),\n  // the spacer must reflect the collapsed height — not the expanded height.\n  // On unmount, we reset heightCache to baseline so spacers match remount state.\n  //\n  // Why scheduleFlush exists: on unmount of an expanded item above the viewport,\n  // we need to update spacers AND correct scrollTop atomically. scheduleFlush\n  // queues a microtask (runs before paint) that flushSync's a re-render to update\n  // spacers, then useLayoutEffect applies the scrollTop correction — all before\n  // the browser paints. This only fires on the rare expand→scroll-away path.\n  const heightCache = useRef(new Map<string | number, number>())\n  const baselineCache = useRef(new Map<string | number, number>())\n\n  const [version, setVersion] = useState(0)\n\n  // Normal path: rAF-batched version bump (cheap, max 1 re-render per frame)\n  const rafScheduled = useRef(false)\n  function scheduleVersionBump() {\n    if (rafScheduled.current) return\n    rafScheduled.current = true\n    requestAnimationFrame(() => {\n      rafScheduled.current = false\n      setVersion(v => v + 1)\n    })\n  }\n\n  // Expand→unmount path: microtask + flushSync for atomic spacer + scroll correction.\n  // useLayoutEffect skips while flushPending=true (spacers stale), applies after\n  // the flushSync re-render clears the flag (spacers correct).\n  const pendingCorrection = useRef(0)\n  const flushPending = useRef(false)\n\n  function scheduleFlush() {\n    if (flushPending.current) return\n    flushPending.current = true\n    queueMicrotask(() => {\n      flushSync(() => {\n        flushPending.current = false\n        setVersion(v => v + 1)\n      })\n    })\n  }\n\n  useLayoutEffect(() => {\n    if (pendingCorrection.current !== 0 && !flushPending.current) {\n      const el = getScrollEl()\n      if (el) el.scrollTop += pendingCorrection.current\n      pendingCorrection.current = 0\n    }\n  })\n\n  // On width change: clear RO caches (stale at new width) and save scroll anchor\n  // so we can restore position after recomputation.\n  const prevWidth = useRef(width)\n  const scrollAnchor = useRef<{ itemId: string | number; offset: number } | null>(null)\n  const prevPositioned = useRef<PositionedItem<T>[]>([])\n  if (prevWidth.current !== width) {\n    heightCache.current.clear()\n    baselineCache.current.clear()\n    // Save anchor: which item is at viewport top + pixel offset into that item\n    const scrollEl = getScrollEl()\n    const prev = prevPositioned.current\n    if (scrollEl && prev.length > 0) {\n      const st = scrollEl.scrollTop\n      for (const p of prev) {\n        if (p.y + p.height > st) {\n          scrollAnchor.current = { itemId: p.id, offset: st - p.y }\n          break\n        }\n      }\n    }\n    prevWidth.current = width\n  }\n\n  // Precompute strategy heights incrementally.\n  // Cache by (id, strategy ref, width) — only recompute when strategy or width changes.\n  // Streaming: only the changed message gets recomputed (strategy ref changed).\n  // Width change: all items recompute (width changed), but AST is pre-parsed in strategy.\n  const strategyCache = useRef(new Map<string | number, { strategy: MeasureStrategy; width: number; height: number }>())\n  const strategyHeights = useMemo(() => {\n    const m = new Map<string | number, number>()\n    const cache = strategyCache.current\n    for (const item of items) {\n      const cached = cache.get(item.id)\n      if (cached && cached.strategy === item.strategy && cached.width === width) {\n        m.set(item.id, cached.height)\n      } else {\n        const h = resolveHeight(item.strategy, width)\n        m.set(item.id, h)\n        cache.set(item.id, { strategy: item.strategy, width, height: h })\n      }\n    }\n    return m\n  }, [items, width])\n\n  // Position items using cached heights → strategy fallback.\n  const { positioned, totalHeight } = useMemo(\n    () => positionItems(items, width, gap, heightCache.current, strategyHeights),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [items, width, gap, version, strategyHeights],\n  )\n\n  // Store positioned for scroll anchor on next width change\n  prevPositioned.current = positioned\n\n  // Restore scroll position after width change\n  useLayoutEffect(() => {\n    const anchor = scrollAnchor.current\n    if (!anchor) return\n    scrollAnchor.current = null\n    const scrollEl = getScrollEl()\n    if (!scrollEl) return\n    // Find the anchored item in new positions\n    const item = positioned.find(p => p.id === anchor.itemId)\n    if (item) {\n      scrollEl.scrollTop = item.y + anchor.offset\n    }\n  }, [positioned, getScrollEl])\n\n  // Y-position lookup for \"above viewport?\" check (ref for stable callback access)\n  const yLookupRef = useRef(new Map<string | number, number>())\n  yLookupRef.current = useMemo(() => {\n    const m = new Map<string | number, number>()\n    for (const p of positioned) m.set(p.id, p.y)\n    return m\n  }, [positioned])\n\n  const virtual = useVirtualize(positioned, scroll.scrollTop, viewportHeight)\n\n  // --- Single ResizeObserver ---\n\n  const roRef = useRef<ResizeObserver | null>(null)\n  const elementToId = useRef(new Map<Element, string | number>())\n\n  useEffect(() => () => { roRef.current?.disconnect() }, [])\n\n  if (process.env.NODE_ENV === 'development' && !roRef.current) {\n    console.log('[virtualize] dev mode active')\n  }\n\n  if (!roRef.current && typeof ResizeObserver !== 'undefined') {\n    roRef.current = new ResizeObserver((entries) => {\n      let changed = false\n      for (const entry of entries) {\n        const id = elementToId.current.get(entry.target)\n        if (id === undefined) continue\n        const h = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height\n\n        if (!baselineCache.current.has(id)) {\n          baselineCache.current.set(id, h)\n        }\n\n        const prev = heightCache.current.get(id)\n        if (prev !== undefined && Math.abs(prev - h) < 1) continue\n        heightCache.current.set(id, h)\n        changed = true\n      }\n      if (changed) {\n        if (process.env.NODE_ENV === 'development') {\n          for (const entry of entries) {\n            const id = elementToId.current.get(entry.target)\n            if (id === undefined) continue\n            const h = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height\n            const strategy = strategyHeights.get(id)\n            if (strategy !== undefined && Math.abs(h - strategy) > 2) {\n              console.warn(`[virtualize] height drift: id=${id} strategy=${Math.round(strategy)} actual=${Math.round(h)} diff=${Math.round(h - strategy)}px`)\n            }\n          }\n        }\n        scheduleVersionBump()\n      }\n    })\n  }\n\n  // --- Stable ref callbacks per item id ---\n\n  const refCallbacks = useRef(new Map<string | number, (el: HTMLElement | null) => void>())\n  const itemElements = useRef(new Map<string | number, HTMLElement>())\n\n  const getItemRef = useCallback((id: string | number) => {\n    let cb = refCallbacks.current.get(id)\n    if (cb) return cb\n\n    cb = (el: HTMLElement | null) => {\n      const ro = roRef.current\n      if (el) {\n        const prev = itemElements.current.get(id)\n        if (prev === el) return\n        if (prev && ro) {\n          ro.unobserve(prev)\n          elementToId.current.delete(prev)\n        }\n        itemElements.current.set(id, el)\n        elementToId.current.set(el, id)\n        baselineCache.current.delete(id)\n        ro?.observe(el)\n      } else {\n        // Unmount\n        const prev = itemElements.current.get(id)\n        if (prev && ro) {\n          ro.unobserve(prev)\n          elementToId.current.delete(prev)\n        }\n        itemElements.current.delete(id)\n\n        // If item was expanded beyond baseline, reset + correct\n        const cached = heightCache.current.get(id)\n        const base = baselineCache.current.get(id)\n        if (cached !== undefined && base !== undefined && Math.abs(cached - base) > 1) {\n          heightCache.current.set(id, base)\n\n          // Scroll correction only if item was above viewport\n          const scrollEl = getScrollEl()\n          if (scrollEl) {\n            const itemY = yLookupRef.current.get(id) ?? 0\n            if (itemY + cached <= scrollEl.scrollTop) {\n              pendingCorrection.current += base - cached\n            }\n          }\n\n          scheduleFlush()\n        }\n      }\n    }\n\n    refCallbacks.current.set(id, cb)\n    return cb\n  }, [getScrollEl])\n\n  // Cleanup stale entries when items removed from list entirely\n  const prevItemIds = useRef(new Set<string | number>())\n  const currentIds = useMemo(() => new Set(items.map(i => i.id)), [items])\n  if (prevItemIds.current !== currentIds) {\n    for (const id of prevItemIds.current) {\n      if (!currentIds.has(id)) {\n        refCallbacks.current.delete(id)\n        heightCache.current.delete(id)\n        baselineCache.current.delete(id)\n      }\n    }\n    prevItemIds.current = currentIds\n  }\n\n  // Spacers\n  const topSpacer = virtual.items.length > 0 ? virtual.items[0]!.y : 0\n  const bottomSpacer = virtual.items.length > 0\n    ? Math.max(0, totalHeight - (virtual.items[virtual.items.length - 1]!.y + virtual.items[virtual.items.length - 1]!.height))\n    : 0\n\n  return {\n    items: virtual.items,\n    totalHeight,\n    renderedCount: virtual.renderedCount,\n    totalCount: virtual.totalCount,\n    topSpacer,\n    bottomSpacer,\n    scrollRef: scroll.refCallback,\n    getItemRef,\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/virtualize/use-virtualized-list.ts"
    },
    {
      "path": "lib/virtualize/index.ts",
      "content": "export type {\n  MeasureStrategy,\n  FixedStrategy,\n  CappedStrategy,\n  ComputedStrategy,\n  VirtualItem,\n  PositionedItem,\n} from './types'\nexport { resolveHeight, positionItems } from './measure'\nexport { useVirtualizedList } from './use-virtualized-list'\n",
      "type": "registry:lib",
      "target": "lib/virtualize/index.ts"
    },
    {
      "path": "hooks/use-virtualize.ts",
      "content": "'use client'\n\nimport { useMemo, useState, useCallback, useRef } from 'react'\n\ntype Positioned = { y: number; height: number }\n\nexport type VirtualRange<T extends Positioned> = {\n  startIndex: number\n  endIndex: number   // exclusive\n  items: T[]\n  totalHeight: number\n  renderedCount: number\n  totalCount: number\n}\n\nconst OVERSCAN = 3\n\nexport function useVirtualize<T extends Positioned>(\n  items: T[],\n  scrollTop: number,\n  viewportHeight: number,\n): VirtualRange<T> {\n  return useMemo(() => {\n    if (items.length === 0 || viewportHeight <= 0) {\n      return { startIndex: 0, endIndex: 0, items: [], totalHeight: 0, renderedCount: 0, totalCount: 0 }\n    }\n\n    const last = items[items.length - 1]!\n    const totalHeight = last.y + last.height\n\n    let start = bsearchFirst(items, scrollTop)\n    let end = bsearchLast(items, scrollTop + viewportHeight)\n\n    start = Math.max(0, start - OVERSCAN)\n    end = Math.min(items.length, end + OVERSCAN + 1)\n\n    return {\n      startIndex: start,\n      endIndex: end,\n      items: items.slice(start, end),\n      totalHeight,\n      renderedCount: end - start,\n      totalCount: items.length,\n    }\n  }, [items, scrollTop, viewportHeight])\n}\n\nfunction bsearchFirst(items: Positioned[], target: number): number {\n  let lo = 0, hi = items.length - 1\n  while (lo < hi) {\n    const mid = (lo + hi) >>> 1\n    if (items[mid]!.y + items[mid]!.height < target) lo = mid + 1\n    else hi = mid\n  }\n  return lo\n}\n\nfunction bsearchLast(items: Positioned[], target: number): number {\n  let lo = 0, hi = items.length - 1\n  while (lo < hi) {\n    const mid = (lo + hi + 1) >>> 1\n    if (items[mid]!.y > target) hi = mid - 1\n    else lo = mid\n  }\n  return lo\n}\n\n/**\n * Track scroll position. Returns [scrollTop, refCallback].\n * Pass refCallback as the scroll container's ref.\n */\nexport type ScrollState = {\n  scrollTop: number\n  refCallback: (el: HTMLDivElement | null) => void\n  getElement: () => HTMLDivElement | null\n}\n\nexport function useScrollState(): ScrollState {\n  const [scrollTop, setScrollTop] = useState(0)\n  const elRef = useRef<HTMLDivElement | null>(null)\n  const handlerRef = useRef(() => {\n    if (elRef.current) setScrollTop(elRef.current.scrollTop)\n  })\n\n  const refCallback = useCallback((el: HTMLDivElement | null) => {\n    if (elRef.current) {\n      elRef.current.removeEventListener('scroll', handlerRef.current)\n    }\n    elRef.current = el\n    if (el) {\n      el.addEventListener('scroll', handlerRef.current, { passive: true })\n      setScrollTop(el.scrollTop)\n    }\n  }, [])\n\n  const getElement = useCallback(() => elRef.current, [])\n\n  return { scrollTop, refCallback, getElement }\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-virtualize.ts"
    }
  ],
  "type": "registry:block"
}