{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "output-area",
  "title": "OutputArea",
  "description": "Wrapper component for rendering multiple Jupyter outputs with collapsible state and scroll behavior.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@nteract/media-router",
    "@nteract/ansi-output",
    "@nteract/isolated-frame",
    "@nteract/widget-store",
    "@nteract/markdown-output",
    "@nteract/error-boundary",
    "@nteract/highlight-text"
  ],
  "files": [
    {
      "path": "registry/cell/OutputArea.tsx",
      "content": "\"use client\";\n\nimport { ChevronDown, ChevronRight } from \"lucide-react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { isDarkMode as detectDarkMode } from \"@/lib/dark-mode\";\nimport { cn } from \"@/lib/utils\";\nimport { ErrorBoundary } from \"@/registry/lib/error-boundary\";\nimport { highlightTextInDom } from \"@/registry/lib/highlight-text\";\nimport { OutputErrorFallback } from \"@/registry/lib/output-error-fallback\";\nimport {\n  AnsiErrorOutput,\n  AnsiStreamOutput,\n} from \"@/registry/outputs/ansi-output\";\nimport {\n  CommBridgeManager,\n  type IframeToParentMessage,\n  IsolatedFrame,\n  type IsolatedFrameHandle,\n} from \"@/registry/outputs/isolated\";\nimport { DEFAULT_PRIORITY, MediaRouter } from \"@/registry/outputs/media-router\";\nimport { useWidgetStore } from \"@/registry/widgets/widget-store-context\";\n\n/**\n * Jupyter output types based on the nbformat spec.\n */\nexport type JupyterOutput =\n  | {\n      output_type: \"execute_result\" | \"display_data\";\n      data: Record<string, unknown>;\n      metadata?: Record<string, unknown>;\n      execution_count?: number | null;\n    }\n  | {\n      output_type: \"stream\";\n      name: \"stdout\" | \"stderr\";\n      text: string | string[];\n    }\n  | {\n      output_type: \"error\";\n      ename: string;\n      evalue: string;\n      traceback: string[];\n    };\n\ninterface OutputAreaProps {\n  /**\n   * Array of Jupyter outputs to render.\n   */\n  outputs: JupyterOutput[];\n  /**\n   * Whether the output area is collapsed.\n   */\n  collapsed?: boolean;\n  /**\n   * Callback when collapse state is toggled.\n   */\n  onToggleCollapse?: () => void;\n  /**\n   * Maximum height before scrolling. Set to enable scroll behavior.\n   */\n  maxHeight?: number;\n  /**\n   * Additional CSS classes for the container.\n   */\n  className?: string;\n  /**\n   * Custom renderers passed to MediaRouter.\n   */\n  renderers?: Record<\n    string,\n    (props: {\n      data: unknown;\n      metadata: Record<string, unknown>;\n      mimeType: string;\n      className?: string;\n    }) => ReactNode\n  >;\n  /**\n   * Custom MIME type priority order.\n   */\n  priority?: readonly string[];\n  /**\n   * Force isolation mode. When true, all outputs render in an isolated iframe.\n   * When \"auto\" (default), isolation is used when any output needs it.\n   * When false, outputs render in-DOM (less secure but faster for simple outputs).\n   */\n  isolated?: boolean | \"auto\";\n  /**\n   * Pre-create the IsolatedFrame even when there are no outputs.\n   * This allows the iframe to bootstrap ahead of time, making output rendering instant.\n   * The iframe is hidden until outputs that need isolation arrive.\n   * @default false\n   */\n  preloadIframe?: boolean;\n  /**\n   * Callback when a link is clicked in isolated outputs.\n   */\n  onLinkClick?: (url: string, newTab: boolean) => void;\n  /**\n   * Callback when widget state is updated in isolated outputs.\n   * @deprecated Use the comm bridge instead for full widget support\n   */\n  onWidgetUpdate?: (commId: string, state: Record<string, unknown>) => void;\n  /**\n   * Search query to highlight in iframe outputs.\n   * Empty string or undefined clears highlights.\n   */\n  searchQuery?: string;\n  /**\n   * Callback reporting how many search matches were found in this cell's outputs.\n   * Called when iframe reports search_results or in-DOM highlighting completes.\n   */\n  onSearchMatchCount?: (count: number) => void;\n}\n\n/**\n * Normalize stream text (can be string or string array).\n */\nfunction normalizeText(text: string | string[]): string {\n  return Array.isArray(text) ? text.join(\"\") : text;\n}\n\n/**\n * MIME types that require iframe isolation for security.\n * These types can contain executable scripts or interactive content.\n */\nconst ISOLATED_MIME_TYPES = new Set([\n  \"text/html\",\n  \"text/markdown\",\n  \"image/svg+xml\",\n  \"application/vnd.jupyter.widget-view+json\",\n  \"application/vnd.plotly.v1+json\",\n  \"application/vnd.vegalite.v5+json\",\n  \"application/vnd.vegalite.v4+json\",\n  \"application/vnd.vegalite.v3+json\",\n  \"application/vnd.vega.v5+json\",\n  \"application/vnd.vega.v4+json\",\n]);\n\n/**\n * Select the best MIME type from available data based on priority.\n */\nfunction selectMimeType(\n  data: Record<string, unknown>,\n  priority: readonly string[] = DEFAULT_PRIORITY,\n): string | null {\n  const availableTypes = Object.keys(data);\n  for (const mimeType of priority) {\n    if (availableTypes.includes(mimeType) && data[mimeType] != null) {\n      return mimeType;\n    }\n  }\n  const firstAvailable = availableTypes.find((type) => data[type] != null);\n  return firstAvailable || null;\n}\n\n/**\n * Check if a single output needs iframe isolation.\n */\nfunction outputNeedsIsolation(\n  output: JupyterOutput,\n  priority: readonly string[] = DEFAULT_PRIORITY,\n): boolean {\n  if (\n    output.output_type === \"execute_result\" ||\n    output.output_type === \"display_data\"\n  ) {\n    const mimeType = selectMimeType(output.data, priority);\n    return mimeType ? ISOLATED_MIME_TYPES.has(mimeType) : false;\n  }\n  // stream and error outputs don't need isolation\n  return false;\n}\n\n/**\n * Check if any outputs in the array need iframe isolation.\n * If any output needs isolation, ALL outputs should go to the iframe.\n */\nfunction anyOutputNeedsIsolation(\n  outputs: JupyterOutput[],\n  priority: readonly string[] = DEFAULT_PRIORITY,\n): boolean {\n  return outputs.some((output) => outputNeedsIsolation(output, priority));\n}\n\n/**\n * Check if outputs contain any widget MIME types.\n */\nfunction hasWidgetOutputs(\n  outputs: JupyterOutput[],\n  priority: readonly string[] = DEFAULT_PRIORITY,\n): boolean {\n  return outputs.some((output) => {\n    if (\n      output.output_type === \"execute_result\" ||\n      output.output_type === \"display_data\"\n    ) {\n      const mimeType = selectMimeType(output.data, priority);\n      return mimeType === \"application/vnd.jupyter.widget-view+json\";\n    }\n    return false;\n  });\n}\n\n/**\n * Render a single Jupyter output based on its type.\n */\nfunction renderOutput(\n  output: JupyterOutput,\n  index: number,\n  renderers?: OutputAreaProps[\"renderers\"],\n  priority?: readonly string[],\n) {\n  const key = `output-${index}`;\n\n  switch (output.output_type) {\n    case \"execute_result\":\n    case \"display_data\":\n      return (\n        <MediaRouter\n          key={key}\n          data={output.data}\n          metadata={\n            output.metadata as Record<\n              string,\n              Record<string, unknown> | undefined\n            >\n          }\n          renderers={renderers}\n          priority={priority}\n        />\n      );\n\n    case \"stream\":\n      return (\n        <AnsiStreamOutput\n          key={key}\n          text={normalizeText(output.text)}\n          streamName={output.name}\n        />\n      );\n\n    case \"error\":\n      return (\n        <AnsiErrorOutput\n          key={key}\n          ename={output.ename}\n          evalue={output.evalue}\n          traceback={output.traceback}\n        />\n      );\n\n    default:\n      return null;\n  }\n}\n\n/**\n * OutputArea renders multiple Jupyter outputs with proper layout.\n *\n * Handles all Jupyter output types: execute_result, display_data, stream, and error.\n * Supports collapsible state and scroll behavior for large outputs.\n *\n * @example\n * ```tsx\n * <OutputArea\n *   outputs={cell.outputs}\n *   collapsed={outputsCollapsed}\n *   onToggleCollapse={() => setOutputsCollapsed(!outputsCollapsed)}\n *   maxHeight={400}\n * />\n * ```\n */\nexport function OutputArea({\n  outputs,\n  collapsed = false,\n  onToggleCollapse,\n  maxHeight,\n  className,\n  renderers,\n  priority = DEFAULT_PRIORITY,\n  isolated = \"auto\",\n  preloadIframe = false,\n  onLinkClick,\n  onWidgetUpdate,\n  searchQuery,\n  onSearchMatchCount,\n}: OutputAreaProps) {\n  const id = useId();\n  const frameRef = useRef<IsolatedFrameHandle>(null);\n  const bridgeRef = useRef<CommBridgeManager | null>(null);\n  const inDomOutputRef = useRef<HTMLDivElement>(null);\n  const searchQueryRef = useRef(searchQuery);\n  searchQueryRef.current = searchQuery;\n\n  // Track dark mode state and observe changes\n  const [darkMode, setDarkMode] = useState(() => detectDarkMode());\n\n  useEffect(() => {\n    // Update dark mode when document class changes\n    const observer = new MutationObserver(() => {\n      setDarkMode(detectDarkMode());\n    });\n\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-theme\", \"data-mode\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  // Get widget store context (may be null if not in provider)\n  const widgetContext = useWidgetStore();\n\n  // Determine if we should use isolation (when we have outputs)\n  const shouldIsolate =\n    outputs.length > 0 &&\n    (isolated === true ||\n      (isolated === \"auto\" && anyOutputNeedsIsolation(outputs, priority)));\n\n  // When preloading, we render the iframe even with no outputs (hidden)\n  // This allows it to bootstrap ahead of time for instant rendering\n  const showPreloadedIframe = preloadIframe && !collapsed;\n\n  // Check if we have widgets and should set up comm bridge\n  const hasWidgets = hasWidgetOutputs(outputs, priority);\n  const shouldUseBridge = shouldIsolate && hasWidgets && widgetContext !== null;\n\n  const hasCollapseControl = onToggleCollapse !== undefined;\n\n  // Handle messages from iframe, routing widget messages to comm bridge\n  const handleIframeMessage = useCallback(\n    (message: IframeToParentMessage) => {\n      // Route widget messages to bridge\n      if (bridgeRef.current) {\n        bridgeRef.current.handleIframeMessage(message);\n      }\n\n      // Also handle widget_update for backward compatibility\n      if (message.type === \"widget_update\" && onWidgetUpdate) {\n        onWidgetUpdate(message.payload.commId, message.payload.state);\n      }\n\n      // Capture search result count from iframe\n      if (message.type === \"search_results\") {\n        onSearchMatchCount?.(message.payload.count);\n      }\n    },\n    [onWidgetUpdate, onSearchMatchCount],\n  );\n\n  // Callback when frame is ready - set up bridge and render outputs\n  const handleFrameReady = useCallback(() => {\n    if (!frameRef.current) return;\n\n    // Set up comm bridge if we have widgets and widget context\n    if (shouldUseBridge && widgetContext && !bridgeRef.current) {\n      bridgeRef.current = new CommBridgeManager({\n        frame: frameRef.current,\n        store: widgetContext.store,\n        sendUpdate: widgetContext.sendUpdate,\n        sendCustom: widgetContext.sendCustom,\n        closeComm: widgetContext.closeComm,\n      });\n    }\n\n    // Clear existing content\n    frameRef.current.clear();\n\n    // Render each output\n    outputs.forEach((output, index) => {\n      const append = index > 0;\n\n      if (\n        output.output_type === \"execute_result\" ||\n        output.output_type === \"display_data\"\n      ) {\n        const mimeType = selectMimeType(output.data, priority);\n        if (mimeType) {\n          frameRef.current?.render({\n            mimeType,\n            data: output.data[mimeType],\n            metadata: output.metadata?.[mimeType] as\n              | Record<string, unknown>\n              | undefined,\n            outputIndex: index,\n            append,\n          });\n        }\n      } else if (output.output_type === \"stream\") {\n        frameRef.current?.render({\n          mimeType: \"text/plain\",\n          data: normalizeText(output.text),\n          metadata: { streamName: output.name },\n          outputIndex: index,\n          append,\n        });\n      } else if (output.output_type === \"error\") {\n        // Render error with metadata so iframe can use AnsiErrorOutput\n        frameRef.current?.render({\n          mimeType: \"text/plain\",\n          data: output.traceback.join(\"\\n\"),\n          metadata: {\n            isError: true,\n            ename: output.ename,\n            evalue: output.evalue,\n            traceback: output.traceback,\n          },\n          outputIndex: index,\n          append,\n        });\n      }\n    });\n\n    // Re-apply search highlights after rendering new content\n    if (searchQueryRef.current) {\n      frameRef.current?.search(searchQueryRef.current);\n    }\n  }, [outputs, priority, shouldUseBridge, widgetContext]);\n\n  // Clean up bridge on unmount\n  useEffect(() => {\n    return () => {\n      if (bridgeRef.current) {\n        bridgeRef.current.dispose();\n        bridgeRef.current = null;\n      }\n    };\n  }, []);\n\n  // Re-render outputs when they change (after initial ready)\n  useEffect(() => {\n    if (frameRef.current?.isReady) {\n      handleFrameReady();\n    }\n  }, [handleFrameReady]);\n\n  // Forward search query to the iframe (for isolated outputs)\n  useEffect(() => {\n    if (frameRef.current?.isIframeReady) {\n      frameRef.current.search(searchQuery || \"\");\n    }\n  }, [searchQuery]);\n\n  // Highlight search matches in in-DOM outputs\n  // Re-run when outputs array ref changes so new content gets highlighted\n  useEffect(() => {\n    if (shouldIsolate) return; // iframe reports its own count via search_results\n    if (!searchQuery || !inDomOutputRef.current || outputs.length === 0) {\n      // Only report 0 if we were previously tracking matches for this cell\n      if (searchQuery) onSearchMatchCount?.(0);\n      return;\n    }\n    const cleanup = highlightTextInDom(inDomOutputRef.current, searchQuery);\n    const count =\n      inDomOutputRef.current.querySelectorAll(\".global-find-match\").length;\n    onSearchMatchCount?.(count);\n    return cleanup;\n  }, [searchQuery, shouldIsolate, outputs, onSearchMatchCount]);\n\n  // Empty state: render nothing (unless preloading iframe)\n  if (outputs.length === 0 && !showPreloadedIframe) {\n    return null;\n  }\n\n  // Hide the entire output area when only preloading (no visible outputs)\n  const isPreloadOnly = showPreloadedIframe && outputs.length === 0;\n\n  return (\n    <div\n      data-slot=\"output-area\"\n      className={cn(\"output-area\", isPreloadOnly && \"hidden\", className)}\n    >\n      {/* Collapse toggle */}\n      {hasCollapseControl && (\n        <button\n          type=\"button\"\n          onClick={onToggleCollapse}\n          className=\"flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors\"\n          aria-expanded={!collapsed}\n          aria-controls={id}\n        >\n          {collapsed ? (\n            <ChevronRight className=\"h-3 w-3\" />\n          ) : (\n            <ChevronDown className=\"h-3 w-3\" />\n          )}\n          <span>\n            {collapsed\n              ? `Show ${outputs.length} output${outputs.length > 1 ? \"s\" : \"\"}`\n              : \"Hide outputs\"}\n          </span>\n        </button>\n      )}\n\n      {/* Output content */}\n      {!collapsed && (\n        <div\n          id={id}\n          className={cn(\"space-y-2\", maxHeight && \"overflow-y-auto\")}\n          style={maxHeight ? { maxHeight: `${maxHeight}px` } : undefined}\n        >\n          {/* Preloaded or active isolated frame */}\n          {(shouldIsolate || showPreloadedIframe) && (\n            <div className={shouldIsolate ? undefined : \"hidden\"}>\n              <IsolatedFrame\n                ref={frameRef}\n                darkMode={darkMode}\n                minHeight={24}\n                maxHeight={maxHeight ?? 2000}\n                onReady={handleFrameReady}\n                onLinkClick={onLinkClick}\n                onWidgetUpdate={onWidgetUpdate}\n                onMessage={handleIframeMessage}\n                onError={(err) =>\n                  console.error(\"[OutputArea] iframe error:\", err)\n                }\n              />\n            </div>\n          )}\n\n          {/* In-DOM outputs (when not using isolation) */}\n          {!shouldIsolate && (\n            <div ref={inDomOutputRef}>\n              {outputs.map((output, index) => (\n                <div\n                  key={`output-${index}`}\n                  data-slot=\"output-item\"\n                  data-output-index={index}\n                >\n                  <ErrorBoundary\n                    resetKeys={[JSON.stringify(output)]}\n                    fallback={(error, reset) => (\n                      <OutputErrorFallback\n                        error={error}\n                        outputIndex={index}\n                        onRetry={reset}\n                      />\n                    )}\n                    onError={(error, errorInfo) => {\n                      console.error(\n                        `[OutputArea] Error rendering output ${index}:`,\n                        error,\n                        errorInfo.componentStack,\n                      );\n                    }}\n                  >\n                    {renderOutput(output, index, renderers, priority)}\n                  </ErrorBoundary>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/cell/OutputArea.tsx"
    }
  ],
  "type": "registry:component"
}