{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "highlight-to-askai",
  "type": "registry:block",
  "title": "Highlight to Ask AI [Experimental]",
  "description": "🚧 [Experimental] Highlight any text and ask Algolia Ask AI with an inline tooltip",
  "dependencies": [
    "@floating-ui/react",
    "@ai-sdk/react@^2.0.4",
    "ai@^5.0.30",
    "lucide-react",
    "marked"
  ],
  "registryDependencies": [
    "button",
    "input"
  ],
  "files": [
    {
      "path": "src/registry/experiences/highlight-to-askai/components/highlight-to-askai.tsx",
      "content": "/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: markdown rendering sanitized via parseMarkdownToSafeHtml */\n/** biome-ignore-all lint/suspicious/noArrayIndexKey: parts are stable during render */\n\"use client\";\n\nimport type { Placement, ReferenceType } from \"@floating-ui/react\";\nimport {\n  FloatingPortal,\n  flip,\n  offset,\n  shift,\n  useFloating,\n} from \"@floating-ui/react\";\nimport {\n  BrainIcon,\n  CornerDownLeftIcon,\n  SearchIcon,\n  ThumbsDown,\n  ThumbsUp,\n} from \"lucide-react\";\nimport { marked, type Tokens } from \"marked\";\nimport React from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  postAgentStudioFeedback,\n  postFeedback,\n  useAskai,\n} from \"@/registry/experiences/highlight-to-askai/hooks/use-askai\";\n\nfunction escapeHtml(html: string): string {\n  return html\n    .replace(/&/g, \"&amp;\")\n    .replace(/</g, \"&lt;\")\n    .replace(/>/g, \"&gt;\")\n    .replace(/\"/g, \"&quot;\")\n    .replace(/'/g, \"&#39;\");\n}\n\nfunction decodeUrlForSchemeCheck(value: string): string {\n  let current = value;\n  for (let i = 0; i < 3; i += 1) {\n    try {\n      const decoded = decodeURIComponent(current);\n      if (decoded === current) {\n        break;\n      }\n      current = decoded;\n    } catch {\n      break;\n    }\n  }\n  return current;\n}\n\nfunction stripControlsAndWhitespace(value: string): string {\n  let result = \"\";\n  for (let i = 0; i < value.length; i += 1) {\n    const code = value.charCodeAt(i);\n    if (code > 0x20 && code !== 0x7f) {\n      result += value.charAt(i);\n    }\n  }\n  return result;\n}\n\nfunction sanitizeUrl(url: string | null | undefined): string {\n  if (!url) {\n    return \"\";\n  }\n\n  const trimmed = url.trim();\n  if (!trimmed) {\n    return \"\";\n  }\n\n  // Decode / strip controls for scheme checks only — return the original trimmed\n  // URL when safe so percent-encoding in the path/query is preserved.\n  const normalized = stripControlsAndWhitespace(\n    decodeUrlForSchemeCheck(trimmed),\n  ).replace(/\\\\/g, \"/\");\n  if (!normalized) {\n    return \"\";\n  }\n\n  // Protocol-relative and backslash-obfuscated hosts (e.g. /\\evil.com → //evil.com).\n  if (normalized.startsWith(\"//\")) {\n    return \"\";\n  }\n\n  if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) {\n    return trimmed;\n  }\n\n  try {\n    const parsed = new URL(normalized);\n    if (\n      parsed.protocol === \"http:\" ||\n      parsed.protocol === \"https:\" ||\n      parsed.protocol === \"mailto:\"\n    ) {\n      return trimmed;\n    }\n  } catch {\n    return \"\";\n  }\n\n  return \"\";\n}\n\n/* eslint-disable xss/no-mixed-html -- marked renderer: interpolations escaped/sanitized */\nconst markdownRenderer = new marked.Renderer();\n\nmarkdownRenderer.code = ({ text, lang = \"\", escaped }: Tokens.Code): string => {\n  const safeLang = /^[a-zA-Z0-9_-]+$/.test(lang) ? lang : \"\";\n  const languageClass = safeLang ? \"language-\" + safeLang : \"\";\n  const safeCode = escaped ? text : escapeHtml(text);\n  return (\n    '<pre><code class=\"' + languageClass + '\">' + safeCode + \"</code></pre>\"\n  );\n};\n\nmarkdownRenderer.link = ({ href, title, text }: Tokens.Link): string => {\n  const safeHref = escapeHtml(sanitizeUrl(href));\n  const textEscaped = escapeHtml(text);\n  if (!safeHref) {\n    return textEscaped;\n  }\n  const titleAttr = title ? ' title=\"' + escapeHtml(title) + '\"' : \"\";\n  // href/text/title are sanitized + escaped above.\n  return (\n    '<a href=\"' +\n    safeHref +\n    '\"' +\n    titleAttr +\n    ' target=\"_blank\" rel=\"noopener noreferrer\">' +\n    textEscaped +\n    \"</a>\"\n  ); // nosemgrep\n};\n\nmarkdownRenderer.image = ({ href, title, text }: Tokens.Image): string => {\n  const safeHref = escapeHtml(sanitizeUrl(href));\n  if (!safeHref) {\n    return escapeHtml(text);\n  }\n  const titleAttr = title ? ' title=\"' + escapeHtml(title) + '\"' : \"\";\n  // src/alt/title are sanitized + escaped above.\n  return (\n    '<img src=\"' +\n    safeHref +\n    '\" alt=\"' +\n    escapeHtml(text) +\n    '\"' +\n    titleAttr +\n    \" />\"\n  ); // nosemgrep\n};\n\nmarkdownRenderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string =>\n  escapeHtml(text);\n/* eslint-enable xss/no-mixed-html */\n\nfunction parseMarkdownToSafeHtml(content: string): string {\n  return marked.parse(content, {\n    gfm: true,\n    breaks: true,\n    renderer: markdownRenderer,\n  }) as string;\n}\n\ntype OnAskPayload = {\n  text: string;\n  html: string;\n  rect: DOMRect;\n  range: Range;\n  contextNode: HTMLElement;\n};\n\nexport type HighlightAskAIProps = {\n  agentStudio?: boolean;\n  applicationId: string;\n  apiKey: string;\n  indexName: string;\n  assistantId: string;\n  excludeElements?: string[];\n  side?: Placement;\n  sideOffset?: number;\n  delay?: number;\n  askButtonLabel?: string;\n  className?: string;\n  askAiBaseUrl?: string;\n  onAsk?: (payload: OnAskPayload) => void | Promise<void>;\n  children: React.ReactNode;\n};\n\ntype VirtualElement = {\n  getBoundingClientRect: () => DOMRect;\n  contextElement?: Element | null;\n};\n\nfunction isWhitespaceOnly(text: string) {\n  return text.trim().length === 0;\n}\n\nfunction selectionIntersectsContainer(\n  range: Range,\n  container: HTMLElement,\n): boolean {\n  const startContainer = range.startContainer as Node | null;\n  const endContainer = range.endContainer as Node | null;\n  if (!startContainer || !endContainer) return false;\n  return container.contains(startContainer) || container.contains(endContainer);\n}\n\nfunction nodeIsExcluded(node: Node, selectors: string[]): boolean {\n  let el: Element | null = null;\n  if (node instanceof Element) {\n    el = node;\n  } else if ((node as ChildNode).parentElement) {\n    el = (node as ChildNode).parentElement;\n  }\n  if (!el) return false;\n  for (const sel of selectors) {\n    if (el.closest(sel)) return true;\n  }\n  return false;\n}\n\nconst AlgoliaLogo = ({ size = 52 }: { size?: number | string }) => (\n  <svg\n    width=\"80\"\n    height=\"24\"\n    aria-label=\"Algolia\"\n    role=\"img\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 2196.2 500\"\n    style={{ maxWidth: size }}\n  >\n    <defs>\n      {/* eslint-disable-nextLine @docusaurus/no-untranslated-text */}\n      <style>{`.cls-1,.cls-2{fill:#003dff}.cls-2{fillRule:evenodd}`}</style>\n    </defs>\n    <path\n      className=\"cls-2\"\n      d=\"M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z\"\n    />\n    <rect\n      className=\"cls-1\"\n      x=\"1845.88\"\n      y=\"104.73\"\n      width=\"62.58\"\n      height=\"277.9\"\n      rx=\"5.9\"\n      ry=\"5.9\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z\"\n    />\n    <path\n      className=\"cls-2\"\n      d=\"M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z\"\n    />\n    <path\n      className=\"cls-1\"\n      d=\"M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z\"\n    />\n  </svg>\n);\n\nexport function HighlightAskAI({\n  agentStudio = false,\n  applicationId,\n  apiKey,\n  indexName,\n  assistantId,\n  excludeElements = [\"pre\", \"code\"],\n  side = \"top\",\n  sideOffset = 8,\n  delay = 120,\n  askButtonLabel = \"Ask AI?\",\n  className,\n  onAsk,\n  children,\n}: HighlightAskAIProps) {\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const [open, setOpen] = React.useState(false);\n  const [expanded, setExpanded] = React.useState(false);\n  const [range, setRange] = React.useState<Range | null>(null);\n  const [virtualEl, setVirtualEl] = React.useState<VirtualElement | null>(null);\n  const [followUpInput, setFollowUpInput] = React.useState<string>(\"\");\n  const [feedbackGiven, setFeedbackGiven] = React.useState(false);\n  const [submittingFeedback, setSubmittingFeedback] = React.useState(false);\n\n  const { messages, error, isGenerating, sendMessage, setMessages, stop } =\n    useAskai({\n      applicationId,\n      apiKey,\n      indexName,\n      assistantId,\n      agentStudio,\n    });\n\n  const handleFeedback = async (vote: 0 | 1) => {\n    const messageToFeedback = (messages || []).find(\n      (m: unknown) =>\n        (m as { role?: string }).role === (agentStudio ? \"assistant\" : \"user\"),\n    ) as { id?: string } | undefined;\n\n    if (!messageToFeedback?.id) return;\n\n    setSubmittingFeedback(true);\n    try {\n      if (agentStudio) {\n        await postAgentStudioFeedback({\n          agentId: assistantId,\n          vote,\n          messageId: messageToFeedback.id,\n          appId: applicationId,\n          apiKey,\n        });\n      } else {\n        await postFeedback({\n          assistantId,\n          appId: applicationId,\n          messageId: messageToFeedback.id,\n          thumbs: vote,\n        });\n      }\n      setFeedbackGiven(true);\n    } catch {\n      // ignore errors\n    } finally {\n      setSubmittingFeedback(false);\n    }\n  };\n  const resetConversation = React.useCallback(() => {\n    try {\n      stop?.();\n    } catch {}\n    setMessages?.([]);\n    setFollowUpInput(\"\");\n    setFeedbackGiven(false);\n    setSubmittingFeedback(false);\n  }, [setMessages, stop]);\n\n  const { refs, floatingStyles, update } = useFloating<\n    HTMLElement | ReferenceType\n  >({\n    placement: side,\n    middleware: [offset(sideOffset), flip(), shift()],\n    // Don't use autoUpdate - we want the tooltip to stay in place on scroll\n  });\n\n  // Keep placement in sync with prop changes\n  React.useEffect(() => {\n    update?.();\n  }, [update]);\n\n  React.useEffect(() => {\n    if (virtualEl) refs.setReference(virtualEl as unknown as ReferenceType);\n  }, [virtualEl, refs.setReference]);\n\n  // Debounced selection handler\n  const debounceRef = React.useRef<number | null>(null);\n  const handleSelectionChange = React.useCallback(() => {\n    if (debounceRef.current) window.clearTimeout(debounceRef.current);\n    debounceRef.current = window.setTimeout(() => {\n      const sel = window.getSelection();\n      const containerEl = containerRef.current;\n      const floatingEl = refs.floating.current;\n      const activeEl =\n        typeof document !== \"undefined\"\n          ? (document.activeElement as Node | null)\n          : null;\n\n      // Ignore selection changes while focus is inside the floating tooltip\n      if (floatingEl && activeEl && floatingEl.contains(activeEl)) {\n        return;\n      }\n\n      if (!sel || !containerEl) return;\n      if (sel.rangeCount === 0 || sel.isCollapsed) {\n        // Don't close if already expanded; allow typing in the tooltip\n        if (!expanded) {\n          setOpen(false);\n          setExpanded(false);\n          setRange(null);\n          setVirtualEl(null);\n        }\n        return;\n      }\n\n      const nextRange = sel.getRangeAt(0);\n      const text = sel.toString();\n      if (isWhitespaceOnly(text)) {\n        if (!expanded) {\n          setOpen(false);\n          setExpanded(false);\n          setRange(null);\n          setVirtualEl(null);\n        }\n        return;\n      }\n\n      if (!selectionIntersectsContainer(nextRange, containerEl)) {\n        if (!expanded) {\n          setOpen(false);\n          setExpanded(false);\n          setRange(null);\n          setVirtualEl(null);\n        }\n        return;\n      }\n\n      const anchorNode = sel.anchorNode;\n      const focusNode = sel.focusNode;\n      if (\n        (anchorNode && nodeIsExcluded(anchorNode, excludeElements)) ||\n        (focusNode && nodeIsExcluded(focusNode, excludeElements))\n      ) {\n        if (!expanded) {\n          setOpen(false);\n          setExpanded(false);\n          setRange(null);\n          setVirtualEl(null);\n        }\n        return;\n      }\n\n      const rect = nextRange.getBoundingClientRect();\n      if (rect.width === 0 && rect.height === 0) {\n        if (!expanded) {\n          setOpen(false);\n          setExpanded(false);\n          setRange(null);\n          setVirtualEl(null);\n        }\n        return;\n      }\n\n      const v: VirtualElement = {\n        getBoundingClientRect: () => rect,\n      };\n      setRange(nextRange);\n      setVirtualEl(v);\n      setOpen(true);\n      setExpanded(false);\n      // Trigger update after setting v\n      requestAnimationFrame(() => {\n        update?.();\n      });\n    }, delay);\n  }, [delay, excludeElements, expanded, refs.floating, update]);\n\n  React.useEffect(() => {\n    document.addEventListener(\"selectionchange\", handleSelectionChange);\n    return () =>\n      document.removeEventListener(\"selectionchange\", handleSelectionChange);\n  }, [handleSelectionChange]);\n\n  // Close on Escape and click outside\n  React.useEffect(() => {\n    function onKeyDown(e: KeyboardEvent) {\n      if (e.key === \"Escape\") {\n        setOpen(false);\n        setExpanded(false);\n        resetConversation();\n      }\n    }\n    function onMouseDown(e: MouseEvent) {\n      if (!open) return;\n\n      const floatingEl = refs.floating.current;\n      const containerEl = containerRef.current;\n      const target = e.target as Node | null;\n\n      if (!target) return;\n\n      // Don't close if clicking inside the floating tooltip\n      if (floatingEl?.contains(target)) {\n        return;\n      }\n\n      // Don't close if clicking inside the container (but outside selection)\n      if (containerEl?.contains(target)) {\n        return;\n      }\n\n      // Click is outside both - close the tooltip\n      setOpen(false);\n      setExpanded(false);\n      resetConversation();\n    }\n    document.addEventListener(\"keydown\", onKeyDown);\n    document.addEventListener(\"mousedown\", onMouseDown);\n    return () => {\n      document.removeEventListener(\"keydown\", onKeyDown);\n      document.removeEventListener(\"mousedown\", onMouseDown);\n    };\n  }, [open, refs.floating, resetConversation]);\n\n  // Build HTML content string for onAsk consumers\n  const getSelectionHtml = React.useCallback((rangeArg: Range | null) => {\n    if (!rangeArg) return \"\";\n    const fragment = rangeArg.cloneContents();\n    const div = document.createElement(\"div\");\n    div.appendChild(fragment);\n    return div.innerHTML;\n  }, []);\n\n  const handleAskClick = React.useCallback(async () => {\n    if (!range) return;\n    const text = range.toString();\n    const html = getSelectionHtml(range);\n    const rect = range.getBoundingClientRect();\n    const node = range.commonAncestorContainer as Node;\n    const contextElement =\n      node instanceof HTMLElement ? node : node.parentElement;\n    const contextNode = (contextElement as HTMLElement) ?? document.body;\n\n    setExpanded(true);\n    // starting a fresh question: clear past conversation first\n    resetConversation();\n    if (onAsk) {\n      try {\n        await onAsk({ text, html, rect, range, contextNode });\n      } catch (err) {\n        // eslint-disable-next-line no-console\n        console.error(\"onAsk error\", err);\n      }\n    }\n\n    const prompt = `Can you tell me what exactly is '${text}' ? be concise`;\n    try {\n      await sendMessage({ text: prompt });\n    } catch (err) {\n      // eslint-disable-next-line no-console\n      console.error(\"sendMessage error\", err);\n    }\n  }, [getSelectionHtml, onAsk, range, resetConversation, sendMessage]);\n\n  return (\n    <div ref={containerRef} style={{ position: \"relative\" }}>\n      {children}\n      {open && virtualEl && (\n        <FloatingPortal>\n          <div\n            ref={refs.setFloating}\n            style={{\n              ...floatingStyles,\n              zIndex: 60,\n            }}\n            className={\n              \"rounded-md border border-neutral-200 bg-white text-neutral-900 shadow-lg dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100\" +\n              (className ? ` ${className}` : \"\")\n            }\n          >\n            {!expanded ? (\n              <div className=\"flex items-center\">\n                <Button\n                  type=\"button\"\n                  onClick={handleAskClick}\n                  className=\"inline-flex items-center rounded-md bg-neutral-900 px-2 py-1 text-xs font-medium text-white shadow-sm hover:bg-neutral-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-500 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200\"\n                >\n                  {askButtonLabel}\n                </Button>\n              </div>\n            ) : (\n              <div className=\"flex items-center justify-between gap-2 p-2 border-b border-neutral-200 dark:border-neutral-800\">\n                <a\n                  href=\"https://www.algolia.com/developers?utm_medium=referral&utm_content=powered_by&utm_campaign=sitesearch\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"text-xs opacity-70 flex items-center gap-2\"\n                >\n                  <span className=\"inline-flex items-center gap-1\">\n                    Powered by\n                  </span>\n                  <AlgoliaLogo />\n                </a>\n                <Button\n                  onClick={() => {\n                    setOpen(false);\n                    setExpanded(false);\n                    resetConversation();\n                  }}\n                  type=\"button\"\n                  size=\"sm\"\n                  variant=\"outline\"\n                  className=\"p-1.5 text-xs text-muted-foreground\"\n                >\n                  esc\n                </Button>\n              </div>\n            )}\n            {expanded && (\n              <div className=\"min-w-sm max-w-md\">\n                <div className=\"text-foreground p-3 max-h-[400px] overflow-y-auto text-sm\">\n                  {(() => {\n                    const assistant = (messages || [])\n                      .slice()\n                      .reverse()\n                      .find(\n                        (m: unknown) =>\n                          (m as { role?: string }).role === \"assistant\",\n                      );\n                    if (!assistant)\n                      return (\n                        <span className=\"text-muted-foreground text-xs\">\n                          {isGenerating ? \"Thinking...\" : \"\"}\n                        </span>\n                      );\n                    const parts =\n                      (\n                        assistant as {\n                          parts?: Array<\n                            | string\n                            | {\n                                type: string;\n                                text?: string;\n                                state?: string;\n                                input?: { query?: string };\n                                output?: { query?: string; hits?: unknown[] };\n                                errorText?: string;\n                              }\n                          >;\n                        }\n                      ).parts ?? [];\n\n                    return (\n                      <>\n                        {parts.map((part, index) => {\n                          if (typeof part === \"string\") {\n                            return <p key={index}>{part}</p>;\n                          }\n                          if (part.type === \"text\") {\n                            // eslint-disable-next-line xss/no-mixed-html -- sanitized via parseMarkdownToSafeHtml\n                            const html = parseMarkdownToSafeHtml(\n                              part.text || \"\",\n                            );\n                            return (\n                              <div\n                                key={index}\n                                className=\"prose dark:prose-invert text-sm max-w-none\"\n                                dangerouslySetInnerHTML={{\n                                  __html: html as string,\n                                }}\n                              />\n                            );\n                          } else if (\n                            part.type === \"reasoning\" &&\n                            part.state === \"streaming\"\n                          ) {\n                            return (\n                              <p\n                                key={index}\n                                className=\"text-xs flex my-2 gap-2 items-center text-muted-foreground\"\n                              >\n                                <BrainIcon size={14} /> Reasoning...\n                              </p>\n                            );\n                          } else if (part.type === \"tool-searchIndex\") {\n                            if (part.state === \"input-streaming\") {\n                              return (\n                                <p\n                                  key={index}\n                                  className=\"text-xs flex my-2 gap-2 items-center text-muted-foreground\"\n                                >\n                                  <SearchIcon size={14} /> Searching...\n                                </p>\n                              );\n                            } else if (part.state === \"input-available\") {\n                              return (\n                                <p\n                                  key={index}\n                                  className=\"text-sm flex my-2 gap-2 items-center text-muted-foreground\"\n                                >\n                                  <SearchIcon size={14} /> Looking for{\" \"}\n                                  <mark className=\"bg-transparent text-muted-foreground underline decoration-2 underline-offset-4\">\n                                    &quot;{part.input?.query || \"\"}&quot;\n                                  </mark>\n                                </p>\n                              );\n                            } else if (part.state === \"output-available\") {\n                              return (\n                                <p\n                                  key={index}\n                                  className=\"text-sm flex my-2 gap-2 items-center text-muted-foreground\"\n                                >\n                                  <SearchIcon size={14} />{\" \"}\n                                  <span>\n                                    Searched for{\" \"}\n                                    <mark className=\"bg-transparent text-muted-foreground underline decoration-1 underline-offset-4\">\n                                      &quot;{part.output?.query}&quot;\n                                    </mark>{\" \"}\n                                    found {part.output?.hits?.length || \"no\"}{\" \"}\n                                    results\n                                  </span>\n                                </p>\n                              );\n                            } else if (part.state === \"output-error\") {\n                              return (\n                                <p\n                                  key={index}\n                                  className=\"text-xs flex my-2 gap-2 items-center text-muted-foreground\"\n                                >\n                                  {part.errorText}\n                                </p>\n                              );\n                            }\n                          }\n                          return null;\n                        })}\n                      </>\n                    );\n                  })()}\n                </div>\n                {error ? (\n                  <div className=\"mt-2 text-xs max-h-[400px] prose text-wrap text-red-600 break-all opacity-80\">\n                    {String(\n                      (error as unknown as { message?: string }).message ||\n                        error,\n                    )}\n                  </div>\n                ) : null}\n                {(() => {\n                  const hasAssistantResponse = (messages || []).some(\n                    (m: unknown) =>\n                      (m as { role?: string }).role === \"assistant\",\n                  );\n                  if (!hasAssistantResponse || isGenerating) return null;\n\n                  return (\n                    <>\n                      <div className=\"\">\n                        <div className=\"flex items-center justify-end gap-2 mb-3 p-3 border-b border-neutral-200 dark:border-neutral-800\">\n                          {feedbackGiven ? (\n                            <span className=\"text-muted-foreground text-xs animate-in fade-in slide-in-from-bottom-1\">\n                              Thanks for your feedback!\n                            </span>\n                          ) : submittingFeedback ? (\n                            <span className=\"text-muted-foreground text-xs\">\n                              Submitting...\n                            </span>\n                          ) : (\n                            <div className=\"inline-flex items-center gap-2\">\n                              <button\n                                type=\"button\"\n                                title=\"Like\"\n                                aria-label=\"Like\"\n                                className=\"border-none bg-transparent rounded-md px-2 py-1 text-muted-foreground cursor-pointer flex items-center justify-center gap-2 transition-all duration-150 hover:bg-accent disabled:text-foreground disabled:cursor-not-allowed\"\n                                disabled={submittingFeedback}\n                                onClick={() => handleFeedback(1)}\n                              >\n                                <ThumbsUp size={14} />\n                              </button>\n                              <button\n                                type=\"button\"\n                                title=\"Dislike\"\n                                aria-label=\"Dislike\"\n                                className=\"border-none bg-transparent rounded-md px-2 py-1 text-muted-foreground cursor-pointer flex items-center justify-center gap-2 transition-all duration-150 hover:bg-accent disabled:text-foreground disabled:cursor-not-allowed\"\n                                disabled={submittingFeedback}\n                                onClick={() => handleFeedback(0)}\n                              >\n                                <ThumbsDown size={14} />\n                              </button>\n                            </div>\n                          )}\n                        </div>\n                        <form\n                          onSubmit={(e) => {\n                            e.preventDefault();\n                            if (followUpInput.trim()) {\n                              sendMessage({ text: followUpInput.trim() });\n                              setFollowUpInput(\"\");\n                            }\n                          }}\n                          className=\"flex items-center gap-2 p-3\"\n                        >\n                          <Input\n                            type=\"text\"\n                            value={followUpInput}\n                            onChange={(e) => setFollowUpInput(e.target.value)}\n                            placeholder=\"Ask a follow-up...\"\n                            className=\"flex-1 text-sm\"\n                          />\n                          <Button\n                            type=\"submit\"\n                            size=\"sm\"\n                            disabled={!followUpInput.trim()}\n                            className=\"p-1.5 h-auto\"\n                          >\n                            <CornerDownLeftIcon size={14} />\n                          </Button>\n                        </form>\n                      </div>\n                    </>\n                  );\n                })()}\n              </div>\n            )}\n          </div>\n        </FloatingPortal>\n      )}\n    </div>\n  );\n}\n\nexport default HighlightAskAI;\n",
      "type": "registry:component"
    },
    {
      "path": "src/registry/experiences/highlight-to-askai/hooks/use-askai.ts",
      "content": "import { useChat } from \"@ai-sdk/react\";\nimport {\n  DefaultChatTransport,\n  lastAssistantMessageIsCompleteWithToolCalls,\n} from \"ai\";\nimport { useMemo } from \"react\";\n\nexport interface AskAIConfig {\n  agentStudio?: boolean;\n  applicationId: string;\n  apiKey: string;\n  indexName: string;\n  assistantId: string;\n}\n\n/**\n * Checks if an error is a thread depth error (AI-217)\n * Thread depth errors occur when a conversation has reached its maximum depth limit\n */\nexport function isThreadDepthError(error?: Error | null): boolean {\n  if (!error) return false;\n\n  // Check if error has a code property\n  const errorWithCode = error as Error & { code?: string };\n  if (errorWithCode.code === \"AI-217\") return true;\n\n  // Check message content for AI-217 or thread depth references\n  const message = error.message?.toLowerCase() || \"\";\n  return message.includes(\"ai-217\") || message.includes(\"conversation depth\");\n}\n\nconst agentStudioBaseUrl = (appId: string): string =>\n  `https://${appId}.algolia.net/agent-studio/1`;\n\nfunction getChatApiUrl(config: AskAIConfig): string {\n  if (config.agentStudio) {\n    return `${agentStudioBaseUrl(config.applicationId)}/agents/${config.assistantId}/completions?stream=true&compatibilityMode=ai-sdk-5`;\n  }\n  return `${BASE_ASKAI_URL}/chat`;\n}\n\nexport function useAskai(config: AskAIConfig) {\n  if (!config) {\n    throw new Error(\"config is required for useAskai\");\n  }\n\n  const transport = useMemo(() => {\n    return new DefaultChatTransport({\n      api: getChatApiUrl(config),\n      headers: async () => {\n        if (config.agentStudio) {\n          return {\n            \"x-algolia-api-key\": config.apiKey,\n            \"x-algolia-application-id\": config.applicationId,\n          } as Record<string, string>;\n        }\n        const token = await getValidToken({ assistantId: config.assistantId });\n        return {\n          \"x-algolia-api-key\": config.apiKey,\n          \"x-algolia-application-id\": config.applicationId,\n          \"x-algolia-index-name\": config.indexName,\n          \"x-algolia-assistant-id\": config.assistantId,\n          \"x-ai-sdk-version\": \"v5\",\n          authorization: `TOKEN ${token}`,\n        } as Record<string, string>;\n      },\n    });\n  }, [\n    config.apiKey,\n    config.applicationId,\n    config.indexName,\n    config.assistantId,\n    config.agentStudio,\n    config,\n  ]);\n\n  const chat = useChat({\n    transport,\n    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,\n  });\n\n  const isGenerating =\n    chat.status === \"submitted\" || chat.status === \"streaming\";\n\n  // Check if there's a thread depth error (AI-217)\n  const hasThreadDepthError = useMemo(() => {\n    return (\n      chat.status === \"error\" && isThreadDepthError(chat.error as Error | null)\n    );\n  }, [chat.status, chat.error]);\n\n  return {\n    ...chat,\n    isGenerating,\n    hasThreadDepthError,\n  };\n}\n\nconst BASE_ASKAI_URL = \"https://askai.algolia.com\";\nconst TOKEN_KEY = \"askai_token\";\n\ntype TokenPayload = { exp: number };\n\nconst decode = (token: string): TokenPayload => {\n  const [b64] = token.split(\".\");\n  return JSON.parse(atob(b64));\n};\n\nconst isExpired = (token?: string | null): boolean => {\n  if (!token) return true;\n  try {\n    const { exp } = decode(token);\n    // refresh 30 s before the backend rejects it\n    return Date.now() / 1000 > exp - 30;\n  } catch {\n    return true;\n  }\n};\n\nlet inflight: Promise<string> | null = null;\n\n// call /token once, cache the promise while it’s running\n// eslint-disable-next-line require-await\nexport const getValidToken = async ({\n  assistantId,\n}: {\n  assistantId: string;\n}): Promise<string> => {\n  const cached = sessionStorage.getItem(TOKEN_KEY);\n  if (!isExpired(cached)) return cached as string;\n\n  if (!inflight) {\n    inflight = fetch(`${BASE_ASKAI_URL}/chat/token`, {\n      method: \"POST\",\n      headers: {\n        \"x-algolia-assistant-id\": assistantId,\n        \"content-type\": \"application/json\",\n      },\n    })\n      .then((r) => r.json())\n      .then(({ token }) => {\n        sessionStorage.setItem(TOKEN_KEY, token);\n        return token;\n      })\n      .finally(() => {\n        inflight = null;\n      });\n  }\n\n  return inflight;\n};\n\nexport const postFeedback = async ({\n  assistantId,\n  thumbs,\n  messageId,\n  appId,\n}: {\n  assistantId: string;\n  thumbs: 0 | 1;\n  messageId: string;\n  appId: string;\n}): Promise<Response> => {\n  const headers = new Headers();\n  headers.set(\"x-algolia-assistant-id\", assistantId);\n  headers.set(\"content-type\", \"application/json\");\n\n  const token = await getValidToken({ assistantId });\n  headers.set(\"authorization\", `TOKEN ${token}`);\n\n  return fetch(`${BASE_ASKAI_URL}/chat/feedback`, {\n    method: \"POST\",\n    body: JSON.stringify({\n      appId,\n      messageId,\n      thumbs,\n    }),\n    headers,\n  });\n};\n\nexport const postAgentStudioFeedback = ({\n  agentId,\n  vote,\n  messageId,\n  appId,\n  apiKey,\n}: {\n  agentId: string;\n  vote: 0 | 1;\n  messageId: string;\n  appId: string;\n  apiKey: string;\n}): Promise<Response> => {\n  const headers = new Headers();\n  headers.set(\"x-algolia-application-id\", appId);\n  headers.set(\"x-algolia-api-key\", apiKey);\n  headers.set(\"content-type\", \"application/json\");\n\n  const baseUrl = `${agentStudioBaseUrl(appId)}/feedback`;\n\n  return fetch(baseUrl, {\n    method: \"POST\",\n    body: JSON.stringify({\n      messageId,\n      agentId,\n      vote,\n    }),\n    headers,\n  });\n};\n",
      "type": "registry:hook"
    },
    {
      "path": "src/registry/experiences/highlight-to-askai/page.tsx",
      "content": "\"use client\";\n\nimport { HighlightAskAI } from \"@/registry/experiences/highlight-to-askai/components/highlight-to-askai\";\n\nexport default function Page() {\n  return (\n    <div className=\"flex items-center justify-center min-h-[100px] md:min-h-[400px] relative p-6\">\n      <div className=\"w-full max-w-3xl\">\n        <HighlightAskAI\n          excludeElements={[\"pre\", \"code\"]}\n          applicationId=\"06YAZFOHSQ\"\n          apiKey=\"94b6afdc316917b6e6cdf2763fa561df\"\n          indexName=\"algolia_podcast_sample_dataset\"\n          assistantId=\"UpR727VnXnoG\"\n          askAiBaseUrl=\"https://askai.algolia.com\"\n          side=\"top\"\n          sideOffset={8}\n        >\n          <article className=\"prose dark:prose-invert\">\n            <h3>Try it</h3>\n            <p>\n              Select any text in this block to see a small tooltip. Click\n              <em> Ask AI?</em> to expand the panel and stream an answer.\n            </p>\n            <p>\n              The tooltip respects excluded elements like <code>pre</code> and\n              <code>code</code>, and it uses smart placement to avoid viewport\n              edges.\n            </p>\n          </article>\n        </HighlightAskAI>\n        <p className=\"mt-2 text-right text-xs opacity-60\">\n          Tip: Try selecting a sentence across multiple lines.\n        </p>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/highlight-to-askai/page.tsx"
    }
  ],
  "categories": [
    "ai"
  ]
}