{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-ai",
  "type": "registry:block",
  "title": "Search with Ask AI",
  "description": "Opinionated search experience with Ask AI, powered by Algolia's InstantSearch and Ask AI",
  "dependencies": [
    "@ai-sdk/react@^2.0.4",
    "ai@^5.0.30",
    "algoliasearch@^5",
    "react-instantsearch@^7.16.2",
    "tw-animate-css",
    "lucide-react",
    "marked"
  ],
  "devDependencies": [
    "tw-animate-css"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/experiences/search-askai/components/search-ai.tsx",
      "content": "/** biome-ignore-all lint/suspicious/noArrayIndexKey: . */\n/** biome-ignore-all lint/a11y/useFocusableInteractive: hand crafted interactions */\n/** biome-ignore-all lint/a11y/useSemanticElements: hand crafted interactions */\n/** biome-ignore-all lint/a11y/noStaticElementInteractions: hand crafted interactions */\n/** biome-ignore-all lint/a11y/useKeyWithClickEvents: hand crafted interactions */\nimport type { UIMessage } from \"@ai-sdk/react\";\nimport type { UIDataTypes, UIMessagePart } from \"ai\";\nimport { liteClient as algoliasearch } from \"algoliasearch/lite\";\nimport type { BaseHit, Hit } from \"instantsearch.js\";\nimport {\n  ArrowLeftIcon,\n  BrainIcon,\n  CheckIcon,\n  CopyIcon,\n  CornerDownLeftIcon,\n  SearchIcon,\n  SparklesIcon,\n  SquarePen,\n  ThumbsDown,\n  ThumbsUp,\n} from \"lucide-react\";\nimport { marked, type Tokens } from \"marked\";\nimport type React from \"react\";\nimport {\n  type ComponentPropsWithoutRef,\n  type CSSProperties,\n  type FC,\n  memo,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  Configure,\n  Highlight,\n  InstantSearch,\n  useHits,\n  useInstantSearch,\n  useSearchBox,\n} from \"react-instantsearch\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  isThreadDepthError,\n  postAgentStudioFeedback,\n  postFeedback,\n  threadDepthErrorDetail,\n  useAskai,\n} from \"@/registry/experiences/search-askai/hooks/use-askai\";\n\nimport { useKeyboardNavigation } from \"@/registry/experiences/search-askai/hooks/use-keyboard-navigation\";\nimport { useSearchState } from \"@/registry/experiences/search-askai/hooks/use-search-state\";\nimport {\n  type SuggestedQuestionHit,\n  useSuggestedQuestions,\n} from \"@/registry/experiences/search-askai/hooks/use-suggested-questions\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SearchWithAskAIConfig {\n  /** Algolia Application ID (required) */\n  applicationId: string;\n  /** Algolia API Key (required) */\n  apiKey: string;\n  /** Algolia Index Name (required) */\n  indexName: string;\n  /** AI Assistant ID (required for chat functionality) */\n  assistantId: string;\n  /** Placeholder text for search input (optional, defaults to \"What are you looking for?\") */\n  placeholder?: string;\n  /** Number of hits per page (optional, defaults to 8) */\n  hitsPerPage?: number;\n  /** Custom search button text (optional) */\n  buttonText?: string;\n  /** Custom search button props (optional) */\n  buttonProps?: React.ComponentProps<typeof SearchButton>;\n  /** Map which hit attributes to render (supports dotted paths) */\n  attributes: HitsAttributesMapping;\n  /** Additional Algolia search parameters (optional) - e.g., analytics, filters, distinct, etc. */\n  searchParameters?: Record<string, unknown>;\n  /** Enable Algolia Insights (optional, defaults to true) */\n  insights?: boolean;\n  /** Suggested Questions Enabled (optional, defaults to false) */\n  suggestedQuestionsEnabled?: boolean;\n  /** Open hit URLs in a new tab (optional, defaults to true) */\n  openResultsInNewTab?: boolean;\n  /** Transform items before rendering (optional) - useful for proxying images or modifying hit data */\n  transformItems?: (items: Hit<BaseHit>[]) => Hit<BaseHit>[];\n  /** Route Ask AI requests through Agent Studio endpoints (optional, defaults to false). */\n  agentStudio?: boolean;\n}\n\ninterface SearchButtonProps extends React.ComponentProps<typeof Button> {}\n\nexport const SearchButton: React.FC<SearchButtonProps> = ({\n  className,\n  ...buttonProps\n}) => {\n  const [modifierLabel, setModifierLabel] = useState(\"⌘\");\n  const [isModifierPressed, setIsModifierPressed] = useState(false);\n  const [isKPressed, setIsKPressed] = useState(false);\n\n  useEffect(() => {\n    if (typeof navigator === \"undefined\") return;\n    const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);\n    setModifierLabel(isMac ? \"⌘\" : \"Ctrl\");\n  }, []);\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.metaKey || event.ctrlKey) setIsModifierPressed(true);\n      if (event.key.toLowerCase() === \"k\") setIsKPressed(true);\n    };\n    const handleKeyUp = (event: KeyboardEvent) => {\n      if (!event.metaKey && !event.ctrlKey) setIsModifierPressed(false);\n      if (event.key.toLowerCase() === \"k\") setIsKPressed(false);\n    };\n    const resetKeys = () => {\n      setIsModifierPressed(false);\n      setIsKPressed(false);\n    };\n    document.addEventListener(\"keydown\", handleKeyDown);\n    document.addEventListener(\"keyup\", handleKeyUp);\n    window.addEventListener(\"blur\", resetKeys);\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n      document.removeEventListener(\"keyup\", handleKeyUp);\n      window.removeEventListener(\"blur\", resetKeys);\n    };\n  }, []);\n\n  const baseClassName =\n    \"md:min-w-[200px] justify-between hover:shadow-md transition-transform duration-400 translate-y-0 py-3 h-auto cursor-pointer hover:bg-transparent hover:translate-y-[-2px] border shadow-none\";\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      className={cn(baseClassName, className)}\n      aria-label=\"Open search\"\n      {...buttonProps}\n    >\n      <span className=\"flex items-center gap-2 opacity-80\">\n        <SearchIcon size={24} color=\"currentColor\" />\n        <span className=\"hidden sm:inline text-muted-foreground\">Search</span>\n      </span>\n      <div className=\"hidden md:flex gap-0.5\">\n        <kbd\n          className={`h-5 min-w-5 rounded grid place-items-center bg-muted text-xs text-muted-foreground transition-all duration-200 ${\n            isModifierPressed\n              ? \"inset-shadow-sm inset-shadow-foreground/30\"\n              : \"shadow-none\"\n          }`}\n        >\n          {modifierLabel}\n        </kbd>\n        <kbd\n          className={`h-5 min-w-5 rounded grid place-items-center bg-muted text-xs text-muted-foreground transition-all duration-200 ${\n            isKPressed\n              ? \"inset-shadow-sm inset-shadow-foreground/30\"\n              : \"shadow-none\"\n          }`}\n        >\n          K\n        </kbd>\n      </div>\n    </Button>\n  );\n};\n\nexport interface SearchIndexTool {\n  input: {\n    query: string;\n  };\n  output: {\n    query: string;\n    hits: Hit<BaseHit>[];\n  };\n}\n\nexport type Message = UIMessage<\n  unknown,\n  UIDataTypes,\n  {\n    searchIndex: SearchIndexTool;\n  }\n>;\n\nexport type AIMessagePart = UIMessagePart<\n  UIDataTypes,\n  {\n    searchIndex: SearchIndexTool;\n  }\n>;\n\ninterface Exchange {\n  id: string;\n  userMessage: Message;\n  assistantMessage: Message | null;\n}\n\n// ============================================================================\n// Utilities & Helpers\n// ============================================================================\n\nfunction useClipboard() {\n  const copyText = useCallback(async (text: string) => {\n    try {\n      await navigator.clipboard.writeText(text);\n    } catch {\n      // Silently fail - clipboard access might be blocked\n    }\n  }, []);\n\n  return { copyText };\n}\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// ============================================================================\n// Markdown Renderer\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  const encodedCode = encodeURIComponent(text);\n\n  const copyIconSvg = `\n    <svg class=\"markdown-copy-icon\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n      <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect>\n      <path d=\"m5 15-4-4 4-4\"></path>\n    </svg>\n  `;\n\n  const checkIconSvg = `\n    <svg class=\"markdown-check-icon\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n      <polyline points=\"20,6 9,17 4,12\"></polyline>\n    </svg>\n  `;\n\n  return `\n    <div class=\"markdown-code-snippet\">\n      <button class=\"markdown-copy-button\" data-code=\"${encodedCode}\" aria-label=\"Copy code to clipboard\" title=\"Copy code\">\n        ${copyIconSvg}${checkIconSvg}\n        <span class=\"markdown-copy-label\">Copy</span>\n      </button>\n      <pre><code class=\"${languageClass}\">${safeCode}</code></pre>\n    </div>\n  `;\n};\n\nmarkdownRenderer.link = ({ href, title, text }: Tokens.Link): string => {\n  const safeHref = escapeHtml(sanitizeUrl(href));\n  const textEscaped = escapeHtml(text);\n\n  if (!safeHref) {\n    return textEscaped;\n  }\n\n  const titleAttr = title ? ' title=\"' + escapeHtml(title) + '\"' : \"\";\n  // href/text/title are sanitized + escaped above.\n  return (\n    '<a href=\"' +\n    safeHref +\n    '\" target=\"_blank\" rel=\"noopener noreferrer\"' +\n    titleAttr +\n    \">\" +\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\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\n// ============================================================================\n// Icon Components\n// ============================================================================\n\ninterface IconProps {\n  size?: number | string;\n  color?: string;\n  className?: string;\n}\n\nconst AlgoliaLogo = ({ size = 150 }: IconProps) => (\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\n// Attribute Mapping\ntype HitsAttributesMapping = {\n  primaryText: string;\n  secondaryText?: string;\n  tertiaryText?: string;\n  url?: string;\n  image?: string;\n};\n\nfunction toAttributePath(attribute: undefined): undefined;\nfunction toAttributePath(attribute: string): string | string[];\nfunction toAttributePath(attribute?: string): string | string[] | undefined {\n  if (!attribute) return undefined;\n  return attribute.includes(\".\") ? attribute.split(\".\") : attribute;\n}\n\nfunction getByPath<T = unknown>(obj: unknown, path?: string): T | undefined {\n  if (!obj || !path) return undefined;\n  const parts = path.split(\".\");\n  let current: unknown = obj;\n  for (const part of parts) {\n    if (current == null || typeof current !== \"object\") return undefined;\n    current = (current as Record<string, unknown>)[part];\n  }\n  return current as T | undefined;\n}\n\n// ============================================================================\n// UI Helper Components\n// ============================================================================\nexport interface AnimatedShinyTextProps\n  extends ComponentPropsWithoutRef<\"span\"> {\n  shimmerWidth?: number;\n}\nexport const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({\n  children,\n  shimmerWidth = 100,\n  ...props\n}) => {\n  return (\n    <span\n      style={\n        {\n          \"--shiny-width\": `${shimmerWidth}px`,\n        } as CSSProperties\n      }\n      className=\"text-neutral-600/70 dark:text-neutral-400/70 animate-shiny-text [background-size:var(--shiny-width)_100%] bg-clip-text [background-position:0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite] bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80\"\n      {...props}\n    >\n      {children}\n    </span>\n  );\n};\n\n// ============================================================================\n// Modal Component\n// ============================================================================\n\ninterface ModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  children: React.ReactNode;\n}\n\nconst Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {\n  useEffect(() => {\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        onClose();\n      }\n    };\n\n    if (isOpen) {\n      document.addEventListener(\"keydown\", handleEscape);\n      document.body.style.overflow = \"hidden\";\n    }\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleEscape);\n      document.body.style.overflow = \"unset\";\n    };\n  }, [isOpen, onClose]);\n\n  if (!isOpen) return null;\n\n  return createPortal(\n    <div\n      className=\"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-start justify-center md:pt-[10vh] dark:bg-black/60\"\n      onClick={onClose}\n    >\n      <div\n        className=\"bg-background md:rounded-xl shadow-2xl w-full md:w-[90%] max-w-full md:max-w-[720px] h-full md:h-auto md:max-h-[80vh] overflow-hidden animate-in fade-in-0 zoom-in-95\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        {children}\n      </div>\n    </div>,\n    document.body,\n  );\n};\n\n// ============================================================================\n// Markdown Component\n// ============================================================================\n\ninterface MemoizedMarkdownProps {\n  children: string;\n  className?: string;\n}\n\nconst MemoizedMarkdown = memo(function MemoizedMarkdown({\n  children,\n  className = \"\",\n}: MemoizedMarkdownProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const html = useMemo(() => {\n    try {\n      return marked(children, {\n        renderer: markdownRenderer,\n        breaks: true,\n        gfm: true,\n      });\n    } catch (error) {\n      console.error(\"Error parsing markdown:\", error);\n      return escapeHtml(children);\n    }\n  }, [children]);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const handleCopyClick = async (event: Event) => {\n      const target = event.target as HTMLElement;\n      const button = target.closest(\n        \".markdown-copy-button\",\n      ) as HTMLButtonElement;\n\n      if (!button) return;\n\n      event.preventDefault();\n      event.stopPropagation();\n\n      const encodedCode = button.getAttribute(\"data-code\");\n      if (!encodedCode) return;\n\n      try {\n        const code = decodeURIComponent(encodedCode);\n        await navigator.clipboard.writeText(code);\n\n        button.classList.add(\"markdown-copied\");\n\n        setTimeout(() => {\n          button.classList.remove(\"markdown-copied\");\n        }, 2000);\n      } catch (error) {\n        console.error(\"Failed to copy code:\", error);\n      }\n    };\n\n    container.addEventListener(\"click\", handleCopyClick);\n\n    return () => {\n      container.removeEventListener(\"click\", handleCopyClick);\n    };\n  }, [html]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={`text-foreground leading-relaxed max-w-none flex flex-col\n        [&_h1]:font-semibold [&_h1]:leading-tight [&_h1]:mb-2 [&_h1]:text-foreground [&_h1]:text-2xl [&_h1]:border-b [&_h1]:border-border [&_h1]:pb-2\n        [&_h2]:font-semibold [&_h2]:leading-tight [&_h2]:mb-2 [&_h2]:text-foreground [&_h2]:text-xl\n        [&_h3]:font-semibold [&_h3]:leading-tight [&_h3]:mb-2 [&_h3]:text-foreground [&_h3]:text-lg\n        [&_h4]:font-semibold [&_h4]:leading-tight [&_h4]:mb-2 [&_h4]:text-foreground [&_h4]:text-base\n        [&_h5]:font-semibold [&_h5]:leading-tight [&_h5]:mb-2 [&_h5]:text-foreground [&_h5]:text-base\n        [&_h6]:font-semibold [&_h6]:leading-tight [&_h6]:mb-2 [&_h6]:text-foreground [&_h6]:text-base\n        [&_p]:p-0 [&_p]:my-2 [&_p:last-child]:mb-0\n        [&_a]:text-blue-600 [&_a]:no-underline [&_a]:border-b [&_a]:border-transparent [&_a]:transition-all [&_a]:duration-200 [&_a:hover]:border-blue-600 [&_a:hover]:bg-blue-50 dark:[&_a:hover]:bg-slate-900\n        [&_ul]:ps-6 [&_ul]:mt-0 [&_ul]:mb-0 [&_ul]:list-disc\n        [&_ol]:ps-6 [&_ol]:mt-0 [&_ol]:mb-0 [&_ol]:list-decimal\n        [&_li]:mb-1 [&_li::marker]:text-muted-foreground\n        [&_ul_ul]:mb-0 [&_ul_ul]:mt-1 [&_ol_ol]:mb-0 [&_ol_ol]:mt-1 [&_ul_ol]:mb-0 [&_ul_ol]:mt-1 [&_ol_ul]:mb-0 [&_ol_ul]:mt-1\n        [&_code:not(.markdown-code-snippet_code)]:bg-muted [&_code:not(.markdown-code-snippet_code)]:text-foreground [&_code:not(.markdown-code-snippet_code)]:text-sm [&_code:not(.markdown-code-snippet_code)]:font-mono [&_code:not(.markdown-code-snippet_code)]:px-1 [&_code:not(.markdown-code-snippet_code)]:py-0.5 [&_code:not(.markdown-code-snippet_code)]:rounded [&_code:not(.markdown-code-snippet_code)]:border [&_code:not(.markdown-code-snippet_code)]:border-border\n        [&_.markdown-code-snippet]:relative [&_.markdown-code-snippet]:my-4 [&_.markdown-code-snippet]:rounded-lg [&_.markdown-code-snippet]:overflow-hidden [&_.markdown-code-snippet]:border [&_.markdown-code-snippet]:border-border [&_.markdown-code-snippet]:bg-muted\n        [&_.markdown-code-snippet_pre]:m-0 [&_.markdown-code-snippet_pre]:p-4 [&_.markdown-code-snippet_pre]:overflow-x-auto [&_.markdown-code-snippet_pre]:text-sm [&_.markdown-code-snippet_pre]:leading-normal [&_.markdown-code-snippet_pre]:font-mono [&_.markdown-code-snippet_pre]:bg-transparent\n        [&_.markdown-code-snippet_code]:bg-transparent [&_.markdown-code-snippet_code]:text-foreground [&_.markdown-code-snippet_code]:p-0 [&_.markdown-code-snippet_code]:border-none\n        [&_.markdown-copy-button]:absolute [&_.markdown-copy-button]:top-2 [&_.markdown-copy-button]:right-2 [&_.markdown-copy-button]:flex [&_.markdown-copy-button]:items-center [&_.markdown-copy-button]:gap-1 [&_.markdown-copy-button]:px-3 [&_.markdown-copy-button]:py-1.5 [&_.markdown-copy-button]:bg-background [&_.markdown-copy-button]:border [&_.markdown-copy-button]:border-border [&_.markdown-copy-button]:rounded-md [&_.markdown-copy-button]:text-xs [&_.markdown-copy-button]:cursor-pointer [&_.markdown-copy-button]:transition-all [&_.markdown-copy-button]:duration-200 [&_.markdown-copy-button]:text-foreground [&_.markdown-copy-button]:opacity-0 [&_.markdown-copy-button]:-translate-y-1\n        [&_.markdown-code-snippet:hover_.markdown-copy-button]:opacity-100 [&_.markdown-code-snippet:hover_.markdown-copy-button]:translate-y-0\n        [&_.markdown-copy-button:hover]:bg-blue-50 dark:[&_.markdown-copy-button:hover]:bg-slate-900 [&_.markdown-copy-button:hover]:shadow-sm\n        [&_.markdown-copy-button_.markdown-check-icon]:hidden\n        [&_.markdown-copy-button.markdown-copied_.markdown-copy-icon]:hidden\n        [&_.markdown-copy-button.markdown-copied_.markdown-check-icon]:block\n        [&_.markdown-copy-button.markdown-copied]:text-green-600 [&_.markdown-copy-button.markdown-copied]:border-green-600\n        [&_.markdown-copy-label]:font-medium\n        [&_.markdown-copied_.markdown-copy-label]:after:content-['ed']\n        [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm [&_table]:bg-background [&_table]:my-4 [&_table]:rounded-lg [&_table]:border [&_table]:border-border [&_table]:overflow-hidden\n        [&_thead]:bg-muted\n        [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground [&_th]:border-b-2 [&_th]:border-border\n        [&_td]:px-4 [&_td]:py-3 [&_td]:border-b [&_td]:border-border [&_td]:text-foreground\n        [&_tr:last-child_td]:border-b-0\n        [&_tbody_tr:hover]:bg-blue-50 dark:[&_tbody_tr:hover]:bg-slate-900\n        [&_blockquote]:border-l-4 [&_blockquote]:border-blue-600 [&_blockquote]:my-4 [&_blockquote]:py-2 [&_blockquote]:px-4 [&_blockquote]:bg-blue-50 [&_blockquote]:text-foreground [&_blockquote]:italic\n        [&_blockquote_p]:mb-2 [&_blockquote_p:last-child]:mb-0\n        [&_strong]:font-semibold [&_strong]:text-foreground\n        [&_em]:italic\n        [&_hr]:border-none [&_hr]:border-t [&_hr]:border-border [&_hr]:my-6\n        [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md [&_img]:my-2\n        ${className}`.trim()}\n      // eslint-disable-next-line xss/no-mixed-html -- sanitized marked output\n      // biome-ignore lint/security/noDangerouslySetInnerHtml: HTML escaped via marked renderer (html/link/image)\n      dangerouslySetInnerHTML={{ __html: html }}\n    />\n  );\n});\n\n// ============================================================================\n// Thread Depth Error Banner Component\n// ============================================================================\n\ninterface ThreadDepthErrorBannerProps {\n  onNewChat: () => void;\n  detailMessage?: string;\n}\n\nconst ThreadDepthErrorBanner = ({\n  onNewChat,\n  detailMessage,\n}: ThreadDepthErrorBannerProps) => (\n  <div className=\"text-gray-900 text-sm leading-normal\">\n    {detailMessage ? (\n      <p className=\"m-0 mb-2 font-semibold text-foreground\">{detailMessage}</p>\n    ) : null}\n    <p className=\"m-0\">\n      <button\n        type=\"button\"\n        className=\"text-blue-600 underline font-normal cursor-pointer bg-transparent border-none p-0 hover:text-blue-800 focus:outline-2 focus:outline-blue-600 focus:outline-offset-2 focus:rounded-sm\"\n        onClick={onNewChat}\n      >\n        Start a new conversation\n      </button>{\" \"}\n      to continue.\n    </p>\n  </div>\n);\n\n// ============================================================================\n// Chat Component\n// ============================================================================\n\ninterface ChatWidgetProps {\n  messages: Message[];\n  error: Error | null;\n  isGenerating: boolean;\n  onCopy?: (text: string) => Promise<void> | void;\n  onThumbsUp?: (userMessageId: string) => Promise<void> | void;\n  onThumbsDown?: (userMessageId: string) => Promise<void> | void;\n  applicationId: string;\n  apiKey?: string;\n  assistantId: string;\n  agentStudio?: boolean;\n  suggestedQuestions?: SuggestedQuestionHit[];\n  onSuggestedQuestionClick?: (question: string) => void;\n  onNewChat?: () => void;\n}\n\nconst ChatWidget = memo(function ChatWidget({\n  messages,\n  error,\n  isGenerating,\n  onCopy,\n  onThumbsUp,\n  onThumbsDown,\n  applicationId,\n  apiKey,\n  assistantId,\n  agentStudio,\n  suggestedQuestions,\n  onSuggestedQuestionClick,\n  onNewChat,\n}: ChatWidgetProps) {\n  const { copyText } = useClipboard();\n  const [copiedExchangeId, setCopiedExchangeId] = useState<string | null>(null);\n  const copyResetTimeoutRef = useRef<number | null>(null);\n  const [acknowledgedExchangeIds, setAcknowledgedExchangeIds] = useState<\n    Set<string>\n  >(new Set());\n  const [submittingExchangeId, setSubmittingExchangeId] = useState<\n    string | null\n  >(null);\n\n  const handleFeedback = async (exchange: Exchange, vote: 0 | 1) => {\n    if (!exchange.assistantMessage) return;\n    const customHandler = vote === 1 ? onThumbsUp : onThumbsDown;\n    try {\n      setSubmittingExchangeId(exchange.id);\n      if (customHandler) {\n        await customHandler(exchange.userMessage.id);\n      } else if (agentStudio) {\n        if (apiKey) {\n          await postAgentStudioFeedback({\n            agentId: assistantId,\n            vote,\n            messageId: exchange.assistantMessage.id,\n            appId: applicationId,\n            apiKey,\n          });\n        }\n      } else {\n        await postFeedback({\n          assistantId,\n          appId: applicationId,\n          messageId: exchange.userMessage.id,\n          thumbs: vote,\n        });\n      }\n      setAcknowledgedExchangeIds((prev) => {\n        const next = new Set(prev);\n        next.add(exchange.id);\n        return next;\n      });\n    } catch {\n      // ignore errors\n    } finally {\n      setSubmittingExchangeId(null);\n    }\n  };\n\n  // Group messages into exchanges (user + assistant pairs)\n  const exchanges = useMemo(() => {\n    const grouped: Exchange[] = [];\n\n    for (let i = 0; i < messages.length; i++) {\n      const current = messages[i];\n      if (current.role === \"user\") {\n        const userMessage = current as Message;\n        const nextMessage = messages[i + 1];\n        if (nextMessage?.role === \"assistant\") {\n          grouped.push({\n            id: userMessage.id,\n            userMessage,\n            assistantMessage: nextMessage as Message,\n          });\n          i++; // Skip the assistant message since we've already processed it\n        } else {\n          // No assistant yet – show a pending exchange immediately\n          grouped.push({\n            id: userMessage.id,\n            userMessage,\n            assistantMessage: null,\n          });\n        }\n      }\n    }\n    return grouped;\n  }, [messages]);\n\n  // Cleanup any pending reset timers on unmount\n  useEffect(() => {\n    return () => {\n      if (copyResetTimeoutRef.current) {\n        window.clearTimeout(copyResetTimeoutRef.current);\n      }\n    };\n  }, []);\n\n  return (\n    <div className=\"flex flex-col h-[91vh] md:h-[50vh] p-4 bg-muted overflow-y-auto\">\n      <div className=\"flex flex-col gap-4\">\n        {exchanges.length === 0 ? (\n          <>\n            <div className=\"flex flex-col gap-4 py-2\">\n              <h2 className=\"text-2xl font-semibold text-foreground m-0\">\n                How can I help you today?\n              </h2>\n              <p className=\"text-muted-foreground m-0\">\n                I search through your content to help you find answers to your\n                questions, fast.\n              </p>\n              {suggestedQuestions && suggestedQuestions.length > 0 ? (\n                <div className=\"flex flex-wrap gap-2\">\n                  {suggestedQuestions.map((question) => (\n                    <Button\n                      key={question.objectID}\n                      type=\"button\"\n                      variant=\"outline\"\n                      className=\"cursor-pointer text-left\"\n                      disabled={isGenerating}\n                      onClick={() => {\n                        if (isGenerating) return;\n                        onSuggestedQuestionClick?.(question.question);\n                      }}\n                    >\n                      {question.question}\n                    </Button>\n                  ))}\n                </div>\n              ) : null}\n            </div>\n            <p className=\"text-sm m-0 text-left text-muted-foreground self-stretch\">\n              Answers are generated with AI which can make mistakes.\n            </p>\n          </>\n        ) : (\n          <p className=\"text-sm m-0 text-left text-muted-foreground self-stretch\">\n            Answers are generated with AI which can make mistakes.\n          </p>\n        )}\n        {/* errors */}\n        {error && (\n          <div className=\"border border-red-300 bg-red-100 text-red-900 px-4 py-3 rounded-lg\">\n            {isThreadDepthError(error) && onNewChat ? (\n              <ThreadDepthErrorBanner\n                onNewChat={onNewChat}\n                detailMessage={threadDepthErrorDetail(error)}\n              />\n            ) : (\n              error.message\n            )}\n          </div>\n        )}\n\n        {/* exchanges */}\n        {exchanges\n          .slice()\n          .reverse()\n          .map((exchange, index) => {\n            const isLastExchange = index === 0;\n\n            return (\n              <article\n                key={exchange.id}\n                className=\"rounded-lg bg-background p-4\"\n              >\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"font-semibold text-2xl text-foreground mb-2\">\n                    {exchange.userMessage.parts.map((part, index) =>\n                      part.type === \"text\" ? (\n                        <span key={index}>{part.text}</span>\n                      ) : null,\n                    )}\n                  </div>\n                </div>\n\n                <div className=\"mt-3 flex items-start gap-3\">\n                  <div className=\"flex-1 gap-3\">\n                    {exchange.assistantMessage ? (\n                      <div className=\"text-foreground\">\n                        {exchange.assistantMessage.parts.map((part, index) => {\n                          if (typeof part === \"string\") {\n                            return <p key={`${index}`}>{part}</p>;\n                          }\n                          if (part.type === \"text\") {\n                            return (\n                              <MemoizedMarkdown key={`${index}`}>\n                                {part.text}\n                              </MemoizedMarkdown>\n                            );\n                          } else if (\n                            part.type === \"reasoning\" &&\n                            part.state === \"streaming\"\n                          ) {\n                            return (\n                              <p\n                                className=\"text-[0.95rem] flex my-2 gap-2 items-center text-muted-foreground\"\n                                key={`${index}`}\n                              >\n                                <BrainIcon />{\" \"}\n                                <AnimatedShinyText>\n                                  Reasoning...\n                                </AnimatedShinyText>\n                              </p>\n                            );\n                          } else if (part.type === \"tool-searchIndex\") {\n                            if (part.state === \"input-streaming\") {\n                              return (\n                                <p\n                                  className=\"text-[0.95rem] flex my-2 gap-2 items-center text-muted-foreground\"\n                                  key={`${index}`}\n                                >\n                                  <SearchIcon size={18} />{\" \"}\n                                  <AnimatedShinyText>\n                                    Searching...\n                                  </AnimatedShinyText>\n                                </p>\n                              );\n                            } else if (part.state === \"input-available\") {\n                              return (\n                                <p\n                                  className=\"text-[0.95rem] flex my-2 gap-2 items-center text-muted-foreground\"\n                                  key={`${index}`}\n                                >\n                                  <SearchIcon size={18} />{\" \"}\n                                  <AnimatedShinyText>\n                                    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                                  </AnimatedShinyText>\n                                </p>\n                              );\n                            } else if (part.state === \"output-available\") {\n                              return (\n                                <p\n                                  className=\"text-[0.95rem] flex my-2 gap-2 items-center text-muted-foreground\"\n                                  key={`${index}`}\n                                >\n                                  <SearchIcon size={18} />{\" \"}\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{\" \"}\n                                    {Array.isArray(part.output?.hits)\n                                      ? part.output.hits.length\n                                      : \"no\"}{\" \"}\n                                    results\n                                  </span>\n                                </p>\n                              );\n                            } else if (part.state === \"output-error\") {\n                              return (\n                                <p\n                                  className=\"text-[0.95rem] flex my-2 gap-2 items-center text-muted-foreground\"\n                                  key={`${index}`}\n                                >\n                                  {part.errorText}\n                                </p>\n                              );\n                            } else {\n                              return null;\n                            }\n                          } else {\n                            return null;\n                          }\n                        })}\n                      </div>\n                    ) : (\n                      <div className=\"text-muted-foreground\">\n                        <AnimatedShinyText>\n                          {isGenerating && isLastExchange ? \"Thinking...\" : \"\"}\n                        </AnimatedShinyText>\n                      </div>\n                    )}\n                  </div>\n                </div>\n\n                <div className=\"mt-4 flex items-center justify-end gap-2\">\n                  {exchange.assistantMessage && !isGenerating ? (\n                    acknowledgedExchangeIds.has(exchange.id) ? (\n                      <span className=\"text-muted-foreground text-[0.85rem] animate-in fade-in slide-in-from-bottom-1\">\n                        Thanks for your feedback!\n                      </span>\n                    ) : submittingExchangeId === exchange.id ? (\n                      <span className=\"text-muted-foreground text-[0.85rem] shimmer-text\">\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.5 py-1.5 text-muted-foreground cursor-pointer flex items-center justify-center gap-2 transition-all duration-150 hover:bg-blue-50 dark:hover:bg-slate-900 disabled:text-foreground disabled:cursor-not-allowed\"\n                          disabled={\n                            !exchange.assistantMessage ||\n                            submittingExchangeId === exchange.id\n                          }\n                          onClick={() => handleFeedback(exchange, 1)}\n                        >\n                          <ThumbsUp size={18} />\n                        </button>\n                        <button\n                          type=\"button\"\n                          title=\"Dislike\"\n                          aria-label=\"Dislike\"\n                          className=\"border-none bg-transparent rounded-md px-2.5 py-1.5 text-muted-foreground cursor-pointer flex items-center justify-center gap-2 transition-all duration-150 hover:bg-blue-50 dark:hover:bg-slate-900 disabled:text-foreground disabled:cursor-not-allowed\"\n                          disabled={\n                            !exchange.assistantMessage ||\n                            submittingExchangeId === exchange.id\n                          }\n                          onClick={() => handleFeedback(exchange, 0)}\n                        >\n                          <ThumbsDown size={18} />\n                        </button>\n                      </div>\n                    )\n                  ) : null}\n                  <button\n                    type=\"button\"\n                    className={`border-none bg-transparent rounded-md px-2.5 py-1.5 text-muted-foreground cursor-pointer flex items-center justify-center gap-2 transition-all duration-150 hover:bg-blue-50 dark:hover:bg-slate-900 disabled:text-foreground disabled:cursor-not-allowed ${\n                      copiedExchangeId === exchange.id\n                        ? \"bg-blue-50 dark:bg-slate-900 text-blue-600 -translate-y-px\"\n                        : \"\"\n                    }`}\n                    aria-label={\n                      copiedExchangeId === exchange.id\n                        ? \"Copied\"\n                        : \"Copy answer\"\n                    }\n                    title={\n                      copiedExchangeId === exchange.id\n                        ? \"Copied\"\n                        : \"Copy answer\"\n                    }\n                    disabled={\n                      !exchange.assistantMessage ||\n                      copiedExchangeId === exchange.id\n                    }\n                    onClick={async () => {\n                      const parts = exchange.assistantMessage?.parts ?? [];\n                      const textContent = parts\n                        .flatMap((part) =>\n                          part.type === \"text\" ? [part.text] : [],\n                        )\n                        .join(\"\")\n                        .trim();\n                      if (!textContent) return;\n                      try {\n                        if (onCopy) {\n                          await onCopy(textContent);\n                        } else {\n                          await copyText(textContent);\n                        }\n                        setCopiedExchangeId(exchange.id);\n                        if (copyResetTimeoutRef.current) {\n                          window.clearTimeout(copyResetTimeoutRef.current);\n                        }\n                        copyResetTimeoutRef.current = window.setTimeout(() => {\n                          setCopiedExchangeId(null);\n                        }, 1500);\n                      } catch {\n                        // noop – copy may fail silently\n                      }\n                    }}\n                  >\n                    {copiedExchangeId === exchange.id ? (\n                      <CheckIcon size={18} />\n                    ) : (\n                      <CopyIcon size={18} />\n                    )}\n                  </button>\n                </div>\n              </article>\n            );\n          })}\n      </div>\n    </div>\n  );\n});\n\n// ============================================================================\n// Hits List Component\n// ============================================================================\n\ninterface HitsActionsProps {\n  query: string;\n  isSelected: boolean;\n  onAskAI: () => void;\n  onHoverIndex?: (index: number) => void;\n  hoverEnabled?: boolean;\n}\n\nconst HitsActions = memo(function HitsActions({\n  query,\n  isSelected,\n  onAskAI,\n  onHoverIndex,\n  hoverEnabled,\n}: HitsActionsProps) {\n  return (\n    <div className=\"list-none p-0 m-0 animate-in fade-in-0 slide-in-from-top-1\">\n      <article\n        onClick={onAskAI}\n        className=\"my-1 p-3 rounded-lg bg-background flex items-center gap-4 cursor-pointer select-none whitespace-nowrap transition-all duration-150 hover:bg-blue-50 hover:shadow-lg hover:-translate-y-px aria-selected:bg-blue-50 aria-selected:shadow-lg aria-selected:-translate-y-px dark:hover:bg-slate-900 dark:aria-selected:bg-slate-900\"\n        aria-label=\"Ask AI\"\n        title=\"Ask AI\"\n        // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: hand crafted\n        role=\"option\"\n        aria-selected={isSelected}\n        onMouseEnter={() => {\n          if (!hoverEnabled) return;\n          onHoverIndex?.(0);\n        }}\n        onMouseMove={() => {\n          if (!hoverEnabled) return;\n          onHoverIndex?.(0);\n        }}\n      >\n        <SparklesIcon strokeWidth={1.5} size={20} />\n        <p className=\"text-base text-foreground m-0 font-normal\">\n          Ask AI:{\" \"}\n          <span className=\"text-blue-600 bg-transparent underline decoration-1 underline-offset-4 aria-selected:text-blue-600 aria-selected:bg-transparent aria-selected:underline aria-selected:decoration-1 aria-selected:underline-offset-4\">\n            &quot;{query}&quot;\n          </span>\n        </p>\n      </article>\n    </div>\n  );\n});\n\ninterface HitsListProps {\n  hits: Hit<BaseHit>[];\n  query: string;\n  selectedIndex: number;\n  onAskAI: () => void;\n  attributes: HitsAttributesMapping;\n  onHoverIndex?: (index: number) => void;\n  hoverEnabled?: boolean;\n  sendEvent?: (\n    eventType: \"click\",\n    hit: Hit<BaseHit>,\n    eventName: string,\n  ) => void;\n  openResultsInNewTab?: boolean;\n}\n\nconst HitsList = memo(function HitsList({\n  hits,\n  query,\n  selectedIndex,\n  onAskAI,\n  attributes,\n  onHoverIndex,\n  hoverEnabled,\n  sendEvent,\n  openResultsInNewTab = true,\n}: HitsListProps) {\n  const [failedImages, setFailedImages] = useState<Record<string, boolean>>({});\n  const mapping = useMemo(\n    () => ({\n      primaryText: attributes.primaryText,\n      secondaryText: attributes.secondaryText,\n      tertiaryText: attributes.tertiaryText,\n      url: attributes.url,\n      image: attributes.image,\n    }),\n    [attributes],\n  );\n\n  if (!attributes || !mapping.primaryText) {\n    throw new Error(\"At least a primaryText is required to display results\");\n  }\n\n  return (\n    <>\n      <HitsActions\n        query={query}\n        isSelected={selectedIndex === 0}\n        onAskAI={onAskAI}\n        onHoverIndex={onHoverIndex}\n        hoverEnabled={hoverEnabled}\n      />\n      <p className=\"text-muted-foreground text-sm mt-4 mb-2\">Results</p>\n      {hits.map((hit, idx) => {\n        const isSel = selectedIndex === idx + 1;\n        const primaryVal = getByPath<string>(hit, mapping.primaryText);\n        const imageUrl = getByPath<string>(hit, mapping.image);\n        const url = getByPath<string>(hit, mapping.url);\n        const hasImage = Boolean(imageUrl);\n        const isImageFailed = failedImages[hit.objectID] || !hasImage;\n        return (\n          <a\n            key={hit.objectID}\n            href={url ?? \"#\"}\n            target={openResultsInNewTab && url ? \"_blank\" : undefined}\n            rel={openResultsInNewTab && url ? \"noopener noreferrer\" : undefined}\n            className={`my-1 p-4 rounded-lg bg-background gap-4 cursor-pointer no-underline text-foreground transition-all duration-150 block hover:bg-blue-50 hover:border-border hover:shadow-lg hover:-translate-y-px aria-selected:bg-blue-50 aria-selected:border-border aria-selected:shadow-lg aria-selected:-translate-y-px dark:hover:bg-slate-900 dark:aria-selected:bg-slate-900 animate-in fade-in-0 zoom-in-95 ${\n              hasImage ? \"flex flex-row items-center gap-4\" : \"\"\n            }`}\n            role=\"option\"\n            aria-selected={isSel}\n            onClick={() => {\n              sendEvent?.(\"click\", hit, \"Hit Clicked\");\n            }}\n            onMouseEnter={() => {\n              if (!hoverEnabled) return;\n              onHoverIndex?.(idx + 1);\n            }}\n            onMouseMove={() => {\n              if (!hoverEnabled) return;\n              onHoverIndex?.(idx + 1);\n            }}\n          >\n            {hasImage ? (\n              <div className=\"w-[100px] h-[100px] self-start flex-[0_0_100px] items-center justify-center overflow-hidden rounded-sm bg-muted\">\n                {!isImageFailed ? (\n                  <img\n                    src={imageUrl as string}\n                    alt={primaryVal || \"\"}\n                    className=\"w-full h-full object-contain rounded-sm\"\n                    onError={() =>\n                      setFailedImages((prev) => ({\n                        ...prev,\n                        [hit.objectID]: true,\n                      }))\n                    }\n                  />\n                ) : (\n                  <div\n                    className=\"flex items-center justify-center w-full h-full text-muted-foreground\"\n                    aria-hidden=\"true\"\n                  >\n                    <SearchIcon />\n                  </div>\n                )}\n              </div>\n            ) : null}\n            <div>\n              <p className=\"text-base text-foreground m-0 mb-2 font-normal [&_mark]:text-blue-600 [&_mark]:bg-transparent [&_mark]:underline [&_mark]:decoration-1 [&_mark]:underline-offset-4 aria-selected:text-blue-600 aria-selected:bg-transparent aria-selected:underline aria-selected:decoration-1 aria-selected:underline-offset-4\">\n                <Highlight\n                  attribute={toAttributePath(mapping.primaryText as string)}\n                  hit={hit}\n                />\n              </p>\n              {mapping.secondaryText ? (\n                <p className=\"text-sm mt-2 text-muted-foreground\">\n                  {getByPath<string>(hit, mapping.secondaryText)}\n                </p>\n              ) : null}\n              {mapping.tertiaryText ? (\n                <p className=\"text-sm text-muted-foreground mt-2\">\n                  {getByPath<string>(hit, mapping.tertiaryText)}\n                </p>\n              ) : null}\n            </div>\n          </a>\n        );\n      })}\n    </>\n  );\n});\n\n// ============================================================================\n// Search Input Component\n// ============================================================================\n\ninterface SearchInputProps {\n  placeholder?: string;\n  className?: string;\n  showChat: boolean;\n  isGenerating?: boolean;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  onClose: () => void;\n  setShowChat: (show: boolean) => void;\n  onArrowDown?: () => void;\n  onArrowUp?: () => void;\n  onEnter?: (value: string) => boolean;\n  onNewChat?: () => void;\n}\n\nconst SearchLeftButton = memo(function SearchLeftButton({\n  showChat,\n  setShowChat,\n}: {\n  showChat: boolean;\n  setShowChat: (show: boolean) => void;\n}) {\n  if (showChat) {\n    return (\n      <button\n        type=\"button\"\n        onClick={() => setShowChat(false)}\n        className=\"cursor-pointer p-2 rounded-full flex items-center justify-center text-foreground transition-colors hover:text-blue-600\"\n        aria-label=\"Back to search\"\n        title=\"Back to search\"\n      >\n        <ArrowLeftIcon strokeWidth={1.5} />\n      </button>\n    );\n  }\n\n  return (\n    <div\n      role=\"button\"\n      tabIndex={-1}\n      className=\"p-2 rounded-full flex items-center justify-center text-muted-foreground has-[+input:focus]:text-blue-600\"\n      aria-label=\"Search\"\n      title=\"Search\"\n    >\n      <SearchIcon strokeWidth={1.5} />\n    </div>\n  );\n});\n\nconst SearchInput = memo(function SearchInput(props: SearchInputProps) {\n  const { status } = useInstantSearch();\n  const { query, refine } = useSearchBox();\n  const [chatInput, setChatInput] = useState(\"\");\n\n  const isSearchStalled = status === \"stalled\";\n\n  function setQuery(newQuery: string) {\n    if (props.showChat) {\n      setChatInput(newQuery);\n    } else {\n      refine(newQuery);\n    }\n  }\n\n  // Clear the input when entering chat mode\n  useEffect(() => {\n    if (props.showChat) {\n      setChatInput(\"\");\n    }\n  }, [props.showChat]);\n\n  const placeholder = props.isGenerating\n    ? \"Answering...\"\n    : props.showChat\n      ? \"Ask AI anything\"\n      : props.placeholder;\n\n  const currentValue = props.showChat ? chatInput : query || \"\";\n\n  return (\n    <search\n      className={cn(\n        \"flex flex-row items-center bg-background\",\n        props.showChat\n          ? \"mx-4 mt-3 mb-2 gap-1.5 rounded-lg border border-blue-600 py-1.5 pl-2 pr-1.5 dark:border-blue-500\"\n          : \"border-b border-border rounded-t-lg p-2\",\n      )}\n      onSubmit={(event) => {\n        event.preventDefault();\n        event.stopPropagation();\n      }}\n      onReset={(event) => {\n        event.preventDefault();\n        event.stopPropagation();\n\n        setQuery(\"\");\n        if (props.inputRef.current) {\n          props.inputRef.current.focus();\n        }\n      }}\n    >\n      <SearchLeftButton\n        showChat={props.showChat}\n        setShowChat={props.setShowChat}\n      />\n      <input\n        ref={props.inputRef}\n        className={cn(\n          \"peer w-full border-none bg-transparent font-light text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n          props.showChat ? \"py-0.5 text-xl leading-snug\" : \"text-xl\",\n        )}\n        autoComplete=\"off\"\n        autoCorrect=\"off\"\n        autoCapitalize=\"off\"\n        placeholder={placeholder}\n        spellCheck={false}\n        maxLength={512}\n        type=\"search\"\n        value={currentValue}\n        disabled={props.isGenerating}\n        onChange={(event) => {\n          setQuery(event.currentTarget.value);\n        }}\n        onKeyDown={(e) => {\n          if (props.isGenerating) {\n            e.preventDefault();\n            return;\n          }\n          if (e.key === \"ArrowDown\") {\n            e.preventDefault();\n            props.onArrowDown?.();\n            return;\n          }\n          if (e.key === \"ArrowUp\") {\n            e.preventDefault();\n            props.onArrowUp?.();\n            return;\n          }\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            const valueAtEnter = props.showChat ? chatInput : query || \"\";\n            if (props.onEnter?.(valueAtEnter)) {\n              if (props.showChat) {\n                setChatInput(\"\");\n              } else {\n                setQuery(\"\");\n              }\n              return;\n            }\n            const trimmed = valueAtEnter.trim();\n            if (trimmed) {\n              props.setShowChat(true);\n            }\n          }\n        }}\n        // biome-ignore lint/a11y/noAutofocus: expected\n        autoFocus\n      />\n      <div className=\"flex items-center gap-2 ml-auto\">\n        <Button\n          type=\"reset\"\n          variant=\"ghost\"\n          className=\"px-2 text-muted-foreground\"\n          hidden={!currentValue || currentValue.length === 0 || isSearchStalled}\n          onClick={() => {\n            setQuery(\"\");\n            if (props.inputRef.current) {\n              props.inputRef.current.focus();\n            }\n          }}\n        >\n          Clear\n        </Button>\n        {props.showChat ? (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            disabled={props.isGenerating}\n            onClick={() => {\n              setChatInput(\"\");\n              props.onNewChat?.();\n            }}\n          >\n            <SquarePen size={18} />\n          </Button>\n        ) : null}\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          className=\"px-2 text-muted-foreground\"\n          onClick={props.onClose}\n        >\n          esc\n        </Button>\n      </div>\n    </search>\n  );\n});\n\n// ============================================================================\n// No Results Component\n// ============================================================================\n\ninterface NoResultsProps {\n  query: string;\n  onAskAI: () => void;\n  onClear: () => void;\n}\n\nconst NoResults = memo(function NoResults({\n  query,\n  onAskAI,\n  onClear,\n}: NoResultsProps) {\n  return (\n    <div className=\"flex flex-col items-center text-center justify-center gap-2 bg-muted p-8 h-[50vh] text-foreground\">\n      <div className=\"inline-flex items-center justify-center w-10 h-10 rounded-full bg-blue-50 dark:bg-slate-900 text-muted-foreground\">\n        <SearchIcon size={22} />\n      </div>\n      <p className=\"m-0 font-normal text-base\">\n        No results for &quot;{query}&quot;\n      </p>\n      <p className=\"m-0 text-muted-foreground text-sm\">\n        Try a different query or ask AI to help.\n      </p>\n      <div className=\"inline-flex gap-4 mt-4\">\n        <Button className=\"\" onClick={onAskAI}>\n          <CornerDownLeftIcon size={20}></CornerDownLeftIcon>\n          Ask AI\n        </Button>\n        <Button variant=\"ghost\" className=\"\" onClick={onClear}>\n          Clear\n        </Button>\n      </div>\n    </div>\n  );\n});\n\n// ============================================================================\n// Results Panel Component\n// ============================================================================\n\ninterface ResultsPanelProps {\n  showChat: boolean;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  setShowChat: (showChat: boolean) => void;\n  query: string;\n  selectedIndex: number;\n  refine: (query: string) => void;\n  config: SearchWithAskAIConfig;\n  messages: unknown[];\n  error: Error | null;\n  isGenerating: boolean;\n  sendMessage: (options: { text: string }) => void | Promise<void>;\n  onHoverIndex?: (index: number) => void;\n  scrollOnSelectionChange?: boolean;\n  sendEvent?: (\n    eventType: \"click\",\n    hit: Hit<BaseHit>,\n    eventName: string,\n  ) => void;\n  suggestedQuestions?: SuggestedQuestionHit[];\n  onNewChat?: () => void;\n}\n\nconst ResultsPanel = memo(function ResultsPanel({\n  showChat,\n  inputRef,\n  setShowChat,\n  query,\n  selectedIndex,\n  refine,\n  config,\n  messages,\n  error,\n  isGenerating,\n  sendMessage,\n  onHoverIndex,\n  scrollOnSelectionChange = true,\n  sendEvent,\n  suggestedQuestions,\n  onNewChat,\n}: ResultsPanelProps) {\n  const { items } = useHits(\n    config.transformItems ? { transformItems: config.transformItems } : {},\n  );\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [hoverEnabled, setHoverEnabled] = useState(false);\n\n  // Enable hover selection only after the user moves the pointer inside the list\n  useEffect(() => {\n    if (showChat) return;\n    const container = containerRef.current;\n    if (!container) return;\n    setHoverEnabled(false);\n    const enable = () => setHoverEnabled(true);\n    container.addEventListener(\"pointermove\", enable, { once: true });\n    return () => {\n      container.removeEventListener(\"pointermove\", enable);\n    };\n  }, [showChat]);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n  useEffect(() => {\n    if (showChat || !scrollOnSelectionChange) return;\n    const container = containerRef.current;\n    if (!container) return;\n    const selectedEl = container.querySelector(\n      '[aria-selected=\"true\"]',\n    ) as HTMLElement | null;\n    if (!selectedEl) return;\n\n    const padding = 8;\n    const cRect = container.getBoundingClientRect();\n    const iRect = selectedEl.getBoundingClientRect();\n\n    if (iRect.top < cRect.top + padding) {\n      container.scrollTop -= cRect.top + padding - iRect.top;\n    } else if (iRect.bottom > cRect.bottom - padding) {\n      container.scrollTop += iRect.bottom - (cRect.bottom - padding);\n    }\n  }, [selectedIndex, showChat, items.length, scrollOnSelectionChange]);\n\n  const lastSentRef = useRef<string | null>(null);\n  useEffect(() => {\n    if (!showChat) return;\n    const trimmed = (query ?? \"\").trim();\n    if (!trimmed) return;\n    if (lastSentRef.current === trimmed) return;\n    refine(\"\");\n    if (inputRef.current) {\n      inputRef.current.focus();\n    }\n    sendMessage({ text: trimmed });\n    lastSentRef.current = trimmed;\n  }, [showChat, query, inputRef, sendMessage, refine]);\n\n  useEffect(() => {\n    if ((messages as Message[]).length === 0) {\n      lastSentRef.current = null;\n    }\n  }, [messages]);\n\n  const handleSuggestedQuestionClick = useCallback(\n    (question: string) => {\n      const trimmed = question.trim();\n      if (!trimmed || isGenerating) {\n        return;\n      }\n      sendMessage({ text: trimmed });\n    },\n    [sendMessage, isGenerating],\n  );\n\n  if (showChat) {\n    return (\n      <ChatWidget\n        messages={messages as unknown as Message[]}\n        error={error as Error | null}\n        isGenerating={isGenerating}\n        applicationId={config.applicationId}\n        apiKey={config.apiKey}\n        assistantId={config.assistantId}\n        agentStudio={config.agentStudio}\n        suggestedQuestions={suggestedQuestions}\n        onSuggestedQuestionClick={handleSuggestedQuestionClick}\n        onNewChat={onNewChat}\n      />\n    );\n  }\n\n  return (\n    <>\n      <div\n        ref={containerRef}\n        className=\"flex flex-col h-[91vh] md:h-[50vh] bg-muted p-4 overflow-y-auto\"\n        role=\"listbox\"\n      >\n        <HitsList\n          hits={items}\n          query={query}\n          selectedIndex={selectedIndex}\n          onAskAI={() => setShowChat(true)}\n          attributes={config.attributes}\n          onHoverIndex={onHoverIndex}\n          hoverEnabled={hoverEnabled}\n          sendEvent={sendEvent}\n          openResultsInNewTab={config.openResultsInNewTab}\n        />\n      </div>\n    </>\n  );\n});\n\n// ============================================================================\n// Search Box Component\n// ============================================================================\n\ninterface SearchBoxProps {\n  query?: string;\n  className?: string;\n  placeholder?: string;\n  showChat: boolean;\n  isGenerating?: boolean;\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  refine: (query: string) => void;\n  setShowChat: (show: boolean) => void;\n  onClose?: () => void;\n  onArrowDown?: () => void;\n  onArrowUp?: () => void;\n  onEnter?: (value: string) => boolean;\n  onNewChat?: () => void;\n}\n\nconst SearchBox = memo(function SearchBox(props: SearchBoxProps) {\n  return (\n    <SearchInput\n      className={props.className}\n      placeholder={props.placeholder}\n      showChat={props.showChat}\n      isGenerating={props.isGenerating}\n      inputRef={props.inputRef}\n      setShowChat={props.setShowChat}\n      onClose={props.onClose || (() => {})}\n      onArrowDown={props.onArrowDown}\n      onArrowUp={props.onArrowUp}\n      onEnter={props.onEnter}\n      onNewChat={props.onNewChat}\n    />\n  );\n});\n\n// ============================================================================\n// Footer Component\n// ============================================================================\n\nconst Footer = memo(function Footer({ showChat }: { showChat: boolean }) {\n  const basePoweredByUrl =\n    \"https://www.algolia.com/developers?utm_medium=referral&utm_content=powered_by&utm_campaign=sitesearch\";\n  const poweredByHref =\n    typeof window !== \"undefined\"\n      ? `${basePoweredByUrl}&utm_source=${encodeURIComponent(\n          window.location.hostname,\n        )}`\n      : basePoweredByUrl;\n  return (\n    <div className=\"flex items-center justify-between bg-background rounded-b-lg border-t border-border p-4\">\n      <div className=\"inline-flex items-center gap-4 text-foreground text-sm\">\n        <div className=\"flex items-center gap-2 text-sm font-light text-muted-foreground\">\n          <kbd className=\"bg-muted rounded-sm h-6 flex items-center justify-center p-0.5 font-mono text-base text-muted-foreground\">\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"20\"\n              height=\"20\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                fill=\"currentColor\"\n                d=\"m6.8 13l2.9 2.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-4.6-4.6q-.15-.15-.213-.325T3.426 12t.063-.375t.212-.325l4.6-4.6q.275-.275.7-.275t.7.275t.275.7t-.275.7L6.8 11H19V8q0-.425.288-.712T20 7t.713.288T21 8v3q0 .825-.587 1.413T19 13z\"\n              />\n            </svg>\n          </kbd>\n          <span>{showChat ? \"Ask question\" : \"Open\"}</span>\n        </div>\n\n        <div className=\"flex items-center gap-2 text-sm font-light text-muted-foreground\">\n          <kbd className=\"bg-muted rounded-sm h-6 flex items-center justify-center p-0.5 font-mono text-base text-muted-foreground\">\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"20\"\n              height=\"20\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                fill=\"currentColor\"\n                d=\"m11 7.825l-4.9 4.9q-.3.3-.7.288t-.7-.313q-.275-.3-.288-.7t.288-.7l6.6-6.6q.15-.15.325-.212T12 4.425t.375.063t.325.212l6.6 6.6q.275.275.275.688t-.275.712q-.3.3-.712.3t-.713-.3L13 7.825V19q0 .425-.288.713T12 20t-.712-.288T11 19z\"\n              />\n            </svg>\n          </kbd>\n          <kbd className=\"bg-muted rounded-sm h-6 flex items-center justify-center p-0.5 font-mono text-base text-muted-foreground\">\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"20\"\n              height=\"20\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                fill=\"currentColor\"\n                d=\"M11 16.175V5q0-.425.288-.712T12 4t.713.288T13 5v11.175l4.9-4.9q.3-.3.7-.288t.7.313q.275.3.287.7t-.287.7l-6.6 6.6q-.15.15-.325.213t-.375.062t-.375-.062t-.325-.213l-6.6-6.6q-.275-.275-.275-.687T4.7 11.3q.3-.3.713-.3t.712.3z\"\n              />\n            </svg>\n          </kbd>\n          <span>Navigate</span>\n        </div>\n      </div>\n      <div className=\"inline-flex\">\n        {/* 🚧 DO NOT REMOVE the logo if you are on a Free plan\n         * https://support.algolia.com/hc/en-us/articles/17226079853073-Is-displaying-the-Algolia-logo-required\n         */}\n        <a\n          className=\"inline-flex items-center gap-2 text-muted-foreground text-sm no-underline transition-colors hover:text-blue-600\"\n          href={poweredByHref}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n        >\n          <span className=\"md:block hidden\">Powered by </span>\n          <AlgoliaLogo />\n        </a>\n      </div>\n    </div>\n  );\n});\n\n// ============================================================================\n// Search Modal (Inner Content)\n// ============================================================================\n\ninterface SearchModalProps {\n  onClose?: () => void;\n  config: SearchWithAskAIConfig;\n}\n\nfunction SearchModal({ onClose, config }: SearchModalProps) {\n  const { query, refine } = useSearchBox();\n  const inputRef = useRef<HTMLInputElement | null>(null);\n\n  const results = useInstantSearch();\n  const { items, sendEvent } = useHits(\n    config.transformItems ? { transformItems: config.transformItems } : {},\n  );\n  const { showChat, setShowChat, handleShowChat } = useSearchState();\n\n  // Track the message count when a thread depth error occurred\n  const [threadDepthErrorAtMessageCount, setThreadDepthErrorAtMessageCount] =\n    useState<number | null>(null);\n\n  const {\n    messages,\n    error,\n    isGenerating,\n    sendMessage,\n    startNewConversation,\n    status,\n    hasThreadDepthError,\n  } = useAskai({\n    applicationId: config.applicationId,\n    apiKey: config.apiKey,\n    indexName: config.indexName,\n    assistantId: config.assistantId,\n    agentStudio: config.agentStudio,\n  });\n\n  // Monitor for thread depth errors (AI-217)\n  useEffect(() => {\n    const err = error as Error & { code?: string };\n    if (\n      status === \"error\" &&\n      (err?.code === \"AI-217\" || error?.message?.includes(\"AI-217\"))\n    ) {\n      // Only record if we have an assistant message (real conversation)\n      messages.some((m) => m.role === \"assistant\") &&\n        setThreadDepthErrorAtMessageCount(messages.length);\n    } else if (\n      status !== \"error\" &&\n      threadDepthErrorAtMessageCount &&\n      messages.length < threadDepthErrorAtMessageCount\n    ) {\n      setThreadDepthErrorAtMessageCount(null);\n    }\n  }, [status, error, messages, threadDepthErrorAtMessageCount]);\n\n  const suggestedQuestionsClient = useMemo(() => {\n    const client = algoliasearch(config.applicationId, config.apiKey);\n    client.addAlgoliaAgent(\"algolia-sitesearch\");\n    return client;\n  }, [config.applicationId, config.apiKey]);\n\n  const suggestedQuestions = useSuggestedQuestions({\n    searchClient: suggestedQuestionsClient,\n    assistantId: config.assistantId,\n    suggestedQuestionsEnabled: config.suggestedQuestionsEnabled ?? false,\n    isOpen: showChat,\n  });\n\n  const noResults = results.results?.nbHits === 0;\n  const {\n    selectedIndex,\n    moveDown,\n    moveUp,\n    activateSelection,\n    hoverIndex,\n    selectionOrigin,\n  } = useKeyboardNavigation(\n    showChat,\n    items,\n    query,\n    config.openResultsInNewTab ?? true,\n  );\n\n  const handleActivateSelection = useCallback((): boolean => {\n    // Send click event for keyboard navigation before activating\n    if (selectedIndex > 0) {\n      const hit = items[selectedIndex - 1];\n      if (hit) {\n        sendEvent?.(\"click\", hit, \"Hit Clicked\");\n      }\n    }\n\n    if (activateSelection()) {\n      if (selectedIndex === 0) {\n        handleShowChat(true);\n      }\n      return true;\n    }\n    return false;\n  }, [activateSelection, selectedIndex, handleShowChat, items, sendEvent]);\n\n  const showResultsPanel = (!noResults && !!query) || showChat;\n\n  const handleNewChat = useCallback(() => {\n    startNewConversation();\n    setThreadDepthErrorAtMessageCount(null);\n    setShowChat(true);\n    refine(\"\");\n    if (inputRef.current) {\n      inputRef.current.focus();\n    }\n  }, [startNewConversation, setShowChat, refine]);\n\n  const handleSetShowChat = useCallback(\n    (v: boolean) => {\n      if (!v && hasThreadDepthError) {\n        startNewConversation();\n        setThreadDepthErrorAtMessageCount(null);\n      }\n      setShowChat(v);\n    },\n    [setShowChat, hasThreadDepthError, startNewConversation],\n  );\n\n  return (\n    <>\n      <Configure\n        hitsPerPage={config.hitsPerPage || 8}\n        {...(config.searchParameters || {})}\n      />\n      <div className=\"flex flex-col\">\n        <SearchBox\n          query={query}\n          placeholder={config.placeholder || \"What are you looking for?\"}\n          refine={refine}\n          showChat={showChat}\n          isGenerating={isGenerating}\n          setShowChat={handleSetShowChat}\n          onClose={onClose}\n          onArrowDown={moveDown}\n          onArrowUp={moveUp}\n          onEnter={(value) => {\n            const trimmed = (value ?? \"\").trim();\n            if (showChat && trimmed) {\n              refine(trimmed);\n              return true;\n            }\n            return handleActivateSelection();\n          }}\n          inputRef={inputRef}\n          onNewChat={handleNewChat}\n        />\n        {showResultsPanel && (\n          <ResultsPanel\n            showChat={showChat}\n            inputRef={inputRef}\n            setShowChat={handleSetShowChat}\n            query={query}\n            selectedIndex={selectedIndex}\n            refine={refine}\n            config={config}\n            messages={messages as unknown[]}\n            error={error as Error | null}\n            isGenerating={isGenerating}\n            sendMessage={sendMessage}\n            onHoverIndex={hoverIndex}\n            scrollOnSelectionChange={selectionOrigin !== \"pointer\"}\n            sendEvent={sendEvent}\n            suggestedQuestions={suggestedQuestions}\n            onNewChat={handleNewChat}\n          />\n        )}\n        {noResults && query && !showChat && (\n          <NoResults\n            query={query}\n            onAskAI={() => {\n              setShowChat(true);\n            }}\n            onClear={() => {\n              refine(\"\");\n              if (inputRef.current) {\n                inputRef.current.focus();\n              }\n            }}\n          />\n        )}\n      </div>\n      <Footer showChat={showChat} />\n    </>\n  );\n}\n\n// ============================================================================\n// Main Export Component\n// ============================================================================\n\nexport default function SearchExperience(config: SearchWithAskAIConfig) {\n  const searchClient = useMemo(() => {\n    const client = algoliasearch(config.applicationId, config.apiKey);\n    client.addAlgoliaAgent(\"algolia-sitesearch\");\n    return client;\n  }, [config.applicationId, config.apiKey]);\n  const [isModalOpen, setIsModalOpen] = useState(false);\n\n  const openModal = () => setIsModalOpen(true);\n  const closeModal = () => setIsModalOpen(false);\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === \"k\") {\n        event.preventDefault();\n        setIsModalOpen(true);\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, []);\n\n  const buttonProps = {\n    ...config.buttonProps,\n    onClick: openModal,\n  };\n\n  return (\n    <>\n      <SearchButton {...buttonProps}>{config.buttonText}</SearchButton>\n      <Modal isOpen={isModalOpen} onClose={closeModal}>\n        <InstantSearch\n          searchClient={searchClient}\n          indexName={config.indexName}\n          future={{ preserveSharedStateOnUnmount: true }}\n          insights={config.insights ?? true}\n        >\n          <SearchModal onClose={closeModal} config={config} />\n        </InstantSearch>\n      </Modal>\n    </>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/registry/experiences/search-askai/hooks/use-keyboard-navigation.ts",
      "content": "import type { BaseHit, Hit } from \"instantsearch.js\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\n\ninterface UseKeyboardNavigationReturn {\n  selectedIndex: number;\n  moveDown: () => void;\n  moveUp: () => void;\n  activateSelection: () => boolean;\n  hoverIndex: (index: number) => void;\n  selectionOrigin: \"keyboard\" | \"pointer\" | \"init\";\n}\n\nexport function useKeyboardNavigation(\n  showChat: boolean,\n  hits: Hit<BaseHit>[],\n  query: string,\n  openResultsInNewTab = true,\n): UseKeyboardNavigationReturn {\n  const [selectedIndex, setSelectedIndex] = useState<number>(0);\n  const [selectionOrigin, setSelectionOrigin] = useState<\n    \"keyboard\" | \"pointer\" | \"init\"\n  >(\"init\");\n\n  const totalItems = useMemo(() => hits.length + 1, [hits.length]); // +1 for Ask AI\n\n  const moveDown = useCallback(() => {\n    if (showChat || totalItems === 0) return;\n    setSelectedIndex((prev) => (prev + 1) % totalItems);\n    setSelectionOrigin(\"keyboard\");\n  }, [showChat, totalItems]);\n\n  const moveUp = useCallback(() => {\n    if (showChat || totalItems === 0) return;\n    setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);\n    setSelectionOrigin(\"keyboard\");\n  }, [showChat, totalItems]);\n\n  const hoverIndex = useCallback(\n    (index: number) => {\n      if (showChat || index < 0 || index >= totalItems) return;\n      setSelectedIndex(index);\n      setSelectionOrigin(\"pointer\");\n    },\n    [showChat, totalItems],\n  );\n\n  const activateSelection = useCallback((): boolean => {\n    if (showChat) return false;\n    if (selectedIndex === 0) {\n      return true; // Let parent handle AI activation\n    }\n    if (selectedIndex > 0) {\n      const hit = hits[selectedIndex - 1];\n      const url = typeof hit?.url === \"string\" ? hit.url : undefined;\n      if (url) {\n        if (openResultsInNewTab) {\n          window.open(url, \"_blank\", \"noopener,noreferrer\");\n        } else {\n          window.location.assign(url);\n        }\n        return true;\n      }\n    }\n    return false;\n  }, [showChat, selectedIndex, hits, openResultsInNewTab]);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: expected\n  useEffect(() => {\n    setSelectedIndex(0);\n    setSelectionOrigin(\"init\");\n  }, [query, showChat]);\n\n  return {\n    selectedIndex,\n    moveDown,\n    moveUp,\n    activateSelection,\n    hoverIndex,\n    selectionOrigin,\n  };\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "src/registry/experiences/search-askai/hooks/use-search-state.ts",
      "content": "import { useCallback, useState } from \"react\";\n\ninterface UseSearchStateReturn {\n  showChat: boolean;\n  setShowChat: (show: boolean) => void;\n  handleShowChat: (show: boolean) => void;\n}\n\nexport function useSearchState(): UseSearchStateReturn {\n  const [showChat, setShowChat] = useState(false);\n\n  const handleShowChat = useCallback((show: boolean) => {\n    setShowChat(show);\n  }, []);\n\n  return {\n    showChat,\n    setShowChat,\n    handleShowChat,\n  };\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "src/registry/experiences/search-askai/hooks/use-askai.ts",
      "content": "import { useChat } from \"@ai-sdk/react\";\nimport {\n  DefaultChatTransport,\n  generateId,\n  lastAssistantMessageIsCompleteWithToolCalls,\n} from \"ai\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\n\nexport interface AskAIConfig {\n  applicationId: string;\n  apiKey: string;\n  indexName: string;\n  assistantId: string;\n  /**\n   * When true, route to Agent Studio endpoints and schemas.\n   * When false or unset, use existing Ask AI endpoints (default).\n   */\n  agentStudio?: boolean;\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  if (message.includes(\"ai-217\") || message.includes(\"conversation depth\")) {\n    return true;\n  }\n  try {\n    const parsed = JSON.parse(error.message) as {\n      code?: string;\n      message?: string;\n    };\n    if (\n      typeof parsed.code === \"string\" &&\n      parsed.code.toUpperCase() === \"AI-217\"\n    ) {\n      return true;\n    }\n    const nested = (parsed.message ?? \"\").toLowerCase();\n    return nested.includes(\"ai-217\") || nested.includes(\"conversation depth\");\n  } catch {\n    return false;\n  }\n}\n\nfunction threadDepthRawMessage(error: unknown): string {\n  if (!error) return \"\";\n  if (error instanceof Error) return (error.message ?? \"\").trim();\n  if (\n    typeof error === \"object\" &&\n    error !== null &&\n    \"message\" in error &&\n    typeof (error as { message: unknown }).message === \"string\"\n  ) {\n    return (error as { message: string }).message.trim();\n  }\n  return \"\";\n}\n\n/** Plain or JSON `{\"message\":\"…\"}` body from the API for thread-depth errors. */\nexport function threadDepthErrorDetail(error?: unknown): string | undefined {\n  if (!isThreadDepthError(error as Error | null)) return undefined;\n  const raw = threadDepthRawMessage(error);\n  if (!raw) return undefined;\n  try {\n    const parsed = JSON.parse(raw) as { message?: string };\n    if (typeof parsed.message === \"string\" && parsed.message.trim()) {\n      return parsed.message.trim();\n    }\n  } catch {\n    // not JSON\n  }\n  return raw;\n}\n\nconst BASE_ASKAI_URL = \"https://askai.algolia.com\";\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 [chatId, setChatId] = useState(() => generateId());\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    id: chatId,\n    transport,\n    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,\n  });\n\n  const chatRef = useRef(chat);\n  chatRef.current = chat;\n\n  const startNewConversation = useCallback(() => {\n    chatRef.current.stop();\n    chatRef.current.clearError();\n    setChatId(generateId());\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    startNewConversation,\n    isGenerating,\n    hasThreadDepthError,\n  };\n}\n\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 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\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",
      "type": "registry:hook"
    },
    {
      "path": "src/registry/experiences/search-askai/hooks/use-suggested-questions.ts",
      "content": "import type { Hit, LiteClient, SearchResponse } from \"algoliasearch/lite\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport const SUGGESTED_QUETIONS_INDEX_NAME =\n  \"algolia_ask_ai_suggested_questions\";\n\ntype SuggestedQuestion = {\n  appId: string;\n  assistantId: string;\n  question: string;\n  locale?: string;\n  state: \"published\";\n  source: string;\n  order: number;\n};\n\nexport type SuggestedQuestionHit = Hit<SuggestedQuestion>;\n\ntype UseSuggestedQuestionsProps = {\n  assistantId: string | null;\n  suggestedQuestionsEnabled?: boolean;\n  searchClient: LiteClient;\n  isOpen?: boolean;\n};\n\nexport const useSuggestedQuestions = ({\n  assistantId,\n  suggestedQuestionsEnabled = false,\n  searchClient,\n  isOpen = false,\n}: UseSuggestedQuestionsProps): SuggestedQuestionHit[] => {\n  const [suggestedQuestions, setSuggestedQuestions] = useState<\n    SuggestedQuestionHit[]\n  >([]);\n  const hasFetchedRef = useRef(false);\n\n  useEffect(() => {\n    // Only fetch once when sidepanel opens\n    if (hasFetchedRef.current || !isOpen) {\n      return;\n    }\n\n    const getSuggestedQuestions = async (): Promise<void> => {\n      if (!suggestedQuestionsEnabled || !assistantId || assistantId === \"\") {\n        return;\n      }\n\n      try {\n        const { results } = await searchClient.search<SuggestedQuestion>({\n          requests: [\n            {\n              indexName: SUGGESTED_QUETIONS_INDEX_NAME,\n              filters: `state:published AND assistantId:${assistantId}`,\n              hitsPerPage: 3,\n            },\n          ],\n        });\n\n        const result = results[0] as SearchResponse<SuggestedQuestion>;\n        setSuggestedQuestions(result.hits);\n        hasFetchedRef.current = true;\n      } catch (error) {\n        console.error(\"Failed to fetch suggested questions:\", error);\n      }\n    };\n\n    getSuggestedQuestions();\n  }, [suggestedQuestionsEnabled, assistantId, isOpen, searchClient]);\n\n  return suggestedQuestions;\n};\n",
      "type": "registry:hook"
    },
    {
      "path": "src/registry/experiences/search-askai/page.tsx",
      "content": "\"use client\";\n\nimport SearchAskAI from \"@/registry/experiences/search-askai/components/search-ai\";\n\nexport default function Page() {\n  return (\n    <div className=\"flex items-center justify-center min-h-[100px] md:min-h-[400px] relative p-4\">\n      <SearchAskAI\n        applicationId=\"06YAZFOHSQ\"\n        apiKey=\"94b6afdc316917b6e6cdf2763fa561df\"\n        indexName=\"algolia_podcast_sample_dataset\"\n        assistantId=\"UpR727VnXnoG\"\n        attributes={{\n          primaryText: \"title\",\n          secondaryText: \"description\",\n          tertiaryText: \"itunesAuthor\",\n          image: \"imageUrl\",\n          url: \"url\",\n        }}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/search-ai/page.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "animate-shiny-text": "shiny-text 8s infinite"
    }
  },
  "css": {
    "@import \"tw-animate-css\"": {},
    "input[type=\"search\"]::-webkit-search-decoration,input[type=\"search\"]::-webkit-search-cancel-button,input[type=\"search\"]::-webkit-search-results-button,input[type=\"search\"]::-webkit-search-results-decoration": "-webkit-appearance: none;",
    "@keyframes shiny-text": {
      "0%,90%,100%": "{\nbackground-position: calc(-100% - var(--shiny-width)) 0;\n}",
      "30%,60%": "{\nbackground-position: calc(100% + var(--shiny-width)) 0;\n}"
    }
  },
  "categories": [
    "search",
    "ai"
  ]
}