{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "conversation-map",
  "type": "registry:component",
  "title": "Conversation Map",
  "description": "Runtime-backed thread rail: a tick per turn, the turn being read marked and the ones on screen deepened, a hover preview, and a click that scrolls there.",
  "registryDependencies": [
    "https://r.assistant-ui.com/elements-conversation-map.json"
  ],
  "dependencies": [
    "@assistant-ui/react@^0.15.23"
  ],
  "files": [
    {
      "type": "registry:component",
      "path": "components/assistant-ui/elements/conversation-map.aui.tsx",
      "sourcePath": "packages/ui/src/components/react/assistant-ui/elements/conversation-map.aui.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useAuiState, useThreadViewport } from \"@assistant-ui/react\";\nimport type { ThreadMessage } from \"@assistant-ui/react\";\nimport { cn } from \"@/lib/utils\";\nimport { ConversationMap, type ConversationMapEntry } from \"./conversation-map\";\n\nconst TITLE_LENGTH = 72;\nconst PREVIEW_LENGTH = 240;\n\n/**\n * A message scrolled to the top of the viewport lands a fraction of a pixel\n * below it, which would otherwise hand the active tick to the turn before.\n */\nconst TOP_TOLERANCE = 1;\n\nconst sameIds = (a: readonly string[], b: readonly string[]): boolean => {\n  if (a.length !== b.length) return false;\n  for (let i = 0; i < a.length; i++) {\n    if (a[i] !== b[i]) return false;\n  }\n  return true;\n};\n\n/**\n * The line a message has to cross to count as the one being read. It sits at\n * the top of the viewport for most of a thread, then slides to the bottom\n * across the final screenful: a message that starts within one viewport height\n * of the end can never reach the top, so a fixed line leaves the last screen's\n * worth of ticks permanently unreachable.\n */\nconst readingLine = (viewport: HTMLElement) => {\n  const rect = viewport.getBoundingClientRect();\n  const height = viewport.clientHeight;\n  if (height <= 0) return rect.top + TOP_TOLERANCE;\n\n  const remaining = viewport.scrollHeight - height - viewport.scrollTop;\n  const descent = Math.min(1, Math.max(0, (height - remaining) / height));\n  return rect.top + rect.height * descent + TOP_TOLERANCE;\n};\n\nconst partsOf = (message: ThreadMessage) => [...message.content];\n\nconst textOf = (message: ThreadMessage) =>\n  partsOf(message)\n    .map((part) => (part.type === \"text\" ? part.text : \"\"))\n    .join(\"\\n\")\n    .trim();\n\nconst labelOf = (message: ThreadMessage) => {\n  const parts = partsOf(message);\n  const tools = parts.flatMap((part) =>\n    part.type === \"tool-call\" ? [part.toolName] : [],\n  );\n  if (tools.length === 1) return tools[0]!;\n  if (tools.length > 1) return `${tools.length} tool calls`;\n  if (parts.some((part) => part.type === \"reasoning\")) return \"Reasoning\";\n\n  // A composer submission carries its files in `attachments` and leaves\n  // `content` empty, so both places decide an attachment-only turn's label.\n  const carriers = [...parts, ...(message.attachments ?? [])];\n  if (carriers.some((carrier) => carrier.type === \"image\")) return \"Image\";\n  if (carriers.some((carrier) => carrier.type === \"file\")) return \"File\";\n  if (carriers.length > 0) return \"Attachment\";\n  return message.role === \"user\" ? \"Message\" : \"Response\";\n};\n\n/** Cuts on a word boundary so a title never splits a word. */\nconst cutAtWord = (text: string, limit: number) => {\n  if (text.length <= limit) return text;\n  const head = text.slice(0, limit);\n  const boundary = head.lastIndexOf(\" \");\n  return boundary > limit / 2 ? head.slice(0, boundary) : head;\n};\n\nconst linesOf = (message: ThreadMessage) =>\n  textOf(message)\n    .split(\"\\n\")\n    .map((line) => line.replace(/^[\\s#>*`-]+/, \"\").trim())\n    .filter(Boolean);\n\n/** A user message and the assistant messages answering it. */\ntype Turn = {\n  head: ThreadMessage;\n  members: ThreadMessage[];\n};\n\nconst groupIntoTurns = (messages: readonly ThreadMessage[]) => {\n  const turns: Turn[] = [];\n\n  for (const message of messages) {\n    if (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n    const current = turns.at(-1);\n    if (message.role === \"user\" || !current) {\n      turns.push({ head: message, members: [message] });\n      continue;\n    }\n    current.members.push(message);\n  }\n\n  return turns;\n};\n\nconst describe = ({ head, members }: Turn): ConversationMapEntry => {\n  const lines = linesOf(head);\n  const first = lines[0] ?? \"\";\n  const title = cutAtWord(first, TITLE_LENGTH);\n\n  // What the turn asked names it; what it answered is the useful preview, and\n  // a turn still being answered falls back to the rest of its own text.\n  const answer = members.find((member) => member !== head && textOf(member));\n  const preview = (\n    answer\n      ? linesOf(answer).join(\" \")\n      : [first.slice(title.length), ...lines.slice(1)].join(\" \")\n  )\n    .trim()\n    .slice(0, PREVIEW_LENGTH);\n\n  return {\n    id: head.id,\n    title: title || labelOf(head),\n    ...(preview ? { preview } : {}),\n  };\n};\n\nexport function ConversationMapAui({\n  side = \"left\",\n  className,\n}: {\n  side?: \"left\" | \"right\";\n  className?: string;\n}) {\n  const messages = useAuiState((s) => s.thread.messages);\n  const viewport = useThreadViewport((s) => s.element.viewport);\n  const viewportHeight = useThreadViewport((s) => s.height.viewport);\n  const [activeId, setActiveId] = useState<string | undefined>(undefined);\n  const [visibleIds, setVisibleIds] = useState<readonly string[]>([]);\n  const scheduleRef = useRef<(() => void) | undefined>(undefined);\n\n  const turns = useMemo(() => groupIntoTurns(messages), [messages]);\n  const entries = useMemo(() => turns.map(describe), [turns]);\n\n  /** Which turn each message belongs to, so a message in view marks its turn. */\n  const turnOf = useMemo(() => {\n    const owners = new Map<string, string>();\n    for (const turn of turns) {\n      for (const member of turn.members) owners.set(member.id, turn.head.id);\n    }\n    return owners;\n  }, [turns]);\n\n  const turnOfRef = useRef(turnOf);\n  const turnKey = turns.map((turn) => turn.head.id).join(\" \");\n\n  useEffect(() => {\n    turnOfRef.current = turnOf;\n  });\n\n  useEffect(() => {\n    if (!viewport) return undefined;\n\n    let frame = 0;\n    const measure = () => {\n      frame = 0;\n      const owners = turnOfRef.current;\n      const view = viewport.getBoundingClientRect();\n      const line = readingLine(viewport);\n\n      // One pass yields both facts the rail draws: which turn is being read,\n      // and which turns the viewport currently holds.\n      let current: string | undefined;\n      const onScreen: string[] = [];\n      for (const element of viewport.querySelectorAll<HTMLElement>(\n        \"[data-message-id]\",\n      )) {\n        const box = element.getBoundingClientRect();\n        if (box.top >= view.bottom) break;\n\n        const id = element.dataset[\"messageId\"];\n        const head = id === undefined ? undefined : owners.get(id);\n        if (head === undefined) continue;\n\n        if (box.top <= line) current = head;\n        if (box.bottom > view.top && !onScreen.includes(head)) {\n          onScreen.push(head);\n        }\n      }\n\n      setActiveId(current ?? owners.values().next().value);\n      setVisibleIds((previous) =>\n        sameIds(previous, onScreen) ? previous : onScreen,\n      );\n    };\n    const schedule = () => {\n      if (frame) return;\n      frame = requestAnimationFrame(measure);\n    };\n\n    scheduleRef.current = schedule;\n    schedule();\n    viewport.addEventListener(\"scroll\", schedule, { passive: true });\n    const observer = new ResizeObserver(schedule);\n    observer.observe(viewport);\n\n    return () => {\n      scheduleRef.current = undefined;\n      if (frame) cancelAnimationFrame(frame);\n      viewport.removeEventListener(\"scroll\", schedule);\n      observer.disconnect();\n    };\n  }, [viewport]);\n\n  useEffect(() => {\n    scheduleRef.current?.();\n  }, [turnKey]);\n\n  const select = useCallback(\n    (id: string) => {\n      if (!viewport) return;\n      for (const element of viewport.querySelectorAll<HTMLElement>(\n        \"[data-message-id]\",\n      )) {\n        if (element.dataset[\"messageId\"] !== id) continue;\n\n        // `scrollIntoView` aligns every scrollable ancestor, which drags the\n        // page a thread is embedded in; only this viewport should move.\n        const top =\n          element.getBoundingClientRect().top -\n          viewport.getBoundingClientRect().top +\n          viewport.scrollTop;\n        viewport.scrollTo({ top, behavior: \"smooth\" });\n        return;\n      }\n    },\n    [viewport],\n  );\n\n  return (\n    <div\n      data-slot=\"conversation-map-rail\"\n      className={cn(\n        \"pointer-events-none sticky top-0 z-10 h-0 w-full\",\n        className,\n      )}\n    >\n      <div\n        className={cn(\n          \"pointer-events-auto absolute top-0 px-3 py-10\",\n          side === \"right\" ? \"right-0\" : \"left-0\",\n        )}\n        style={{ height: viewportHeight }}\n      >\n        <ConversationMap\n          entries={entries}\n          activeId={activeId}\n          visibleIds={visibleIds}\n          onSelect={select}\n          side={side === \"right\" ? \"left\" : \"right\"}\n        />\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}