{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sidepanel-askai",
  "type": "registry:block",
  "title": "Sidepanel Ask AI",
  "description": "Sidepanel chat experience with Ask AI, powered by Algolia's Ask AI",
  "dependencies": [
    "@ai-sdk/react@^2.0.4",
    "ai@^5.0.30",
    "algoliasearch@^5",
    "lucide-react",
    "marked",
    "tw-animate-css"
  ],
  "devDependencies": [
    "tw-animate-css"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/experiences/sidepanel-askai/components/sidepanel-askai.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 */\n\"use client\";\n\nimport type { UIMessage } from \"@ai-sdk/react\";\nimport type { UIDataTypes, UIMessagePart } from \"ai\";\nimport { liteClient } from \"algoliasearch/lite\";\nimport type { BaseHit, Hit } from \"instantsearch.js\";\nimport {\n  ArrowUpIcon,\n  BrainIcon,\n  CheckIcon,\n  CopyIcon,\n  Link2Icon,\n  Maximize2,\n  Minimize2,\n  Sparkles,\n  SquarePen,\n  ThumbsDown,\n  ThumbsUp,\n  XIcon,\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 { 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/sidepanel-askai/hooks/use-askai\";\n\nimport {\n  type SuggestedQuestionHit,\n  useSuggestedQuestions,\n} from \"@/registry/experiences/sidepanel-askai/hooks/use-suggested-questions\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SidepanelAskAIConfig {\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  /** Suggested Questions Enabled (optional, defaults to false) */\n  suggestedQuestionsEnabled?: boolean;\n  /** Placeholder text for input (optional, defaults to \"Ask AI anything about Algolia\") */\n  placeholder?: string;\n  /** Custom button text (optional, defaults to \"Ask AI\") */\n  buttonText?: string;\n  /** Custom button props (optional) */\n  buttonProps?: React.ComponentProps<typeof Button>;\n  /** Display variant (optional, defaults to 'floating') */\n  variant?: \"floating\" | \"inline\";\n  /** Route Ask AI requests through Agent Studio endpoints (optional, defaults to false). */\n  agentStudio?: boolean;\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\ninterface ExtractedLink {\n  url: string;\n  title?: string;\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\nfunction extractLinksFromMessage(message: Message | null): ExtractedLink[] {\n  const links: ExtractedLink[] = [];\n\n  // Used to dedupe multiple urls\n  const seen = new Set<string>();\n\n  if (!message) {\n    return [];\n  }\n\n  message.parts.forEach((part) => {\n    if (part.type !== \"text\") {\n      return;\n    }\n\n    if (typeof part.text !== \"string\" || part.text.length === 0) {\n      return;\n    }\n\n    const markdownLinkRegex = /\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\n    const markdownImageRegex = /!\\[([^\\]]*)\\]\\(([^)]+)\\)/g;\n    const plainLinkRegex = /(?<!\\]\\()https?:\\/\\/[^\\s<>\"{}|\\\\^`[\\]]+/g;\n\n    // Strip out all code blocks e.g. ```\n    const textWithoutCodeBlocks = part.text.replace(/```[\\s\\S]*?```/g, \"\");\n\n    // Strip out all inline code blocks e.g. `\n    const cleanText = textWithoutCodeBlocks.replace(/`[^`]*`/g, \"\");\n\n    // Get all markdown image links to exclude them\n    const imageMatches = cleanText.matchAll(markdownImageRegex);\n    const imageUrls = new Set<string>();\n    for (const match of imageMatches) {\n      imageUrls.add(match[2]);\n    }\n\n    // Get all markdown based links e.g. []()\n    const markdownMatches = cleanText.matchAll(markdownLinkRegex);\n\n    // Parses the title and url from the found links\n    for (const match of markdownMatches) {\n      const title = match[1].trim();\n      const url = sanitizeUrl(match[2]);\n\n      // Skip image URLs\n      if (!url || imageUrls.has(url) || imageUrls.has(match[2])) {\n        continue;\n      }\n\n      if (!seen.has(url)) {\n        seen.add(url);\n        links.push({ url, title: title || undefined });\n      }\n    }\n\n    // Get all \"plain\" links e.g. https://algolia.com/doc\n    const plainUrls = cleanText.matchAll(plainLinkRegex);\n\n    for (const match of plainUrls) {\n      // Strip any extra punctuation\n      const cleanUrl = sanitizeUrl(match[0].replace(/[.,;:!?]+$/, \"\"));\n\n      // Skip image URLs\n      if (!cleanUrl || imageUrls.has(cleanUrl)) {\n        continue;\n      }\n\n      if (!seen.has(cleanUrl)) {\n        seen.add(cleanUrl);\n        links.push({ url: cleanUrl });\n      }\n    }\n  });\n\n  return links;\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// ============================================================================\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// 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 [word-break:break-word] leading-relaxed max-w-none flex flex-col word [&_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// Related Sources Component\n// ============================================================================\n\ninterface RelatedSourcesProps {\n  links: ExtractedLink[];\n}\n\nconst RelatedSources = memo(function RelatedSources({\n  links,\n}: RelatedSourcesProps) {\n  if (links.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className=\"mt-4 pt-4 border-t border-border\">\n      <h3 className=\"text-xs font-medium text-muted-foreground mb-3\">\n        Related sources\n      </h3>\n      <div className=\"flex flex-wrap gap-2\">\n        {links.map((link, index) => {\n          const displayText = link.title || link.url;\n\n          return (\n            <a\n              key={`${link.url}-${index}`}\n              href={link.url}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background text-foreground hover:bg-blue-50 dark:hover:bg-slate-900 hover:border-blue-600 transition-colors duration-200 no-underline\"\n            >\n              <Link2Icon className=\"shrink-0\" size={16} />\n              <span>{displayText}</span>\n            </a>\n          );\n        })}\n      </div>\n    </div>\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  const messagesEndRef = useRef<HTMLDivElement>(null);\n\n  // Group messages into exchanges (user + assistant pairs)\n  const exchanges = useMemo(() => {\n    const grouped: Exchange[] = [];\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  // Auto-scroll to bottom when new messages arrive\n  // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on message changes\n  useEffect(() => {\n    messagesEndRef.current?.scrollIntoView({ behavior: \"smooth\" });\n  }, [exchanges.length, isGenerating]);\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-full overflow-y-auto p-4 bg-muted\">\n      <div className=\"flex flex-col gap-4\">\n        {exchanges.length === 0 && (\n          <div className=\"flex flex-col gap-4 py-8\">\n            <h2 className=\"text-2xl font-semibold text-foreground\">\n              How can I help you today?\n            </h2>\n            <p className=\"text-muted-foreground\">\n              I search through your content to help you find answers to your\n              questions, fast.\n            </p>\n            {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                    onClick={() => onSuggestedQuestionClick(question.question)}\n                  >\n                    {question.question}\n                  </Button>\n                ))}\n              </div>\n            )}\n          </div>\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.map((exchange) => {\n          const isLastExchange =\n            exchanges[exchanges.length - 1]?.id === exchange.id;\n\n          return (\n            <article key={exchange.id} className=\"rounded-sm bg-background p-4\">\n              <div className=\"flex items-start gap-3\">\n                <div className=\"font-semibold text-xl 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                    <>\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                                  <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                                  <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                                  <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                        <RelatedSources\n                          links={extractLinksFromMessage(\n                            exchange.assistantMessage,\n                          )}\n                        />\n                      }\n                    </>\n                  ) : (\n                    <div className=\"text-muted-foreground\">\n                      <AnimatedShinyText>\n                        {isGenerating && isLastExchange ? \"Thinking...\" : \"\"}\n                      </AnimatedShinyText>\n                    </div>\n                  )}\n                </div>\n              </div>\n\n              {exchange.assistantMessage && !isGenerating ? (\n                <div className=\"mt-4 flex items-center justify-start gap-2\">\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                  <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              ) : null}\n            </article>\n          );\n        })}\n        <div ref={messagesEndRef} />\n      </div>\n    </div>\n  );\n});\n\n// ============================================================================\n// Sidepanel Component\n// ============================================================================\n\ninterface SidepanelProps {\n  isOpen: boolean;\n  onClose: () => void;\n  config: SidepanelAskAIConfig;\n  messages: Message[];\n  error: Error | null;\n  isGenerating: boolean;\n  suggestedQuestions: SuggestedQuestionHit[];\n  sendMessage: (options: { text: string }) => void | Promise<void>;\n  inputRef: React.RefObject<HTMLTextAreaElement | null>;\n  onOpenNewConversation: () => void;\n}\n\nconst MAX_PROMPT_ROWS = 20;\n\nconst Sidepanel = memo(function Sidepanel({\n  isOpen,\n  onClose,\n  config,\n  messages,\n  error,\n  isGenerating,\n  suggestedQuestions,\n  sendMessage,\n  inputRef,\n  onOpenNewConversation,\n}: SidepanelProps) {\n  const [inputValue, setInputValue] = useState(\"\");\n  const [shouldRender, setShouldRender] = useState(false);\n  const [isVisible, setIsVisible] = useState(false);\n  const [isMaximized, setIsMaximized] = useState(false);\n  const variant = config.variant || \"floating\";\n\n  // Handle mount/unmount and closing animation\n  useEffect(() => {\n    if (isOpen) {\n      setShouldRender(true);\n      // allow initial render before animating in\n      requestAnimationFrame(() => {\n        setIsVisible(true);\n      });\n      // Focus input when opening\n      setTimeout(() => {\n        inputRef.current?.focus();\n      }, 100);\n    } else if (shouldRender) {\n      // Start closing animation\n      setIsVisible(false);\n      // Unmount after animation completes\n      const timer = setTimeout(() => {\n        setShouldRender(false);\n      }, 200); // Match transition duration\n      return () => clearTimeout(timer);\n    }\n  }, [isOpen, shouldRender, inputRef]);\n\n  useEffect(() => {\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\" && isOpen) {\n        onClose();\n      }\n    };\n\n    if (isOpen) {\n      document.addEventListener(\"keydown\", handleEscape);\n    }\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [isOpen, onClose]);\n\n  // Inline variant: push page content by adjusting body padding-right on desktop\n  useEffect(() => {\n    if (variant !== \"inline\") return;\n    if (typeof window === \"undefined\") return;\n\n    const isDesktop = window.matchMedia(\"(min-width: 768px)\").matches;\n    const panelWidth = isMaximized ? 580 : 360;\n\n    if (isOpen && isDesktop) {\n      const prevPadding = document.body.style.paddingRight;\n      const prevTransition = document.body.style.transition;\n      document.body.style.transition =\n        \"padding-right 0.28s cubic-bezier(0.22, 1, 0.36, 1)\";\n      document.body.style.paddingRight = `${panelWidth}px`;\n      return () => {\n        document.body.style.paddingRight = prevPadding;\n        document.body.style.transition = prevTransition;\n      };\n    }\n\n    return;\n  }, [variant, isOpen, isMaximized]);\n\n  const managePromptHeight = useCallback((): void => {\n    if (!inputRef.current) return;\n\n    const textArea = inputRef.current;\n\n    textArea.style.height = \"auto\";\n\n    const styles = getComputedStyle(textArea);\n\n    const lineHeight = parseFloat(styles.lineHeight);\n    const paddingTop = parseFloat(styles.paddingTop);\n    const paddingBottom = parseFloat(styles.paddingBottom);\n    const padding = paddingTop + paddingBottom;\n\n    const fullHeight = textArea.scrollHeight;\n    const maxHeight = MAX_PROMPT_ROWS * lineHeight + padding;\n\n    textArea.style.overflowY = fullHeight > maxHeight ? \"auto\" : \"hidden\";\n    textArea.style.height = `${Math.min(fullHeight, maxHeight)}px`;\n  }, [inputRef]);\n\n  const sendMessageAndReset = useCallback(\n    (text: string) => {\n      const trimmed = text.trim();\n      if (!trimmed || isGenerating) return;\n\n      sendMessage({ text: trimmed });\n      setInputValue(\"\");\n      // Reset textarea height after clearing input\n      setTimeout(() => {\n        managePromptHeight();\n        inputRef.current?.focus();\n      }, 50);\n    },\n    [isGenerating, sendMessage, inputRef, managePromptHeight],\n  );\n\n  const handleSubmit = useCallback(\n    (e?: React.FormEvent) => {\n      e?.preventDefault();\n      sendMessageAndReset(inputValue);\n    },\n    [inputValue, sendMessageAndReset],\n  );\n\n  const handleSuggestedQuestionClick = useCallback(\n    (question: string) => {\n      sendMessageAndReset(question);\n    },\n    [sendMessageAndReset],\n  );\n\n  const resizeSidepanel = useCallback(() => {\n    setIsMaximized((prev) => !prev);\n  }, []);\n\n  if (!shouldRender) return null;\n\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(window.location.hostname)}`\n      : basePoweredByUrl;\n\n  return createPortal(\n    <div\n      className={`fixed inset-0 z-50 ${\n        variant === \"inline\"\n          ? \"bg-transparent dark:bg-transparent md:p-0\"\n          : \"bg-black/20 dark:bg-black/60 md:p-4\"\n      } flex items-center justify-end pointer-events-none ${\n        isVisible ? \"animate-in fade-in-0\" : \"animate-out fade-out-0\"\n      }`}\n      style={{ animationDuration: \"0.2s\" }}\n    >\n      <div\n        className={`bg-background h-screen w-full md:h-full flex flex-col pointer-events-auto transition-all duration-300 ease-out ${variant === \"inline\" ? \"rounded-none border-l border-border\" : \"md:rounded-lg shadow-2xl\"} ${\n          isVisible\n            ? \"animate-in slide-in-from-right\"\n            : \"animate-out slide-out-to-right\"\n        } ${isMaximized ? \"md:w-[580px]\" : \"md:w-[360px]\"}`}\n        onClick={(e) => e.stopPropagation()}\n        style={{\n          animationDuration: \"0.28s\",\n          animationTimingFunction: \"cubic-bezier(0.22, 1, 0.36, 1)\",\n          animationFillMode: \"both\",\n        }}\n      >\n        {/* Header */}\n        <div className=\"flex items-center justify-between px-4 py-2 border-b border-border\">\n          <div className=\"flex items-center gap-2 min-w-0\">\n            <Sparkles\n              className=\"h-[18px] w-[18px] shrink-0 text-blue-600 dark:text-blue-500\"\n              strokeWidth={2}\n              aria-hidden\n            />\n            <h2 className=\"text-sm font-semibold text-foreground\">Ask AI</h2>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              variant=\"ghost\"\n              onClick={onOpenNewConversation}\n              disabled={messages.length === 0}\n              className=\"px-1 text-muted-foreground disabled:cursor-not-allowed\"\n              aria-label=\"Open new conversation\"\n              title=\"Open new conversation\"\n            >\n              <SquarePen size={18} />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              onClick={resizeSidepanel}\n              className=\"hidden md:flex px-1 cursor-pointer text-muted-foreground\"\n              aria-label={isMaximized ? \"Minimize\" : \"Maximize\"}\n              title={isMaximized ? \"Minimize\" : \"Maximize\"}\n            >\n              {isMaximized ? <Minimize2 size={18} /> : <Maximize2 size={18} />}\n            </Button>\n            <Button\n              variant=\"ghost\"\n              className=\"px-1 text-xs text-muted-foreground cursor-pointer\"\n              onClick={onClose}\n              aria-label=\"Close\"\n              title=\"Close\"\n            >\n              <span className=\"hidden md:inline\">\n                <XIcon size={18} />\n              </span>\n              <span className=\"md:hidden\">\n                <XIcon />\n              </span>\n            </Button>\n          </div>\n        </div>\n\n        {/* Chat Content */}\n        <ChatWidget\n          messages={messages}\n          error={error}\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={onOpenNewConversation}\n        />\n\n        {/* Input Bar — match search-askai composer (primary border + rule above footer). */}\n        <div className=\"border-t border-border px-4 pb-4 pt-2\">\n          <form\n            onSubmit={handleSubmit}\n            className={cn(\n              \"flex items-center gap-2 rounded-lg border border-blue-600 py-1.5 pl-2 pr-1.5 transition-all dark:border-blue-500\",\n              \"focus-within:ring-1 focus-within:ring-blue-600 focus-within:ring-offset-0 dark:focus-within:ring-blue-500\",\n            )}\n          >\n            <textarea\n              ref={inputRef}\n              value={inputValue}\n              id=\"sidepanel-input\"\n              onChange={(e) => {\n                setInputValue(e.target.value);\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && !e.shiftKey) {\n                  e.preventDefault();\n                  handleSubmit();\n                }\n              }}\n              onInput={managePromptHeight}\n              rows={1}\n              placeholder={config.placeholder || \"Ask AI anything\"}\n              disabled={isGenerating}\n              className=\"flex-1 border-none bg-background py-0.5 leading-tight text-foreground outline-none placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 resize-none overflow-y-hidden\"\n            />\n            <Button\n              type=\"submit\"\n              variant=\"secondary\"\n              size=\"icon\"\n              className=\"h-10 w-10 shrink-0 text-muted-foreground hover:bg-muted/80 hover:text-foreground\"\n              disabled={!inputValue.trim() || isGenerating}\n              title=\"Send message\"\n              aria-label=\"Send message\"\n            >\n              <ArrowUpIcon size={22} strokeWidth={2.25} />\n            </Button>\n          </form>\n          <div className=\"mt-2 flex items-start justify-start text-xs text-muted-foreground\">\n            <p className=\"m-0 w-full text-left\">\n              Answers are generated with AI which can make mistakes.\n            </p>\n          </div>\n          <div className=\"mt-2 flex items-center justify-end\">\n            <a\n              className=\"inline-flex items-center gap-2 text-muted-foreground text-xs no-underline transition-colors hover:text-blue-600\"\n              href={poweredByHref}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n            >\n              <span className=\"hidden sm:inline\">Powered by </span>\n              <AlgoliaLogo size={80} />\n            </a>\n          </div>\n        </div>\n      </div>\n    </div>,\n    document.body,\n  );\n});\n\n// ============================================================================\n// Main Export Component\n// ============================================================================\n\nexport default function SidepanelExperience(config: SidepanelAskAIConfig) {\n  const [isOpen, setIsOpen] = useState(false);\n  const inputRef = useRef<HTMLTextAreaElement | null>(null);\n\n  const searchClient = useMemo(() => {\n    const client = liteClient(config.applicationId, config.apiKey);\n    client.addAlgoliaAgent(\"algolia-sitesearch\");\n    return client;\n  }, [config.applicationId, config.apiKey]);\n\n  const { messages, startNewConversation, error, isGenerating, sendMessage } =\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  const suggestedQuestions = useSuggestedQuestions({\n    searchClient,\n    assistantId: config.assistantId,\n    suggestedQuestionsEnabled: config.suggestedQuestionsEnabled ?? false,\n    isOpen,\n  });\n\n  // Keyboard shortcut: Command+I (Mac) or Ctrl+I (Windows)\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      const isModifierPressed = event.metaKey || event.ctrlKey;\n\n      if (isModifierPressed && event.key.toLowerCase() === \"i\") {\n        event.preventDefault();\n        setIsOpen((prev) => !prev);\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, []);\n\n  const openSidepanel = () => setIsOpen(true);\n  const closeSidepanel = () => setIsOpen(false);\n  const openNewConversation = () => {\n    startNewConversation();\n    setIsOpen(true);\n  };\n  const buttonProps = {\n    ...config.buttonProps,\n    onClick: openSidepanel,\n  };\n\n  const [modifierLabel, setModifierLabel] = useState(\"⌘\");\n  const [isModifierPressed, setIsModifierPressed] = useState(false);\n  const [isIPressed, setIsIPressed] = 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() === \"i\") setIsIPressed(true);\n    };\n    const handleKeyUp = (event: KeyboardEvent) => {\n      if (!event.metaKey && !event.ctrlKey) setIsModifierPressed(false);\n      if (event.key.toLowerCase() === \"i\") setIsIPressed(false);\n    };\n    const resetKeys = () => {\n      setIsModifierPressed(false);\n      setIsIPressed(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    \"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    <>\n      <Button\n        {...buttonProps}\n        variant=\"outline\"\n        className={cn(baseClassName, buttonProps.className)}\n      >\n        <span className=\"flex items-center gap-2 opacity-80\">\n          <span className=\"inline text-muted-foreground\">\n            {config.buttonText || \"Ask AI\"}\n          </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              isIPressed\n                ? \"inset-shadow-sm inset-shadow-foreground/30\"\n                : \"shadow-none\"\n            }`}\n          >\n            I\n          </kbd>\n        </div>\n      </Button>\n      <Sidepanel\n        isOpen={isOpen}\n        onClose={closeSidepanel}\n        config={config}\n        messages={messages as unknown as Message[]}\n        error={error as Error | null}\n        isGenerating={isGenerating}\n        suggestedQuestions={suggestedQuestions}\n        sendMessage={sendMessage}\n        inputRef={inputRef}\n        onOpenNewConversation={openNewConversation}\n      />\n    </>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/registry/experiences/sidepanel-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  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/sidepanel-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/sidepanel-askai/page.tsx",
      "content": "\"use client\";\n\nimport SidepanelExperience from \"@/registry/experiences/sidepanel-askai/components/sidepanel-askai\";\n\nexport default function Page() {\n  return (\n    <div className=\"flex items-center justify-center min-h-[100px] md:min-h-[400px] relative p-4\">\n      <SidepanelExperience\n        applicationId=\"06YAZFOHSQ\"\n        apiKey=\"94b6afdc316917b6e6cdf2763fa561df\"\n        indexName=\"algolia_podcast_sample_dataset\"\n        assistantId=\"UpR727VnXnoG\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/sidepanel-askai/page.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "animate-shiny-text": "shiny-text 8s infinite"
    }
  },
  "css": {
    "@import \"tw-animate-css\"": {},
    "@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": [
    "ai",
    "chat"
  ]
}