{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat",
  "title": "Chat UI Primitives",
  "description": "Virtualized chat with measured markdown. Auto-scroll, message strategies, typing indicators.",
  "type": "registry:block",
  "dependencies": [
    "@inferencesh/sdk"
  ],
  "registryDependencies": [
    "button",
    "collapsible",
    "scroll-area",
    "textarea",
    "tooltip",
    "spinner",
    "command",
    "popover",
    "https://inference.sh/ui/r/pretext-md.json",
    "https://inference.sh/ui/r/virtualize.json"
  ],
  "files": [
    {
      "path": "components/infsh/agent/chat-container.tsx",
      "content": "import React, { forwardRef, type ReactNode } from 'react';\nimport { cn } from '@/lib/utils';\n\ninterface ChatContainerProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * ChatContainer - Grid layout wrapper for chat components\n * \n * @example\n * ```tsx\n * <ChatContainer className=\"h-screen\">\n *   <Header />\n *   <ChatMessages>...</ChatMessages>\n *   <ChatInput />\n * </ChatContainer>\n * ```\n */\nexport const ChatContainer = forwardRef<HTMLDivElement, ChatContainerProps>(\n  ({ children, className }, ref) => {\n    return (\n      <div\n        ref={ref}\n        className={cn(\n          'grid max-h-full w-full grid-rows-[auto_1fr_auto]',\n          className\n        )}\n      >\n        {children}\n      </div>\n    );\n  }\n);\n\nChatContainer.displayName = 'ChatContainer';\n\n",
      "type": "registry:component",
      "target": "components/infsh/agent/chat-container.tsx"
    },
    {
      "path": "components/infsh/agent/chat-input.tsx",
      "content": "import React, { useState, useRef, useEffect, useCallback, memo, forwardRef, useImperativeHandle } from 'react';\nimport { cn } from '@/lib/utils';\nimport { Button } from '@/components/ui/button';\nimport { ArrowUp, Square, ImagePlus, Paperclip, File as FileIcon, AlertCircle, X, HelpCircle } from 'lucide-react';\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from '@/components/ui/command';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\nimport { useAgentChat, useAgentActions } from '@inferencesh/sdk/agent';\nimport { isChatBusy } from '@inferencesh/sdk';\nimport {\n  useFileUploadManager,\n  FileUploadList,\n  showFileUploadDialog,\n  type FileUpload,\n} from '@/components/infsh/agent/file-upload';\n\nexport interface ChatInputHandle {\n  setInput: (text: string) => void;\n}\n\ninterface ChatInputProps {\n  placeholder?: string;\n  className?: string;\n  allowAttachments?: boolean;\n  allowFiles?: boolean;\n  allowImages?: boolean;\n  onFilesChange?: (files: File[]) => void;\n  /** Example prompts shown in a ? dropdown */\n  examplePrompts?: string[];\n}\n\n// =============================================================================\n// Drag Overlay Component (CSS transitions)\n// =============================================================================\n\ninterface DragOverlayProps {\n  isDragging: boolean;\n}\n\nconst DragOverlay = memo(function DragOverlay({ isDragging }: DragOverlayProps) {\n  if (!isDragging) return null;\n\n  return (\n    <div\n      className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/10 backdrop-blur-sm animate-in fade-in duration-100\"\n    >\n      <div className=\"flex flex-col items-center gap-2 text-primary\">\n        <div className=\"rounded-full bg-primary/20 p-3\">\n          <Paperclip className=\"h-6 w-6\" />\n        </div>\n        <span className=\"text-sm font-medium\">drop files to upload</span>\n      </div>\n    </div>\n  );\n});\n\n// =============================================================================\n// ChatInput Component\n// =============================================================================\n\n/**\n * ChatInput - Self-contained input with file upload and auto-resize\n *\n * @example\n * ```tsx\n * <ChatInput placeholder=\"Ask me anything...\" allowAttachments />\n * ```\n */\nexport const ChatInput = memo(forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput({\n  placeholder = 'ask a question...',\n  className,\n  allowAttachments,\n  allowFiles = true,\n  allowImages = true,\n  examplePrompts,\n}, ref) {\n  // Backwards compatibility: if allowAttachments is explicitly false, disable both\n  const showFileButton = allowAttachments !== false && allowFiles;\n  const showImageButton = allowAttachments !== false && allowImages;\n  const enableAttachments = showFileButton || showImageButton;\n  const { chat, error } = useAgentChat();\n  const isBusy = isChatBusy(chat);\n  const { sendMessage, stopGeneration, clearError } = useAgentActions();\n\n  const [value, setValue] = useState('');\n  const [isDragging, setIsDragging] = useState(false);\n  const [showCommandMenu, setShowCommandMenu] = useState(false);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const dragCounterRef = useRef(0);\n\n  useImperativeHandle(ref, () => ({\n    setInput(text: string) {\n      setValue(text);\n      textareaRef.current?.focus();\n    },\n  }));\n\n  // File upload manager - uploads files on select\n  const {\n    uploads,\n    addFiles,\n    removeUpload,\n    clearAll,\n    getFileRefs,\n    hasPendingUploads,\n    hasCompletedUploads,\n  } = useFileUploadManager();\n\n  const completedUploads = uploads.filter(u => u.status === 'completed');\n\n  // Auto-resize textarea\n  useEffect(() => {\n    const textarea = textareaRef.current;\n    if (!textarea) return;\n\n    textarea.style.height = 'auto';\n    const newHeight = Math.min(textarea.scrollHeight, 200);\n    textarea.style.height = `${newHeight}px`;\n  }, [value]);\n\n  // Add @filename reference to text\n  const addFileReferenceToText = useCallback((upload: FileUpload) => {\n    const fileName = upload.file.name;\n    const text = `@${fileName} `;\n    setValue(prev => {\n      const needsSpace = prev && !prev.endsWith(' ');\n      return `${prev}${needsSpace ? ' ' : ''}${text}`;\n    });\n\n    // Focus textarea and move cursor to end\n    if (textareaRef.current) {\n      textareaRef.current.focus();\n      setTimeout(() => {\n        if (textareaRef.current) {\n          const length = textareaRef.current.value.length;\n          textareaRef.current.setSelectionRange(length, length);\n        }\n      }, 0);\n    }\n  }, []);\n\n  // Handle send\n  const handleSend = useCallback(async () => {\n    const messageText = value.trim();\n\n    // Need either text or completed uploads\n    if (!messageText && !hasCompletedUploads) return;\n\n    // Don't send while uploads are in progress\n    if (hasPendingUploads) return;\n\n    if (isBusy) return;\n\n    // Get already-uploaded files\n    const uploadedFiles = getFileRefs();\n\n    setValue('');\n    clearAll();\n\n    // Send message with pre-uploaded files\n    await sendMessage(messageText, uploadedFiles.length > 0 ? uploadedFiles : undefined);\n  }, [value, hasCompletedUploads, hasPendingUploads, isBusy, getFileRefs, clearAll, sendMessage]);\n\n  // Handle key down\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n      // Show command menu when @ is typed (and we have completed uploads)\n      if (e.key === '@' && completedUploads.length > 0) {\n        e.preventDefault();\n        setShowCommandMenu(true);\n      }\n\n      // Hide command menu on Escape\n      if (e.key === 'Escape') {\n        setShowCommandMenu(false);\n      }\n\n      // Submit on Enter (without Shift)\n      if (e.key === 'Enter' && !e.shiftKey) {\n        e.preventDefault();\n        handleSend();\n      }\n    },\n    [handleSend, completedUploads.length]\n  );\n\n  // Handle paste\n  const handlePaste = useCallback((e: React.ClipboardEvent) => {\n    if (!enableAttachments) return;\n\n    const items = e.clipboardData?.items;\n    if (!items) return;\n\n    const files: File[] = [];\n    for (const item of items) {\n      const file = item.getAsFile();\n      if (file) {\n        files.push(file);\n      }\n    }\n\n    if (files.length > 0) {\n      e.preventDefault(); // Prevent filename from being pasted as text\n      addFiles(files);\n    }\n  }, [enableAttachments, addFiles]);\n\n  // Drag and drop handlers\n  const handleDragEnter = useCallback((e: React.DragEvent) => {\n    if (!enableAttachments) return;\n    e.preventDefault();\n    e.stopPropagation();\n    dragCounterRef.current++;\n    // Check for files being dragged\n    if (e.dataTransfer.types.includes('Files')) {\n      setIsDragging(true);\n    }\n  }, [enableAttachments]);\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    if (!enableAttachments) return;\n    e.preventDefault();\n    e.stopPropagation();\n    dragCounterRef.current--;\n    if (dragCounterRef.current === 0) {\n      setIsDragging(false);\n    }\n  }, [enableAttachments]);\n\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    if (!enableAttachments) return;\n    e.preventDefault();\n    e.stopPropagation();\n    // Required to allow drop\n    e.dataTransfer.dropEffect = 'copy';\n  }, [enableAttachments]);\n\n  const handleDrop = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setIsDragging(false);\n    dragCounterRef.current = 0;\n\n    if (!enableAttachments) return;\n\n    const files = Array.from(e.dataTransfer.files);\n    if (files.length > 0) {\n      addFiles(files);\n    }\n  }, [enableAttachments, addFiles]);\n\n  // Handle attachment button click\n  const handleAttachmentClick = async () => {\n    const files = await showFileUploadDialog();\n    if (files) {\n      addFiles(files);\n    }\n  };\n\n  // Handle image button click\n  const handleImageClick = async () => {\n    const files = await showFileUploadDialog('image/*');\n    if (files) {\n      addFiles(files);\n    }\n  };\n\n  const canSend = (value.trim().length > 0 || hasCompletedUploads) && !isBusy && !hasPendingUploads;\n\n  return (\n    <div className=\"relative\">\n      {/* Error notification - floats above input */}\n      {error && (\n        <div className=\"absolute -top-10 left-0 right-0 flex items-center justify-center animate-in fade-in slide-in-from-bottom-2 duration-200\">\n          <div className=\"flex items-center gap-2 px-3 py-1.5 rounded-full bg-destructive/10 border border-destructive/20 text-destructive text-xs\">\n            <AlertCircle className=\"h-3 w-3 shrink-0\" />\n            <span className=\"line-clamp-1\">failed to send: {error}</span>\n            <button\n              type=\"button\"\n              onClick={clearError}\n              aria-label=\"Dismiss error\"\n              className=\"shrink-0 rounded-full p-0.5 hover:bg-destructive/20 transition-colors\"\n            >\n              <X className=\"h-3 w-3\" />\n            </button>\n          </div>\n        </div>\n      )}\n\n      <div\n        ref={containerRef}\n        className={cn(\n          'relative flex w-full flex-col gap-2 rounded-2xl border bg-muted/30 p-3',\n          isDragging && 'ring-2 ring-primary/50',\n          className\n        )}\n        onDragEnter={handleDragEnter}\n        onDragLeave={handleDragLeave}\n        onDragOver={handleDragOver}\n        onDrop={handleDrop}\n      >\n        {/* File uploads */}\n      {uploads.length > 0 && (\n        <FileUploadList\n          uploads={uploads}\n          onRemove={removeUpload}\n          className=\"pb-2\"\n        />\n      )}\n\n      {/* Text area with @ file reference popover */}\n      <div className=\"relative\">\n        <Popover open={showCommandMenu} onOpenChange={setShowCommandMenu}>\n          {/* Positioning anchor only — the popover is opened by typing '@' in\n              the textarea, never by clicking this. nativeButton={false} tells\n              Base UI not to expect a native <button>, which it warns about\n              otherwise; a real button would be wrong here since there is\n              nothing to activate. */}\n          <PopoverTrigger nativeButton={false} render={<div className=\"w-0 h-0 absolute\" />} />\n          <PopoverContent className=\"p-0 w-64\" align=\"start\" side=\"top\" sideOffset={8}>\n            <Command className=\"rounded-lg border-none\">\n              <CommandInput placeholder=\"search files...\" />\n              <CommandList>\n                <CommandEmpty>no files uploaded.</CommandEmpty>\n                {completedUploads.length > 0 && (\n                  <CommandGroup heading=\"Files\">\n                    {completedUploads.map((upload) => (\n                      <CommandItem\n                        key={upload.id}\n                        onSelect={() => {\n                          addFileReferenceToText(upload);\n                          setShowCommandMenu(false);\n                        }}\n                        className=\"cursor-pointer\"\n                      >\n                        <FileIcon className=\"mr-2 h-4 w-4\" />\n                        <span className=\"truncate\">{upload.file.name}</span>\n                      </CommandItem>\n                    ))}\n                  </CommandGroup>\n                )}\n              </CommandList>\n            </Command>\n          </PopoverContent>\n        </Popover>\n\n        <textarea\n          ref={textareaRef}\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          onKeyDown={handleKeyDown}\n          onPaste={handlePaste}\n          placeholder={placeholder}\n          aria-label={placeholder}\n          rows={1}\n          className={cn(\n            'w-full resize-none bg-transparent text-sm',\n            'placeholder:text-muted-foreground/50',\n            'focus:outline-none',\n            'disabled:opacity-50 disabled:cursor-not-allowed',\n            'min-h-[24px] max-h-[200px]'\n          )}\n        />\n      </div>\n\n      {/* Toolbar */}\n      <div className=\"flex items-center justify-between\">\n        {/* Left side - action buttons */}\n        <div className=\"flex items-center gap-1\">\n          {showFileButton && (\n            <Button\n              type=\"button\"\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer\"\n              onClick={handleAttachmentClick}\n              disabled={isBusy}\n              aria-label=\"Attach file\"\n            >\n              <Paperclip className=\"h-4 w-4\" />\n            </Button>\n          )}\n          {showImageButton && (\n            <Button\n              type=\"button\"\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer\"\n              onClick={handleImageClick}\n              disabled={isBusy}\n              aria-label=\"Attach image\"\n            >\n              <ImagePlus className=\"h-4 w-4\" />\n            </Button>\n          )}\n          {examplePrompts && examplePrompts.length > 0 && (\n            <Popover>\n              <PopoverTrigger render={<Button type=\"button\" size=\"icon\" variant=\"ghost\" className=\"h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer\" disabled={isBusy} aria-label=\"Example prompts\" />}>\n                  <HelpCircle className=\"h-4 w-4\" />\n              </PopoverTrigger>\n              <PopoverContent className=\"p-1 w-72\" align=\"start\" side=\"top\" sideOffset={8}>\n                <div className=\"flex flex-col\">\n                  {examplePrompts.map((prompt, i) => (\n                    <button\n                      key={i}\n                      onClick={() => {\n                        setValue(prompt);\n                        textareaRef.current?.focus();\n                      }}\n                      className=\"text-left px-3 py-2 text-sm rounded-md hover:bg-muted transition-colors\"\n                    >\n                      {prompt}\n                    </button>\n                  ))}\n                </div>\n              </PopoverContent>\n            </Popover>\n          )}\n        </div>\n\n        {/* Right side - send/stop button */}\n        <div className=\"flex items-center gap-2\">\n          {isBusy ? (\n            <Button\n              type=\"button\"\n              size=\"icon\"\n              variant=\"default\"\n              onClick={stopGeneration}\n              className=\"h-8 w-8 rounded-full cursor-pointer\"\n              aria-label=\"Stop generating\"\n            >\n              <Square className=\"h-3 w-3\" fill=\"currentColor\" />\n            </Button>\n          ) : (\n            <Button\n              type=\"button\"\n              size=\"icon\"\n              onClick={handleSend}\n              disabled={!canSend}\n              className=\"h-8 w-8 rounded-full cursor-pointer\"\n              aria-label=\"Send message\"\n            >\n              <ArrowUp className=\"h-4 w-4\" />\n            </Button>\n          )}\n        </div>\n      </div>\n\n        {/* Drag overlay */}\n        <DragOverlay isDragging={isDragging} />\n      </div>\n    </div>\n  );\n}));\n\nChatInput.displayName = 'ChatInput';\n",
      "type": "registry:component",
      "target": "components/infsh/agent/chat-input.tsx"
    },
    {
      "path": "components/infsh/agent/chat-messages.tsx",
      "content": "import React, { memo, useState, useLayoutEffect, type ReactNode } from 'react';\nimport { cn } from '@/lib/utils';\nimport { useAutoScroll } from '@/hooks/use-auto-scroll';\nimport { ChatWidthContext } from '@/hooks/use-chat-width';\nimport { Button } from '@/components/ui/button';\nimport { ArrowDown } from 'lucide-react';\nimport { useAgentChat } from '@inferencesh/sdk/agent';\nimport { type ChatMessageDTO } from '@inferencesh/sdk';\n\ninterface ChatMessagesProps {\n  children: (props: { messages: ChatMessageDTO[] }) => ReactNode;\n  className?: string;\n  scrollToTopPadding?: boolean;\n}\n\n/**\n * ChatMessages - Scrollable message container with render prop\n *\n * @example\n * ```tsx\n * <ChatMessages>\n *   {({ messages }) => (\n *     <div className=\"space-y-4\">\n *       {messages.map(msg => (\n *         <MessageBubble key={msg.id} message={msg}>\n *           <MessageContent message={msg} />\n *         </MessageBubble>\n *       ))}\n *     </div>\n *   )}\n * </ChatMessages>\n * ```\n *\n * @example With scroll-to-top padding (allows first message to scroll to top)\n * ```tsx\n * <ChatMessages scrollToTopPadding>\n *   {({ messages }) => <MessageList messages={messages} />}\n * </ChatMessages>\n * ```\n */\nexport const ChatMessages = memo(function ChatMessages({\n  children,\n  className,\n  scrollToTopPadding = false,\n}: ChatMessagesProps) {\n  const { messages } = useAgentChat();\n  const [spacerHeight, setSpacerHeight] = useState(0);\n  const [chatWidth, setChatWidth] = useState(0);\n\n  const {\n    containerRef,\n    scrollToBottom,\n    handleScroll,\n    shouldAutoScroll,\n    handleTouchStart,\n  } = useAutoScroll([messages]);\n\n  // measure container dimensions\n  useLayoutEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n\n    const update = () => {\n      if (!containerRef.current) return;\n      setChatWidth(containerRef.current.clientWidth);\n      if (scrollToTopPadding) {\n        setSpacerHeight(containerRef.current.clientHeight * 0.9);\n      }\n    };\n\n    update();\n    const ro = new ResizeObserver(update);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [scrollToTopPadding, containerRef]);\n\n  return (\n    <ChatWidthContext.Provider value={chatWidth}>\n      <div className={cn('flex flex-col min-h-0 min-w-0 relative', className)}>\n        <div\n          ref={containerRef}\n          className=\"flex-1 overflow-y-auto min-w-0\"\n          onScroll={handleScroll}\n          onTouchStart={handleTouchStart}\n        >\n          {children({ messages })}\n          {scrollToTopPadding && messages.length > 0 && (\n            <div aria-hidden=\"true\" className=\"shrink-0\" style={{ minHeight: spacerHeight }} />\n          )}\n        </div>\n\n        {/* Scroll to bottom button */}\n        {!shouldAutoScroll && (\n          <div className=\"absolute bottom-4 left-1/2 -translate-x-1/2\">\n            <Button\n              onClick={scrollToBottom}\n              size=\"sm\"\n              variant=\"default\"\n              className=\"bg-background hover:bg-muted text-foreground hover:text-foreground rounded-full shadow-md animate-in fade-in-0 slide-in-from-bottom-2 cursor-pointer\"\n            >\n              <ArrowDown className=\"h-4 w-4\" />\n              <span className=\"text-xs font-normal text-muted-foreground\">scroll to bottom</span>\n            </Button>\n          </div>\n        )}\n      </div>\n    </ChatWidthContext.Provider>\n  );\n});\n\nChatMessages.displayName = 'ChatMessages';\n",
      "type": "registry:component",
      "target": "components/infsh/agent/chat-messages.tsx"
    },
    {
      "path": "components/infsh/agent/virtualized-chat-messages.tsx",
      "content": "'use client'\n\nimport React, { memo, useState, useCallback, useRef, useLayoutEffect, useEffect, useMemo, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\nimport { ChatWidthContext } from '@/hooks/use-chat-width'\nimport { Button } from '@/components/ui/button'\nimport { ArrowDown } from 'lucide-react'\nimport { useAgentChat } from '@inferencesh/sdk/agent'\nimport type { ChatMessageDTO } from '@inferencesh/sdk'\nimport {\n  ChatMessageContentTypeText,\n  ChatMessageContentTypeReasoning,\n} from '@inferencesh/sdk'\nimport { useVirtualizedList, type VirtualItem } from '@/lib/virtualize'\nimport { messageStrategy } from '@/lib/message-strategy'\n\n/** Skip tool-role messages and empty messages (no text, no reasoning, no tools). */\nfunction isRenderable(msg: ChatMessageDTO): boolean {\n  if (msg.role === 'tool') return false\n  const hasText = msg.content?.some(c => c.type === ChatMessageContentTypeText && c.text?.trim())\n  const hasReasoning = msg.content?.some(c => c.type === ChatMessageContentTypeReasoning && c.text?.trim())\n  const hasTools = msg.tool_invocations && msg.tool_invocations.length > 0\n  return !!(hasText || hasReasoning || hasTools)\n}\n\nconst ACTIVATION_THRESHOLD = 50\nconst MIN_SCROLL_UP_THRESHOLD = 10\nconst LIST_GAP = 8 // gap-2 between messages\nconst LIST_PADDING_X = 16 // px-4\n\ninterface VirtualizedChatMessagesProps {\n  renderMessage: (message: ChatMessageDTO) => ReactNode\n  className?: string\n  scrollToTopPadding?: boolean\n  /** Shown after messages when generating with no content yet */\n  typingIndicator?: ReactNode\n}\n\nexport const VirtualizedChatMessages = memo(function VirtualizedChatMessages({\n  renderMessage,\n  className,\n  scrollToTopPadding = false,\n  typingIndicator,\n}: VirtualizedChatMessagesProps) {\n  const { messages } = useAgentChat()\n\n  // --- Container dimensions ---\n  const [chatWidth, setChatWidth] = useState(0)\n  const [viewportHeight, setViewportHeight] = useState(0)\n  const containerElRef = useRef<HTMLDivElement | null>(null)\n\n  // --- Auto-scroll state ---\n  const [shouldAutoScroll, setShouldAutoScroll] = useState(true)\n  const shouldAutoScrollRef = useRef(true)\n  shouldAutoScrollRef.current = shouldAutoScroll\n  const previousScrollTop = useRef<number | null>(null)\n\n  // --- Convert renderable messages to virtual items ---\n  const virtualItems: VirtualItem<ChatMessageDTO>[] = useMemo(() => {\n    if (chatWidth <= 0) return []\n    return messages.filter(isRenderable).map(msg => ({\n      id: msg.id,\n      strategy: messageStrategy(msg),\n      data: msg,\n    }))\n  }, [messages, chatWidth])\n\n  const list = useVirtualizedList(virtualItems, viewportHeight, chatWidth - LIST_PADDING_X * 2, LIST_GAP)\n\n  // --- Merge scroll ref with our container ref ---\n  // useVirtualizedList returns scrollRef (ref callback) that attaches scroll listener + RO.\n  // We also need the element for auto-scroll + dimension measurement.\n  const mergedRef = useCallback((el: HTMLDivElement | null) => {\n    containerElRef.current = el\n    list.scrollRef(el)\n  }, [list.scrollRef])\n\n  // --- Measure container ---\n  useLayoutEffect(() => {\n    const el = containerElRef.current\n    if (!el) return\n    const update = () => {\n      if (!containerElRef.current) return\n      setChatWidth(containerElRef.current.clientWidth)\n      setViewportHeight(containerElRef.current.clientHeight)\n    }\n    update()\n    const ro = new ResizeObserver(update)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  // --- Auto-scroll: scroll handler ---\n  // Only disable auto-scroll on deliberate user scroll-up.\n  // Re-enable when user scrolls back to bottom.\n  // Never disable from programmatic scrolls or spacer updates\n  // (those look like \"not at bottom\" momentarily but aren't user-initiated).\n  const handleScroll = useCallback(() => {\n    const el = containerElRef.current\n    if (!el) return\n\n    const { scrollTop, scrollHeight, clientHeight } = el\n\n    // Ignore Safari rubber-band\n    if (scrollTop < 0 || scrollTop + clientHeight > scrollHeight + 1) return\n\n    const distanceFromBottom = Math.abs(scrollHeight - scrollTop - clientHeight)\n    const isAtBottom = distanceFromBottom < ACTIVATION_THRESHOLD\n\n    const isScrollingUp = previousScrollTop.current !== null\n      ? scrollTop < previousScrollTop.current\n      : false\n    const scrollUpDistance = previousScrollTop.current !== null\n      ? previousScrollTop.current - scrollTop\n      : 0\n    const isDeliberateScrollUp = isScrollingUp && scrollUpDistance > MIN_SCROLL_UP_THRESHOLD\n\n    if (isDeliberateScrollUp && !isAtBottom) {\n      // User deliberately scrolled up — detach\n      setShouldAutoScroll(false)\n    } else if (isAtBottom) {\n      // User scrolled back to bottom — re-attach\n      setShouldAutoScroll(true)\n    }\n    // Otherwise: spacer update, programmatic scroll, etc. — don't change state.\n\n    previousScrollTop.current = scrollTop\n  }, [])\n\n  const handleTouchStart = useCallback(() => {\n    setShouldAutoScroll(false)\n  }, [])\n\n  const scrollToBottom = useCallback(() => {\n    if (containerElRef.current) {\n      containerElRef.current.scrollTop = containerElRef.current.scrollHeight\n    }\n  }, [])\n\n  // --- Auto-scroll on new messages ---\n  useEffect(() => {\n    if (shouldAutoScrollRef.current) {\n      scrollToBottom()\n    }\n  }, [messages, scrollToBottom])\n\n  // --- Auto-scroll on content height growth ---\n  useEffect(() => {\n    const el = containerElRef.current\n    if (!el) return\n    let prevHeight = el.scrollHeight\n    const ro = new ResizeObserver(() => {\n      const h = el.scrollHeight\n      if (shouldAutoScrollRef.current && h > prevHeight) {\n        scrollToBottom()\n      }\n      prevHeight = h\n    })\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [scrollToBottom])\n\n  // --- Spacer for scroll-to-top ---\n  const spacerHeight = scrollToTopPadding ? viewportHeight * 0.9 : 0\n\n  return (\n    <ChatWidthContext.Provider value={chatWidth}>\n      <div className={cn('flex flex-col min-h-0 min-w-0 relative', className)}>\n        <div\n          ref={mergedRef}\n          className=\"flex-1 overflow-y-auto min-w-0\"\n          onScroll={handleScroll}\n          onTouchStart={handleTouchStart}\n        >\n          {/* Top spacer (virtualizer) */}\n          <div style={{ height: list.topSpacer }} />\n\n          {/* Visible messages */}\n          <div className=\"flex flex-col gap-2 px-4 py-3\">\n            {list.items.map(item => (\n              <div key={item.id} ref={list.getItemRef(item.id)} className=\"min-w-0\">\n                {renderMessage(item.data)}\n              </div>\n            ))}\n          </div>\n\n          {/* Bottom spacer (virtualizer) */}\n          <div style={{ height: list.bottomSpacer }} />\n\n          {/* Typing indicator — shown outside virtualizer, after all messages */}\n          {typingIndicator && (\n            <div className=\"px-4 py-2\"><span>{typingIndicator}</span></div>\n          )}\n\n          {/* Scroll-to-top padding */}\n          {scrollToTopPadding && messages.length > 0 && (\n            <div aria-hidden=\"true\" className=\"shrink-0\" style={{ minHeight: spacerHeight }} />\n          )}\n        </div>\n\n        {/* Scroll to bottom button */}\n        {!shouldAutoScroll && (\n          <div className=\"absolute bottom-4 left-1/2 -translate-x-1/2\">\n            <Button\n              onClick={() => {\n                scrollToBottom()\n                setShouldAutoScroll(true)\n              }}\n              size=\"sm\"\n              variant=\"default\"\n              className=\"bg-background hover:bg-muted text-foreground hover:text-foreground rounded-full shadow-md animate-in fade-in-0 slide-in-from-bottom-2 cursor-pointer\"\n            >\n              <ArrowDown className=\"h-4 w-4\" />\n              <span className=\"text-xs font-normal text-muted-foreground\">scroll to bottom</span>\n            </Button>\n          </div>\n        )}\n      </div>\n    </ChatWidthContext.Provider>\n  )\n})\n\nVirtualizedChatMessages.displayName = 'VirtualizedChatMessages'\n",
      "type": "registry:component",
      "target": "components/infsh/agent/virtualized-chat-messages.tsx"
    },
    {
      "path": "components/infsh/agent/message.tsx",
      "content": "import React, { memo } from 'react';\nimport {\n  ChatMessageRoleUser,\n  ChatMessageRoleAssistant,\n  ChatMessageContentTypeReasoning,\n  ChatMessageContentTypeText,\n  ChatMessageStatusReady,\n  ChatMessageStatusFailed,\n  ChatMessageStatusCancelled,\n  type ChatMessageDTO,\n} from '@inferencesh/sdk';\nimport { MessageBubble } from '@/components/infsh/agent/message-bubble';\nimport { MessageContent } from '@/components/infsh/agent/message-content';\nimport { MessageReasoning } from '@/components/infsh/agent/message-reasoning';\nimport { ToolInvocations } from '@/components/infsh/agent/tool-invocations';\n\nfunction isTerminalChatMessageStatus(status: string | undefined): boolean {\n  return status === ChatMessageStatusReady ||\n    status === ChatMessageStatusFailed ||\n    status === ChatMessageStatusCancelled;\n}\n\nexport interface MessageProps {\n  message: ChatMessageDTO;\n  /** Truncate user messages */\n  truncateUser?: boolean;\n}\n\n/**\n * Default message rendering with all features:\n * - Reasoning block (collapsed, for assistant)\n * - Text content\n * - Tool invocations (for assistant)\n */\nexport const Message = memo(function Message({\n  message,\n  truncateUser = false,\n}: MessageProps) {\n  const isUser = message.role === ChatMessageRoleUser;\n  const isAssistant = message.role === ChatMessageRoleAssistant;\n\n  // Skip tool messages\n  if (message.role === 'tool') return null;\n\n  const reasoningContent = message.content?.find(\n    c => c.type === ChatMessageContentTypeReasoning\n  )?.text;\n\n  const hasText = message.content?.some(\n    c => c.type === ChatMessageContentTypeText && c.text?.trim()\n  );\n\n  const hasTools = message.tool_invocations && message.tool_invocations.length > 0;\n\n  // Skip empty messages (no text, no reasoning, no tools)\n  if (!hasText && !reasoningContent && !hasTools) return null;\n\n  const isGenerating = !isTerminalChatMessageStatus(message.status);\n\n  return (\n    <MessageBubble message={message}>\n      {isAssistant && reasoningContent && (\n        <MessageReasoning\n          reasoning={reasoningContent}\n          isReasoning={isGenerating && !hasText}\n        />\n      )}\n      <MessageContent message={message} truncate={isUser && truncateUser} />\n      {isAssistant && <ToolInvocations message={message} />}\n    </MessageBubble>\n  );\n});\n\n",
      "type": "registry:component",
      "target": "components/infsh/agent/message.tsx"
    },
    {
      "path": "components/infsh/agent/message-bubble.tsx",
      "content": "import React, { memo, type ReactNode } from 'react';\nimport { cn } from '@/lib/utils';\nimport {\n  ChatMessageRoleUser,\n  ChatMessageContentTypeText,\n  type ChatMessageDTO,\n} from '@inferencesh/sdk';\nimport { useChatWidth } from '@/hooks/use-chat-width';\nimport { useShrinkwrap } from '@/hooks/use-shrinkwrap';\n\nconst BUBBLE_MAX_RATIO = 0.7\nconst BUBBLE_PADDING_X = 12 // p-3\nconst BUBBLE_PADDING_Y = 12 // p-3 (user only, assistant has no padding)\n\n/**\n * Returns the bubble chrome dimensions for measurement.\n * Components own their measurement — strategy just calls this.\n */\nexport function measureBubbleChrome(role: string, containerWidth: number): {\n  innerWidth: number\n  paddingY: number\n} {\n  const isUser = role === ChatMessageRoleUser\n  if (isUser) {\n    const maxBubble = Math.floor(containerWidth * BUBBLE_MAX_RATIO)\n    return {\n      innerWidth: maxBubble - BUBBLE_PADDING_X * 2,\n      paddingY: BUBBLE_PADDING_Y * 2,\n    }\n  }\n  return {\n    innerWidth: containerWidth,\n    paddingY: 0,\n  }\n}\n\nfunction getUserText(message: ChatMessageDTO): string | undefined {\n  if (message.role !== ChatMessageRoleUser) return undefined\n  const parts: string[] = []\n  for (const c of message.content) {\n    if (c.type === ChatMessageContentTypeText && c.text) parts.push(c.text)\n  }\n  return parts.length > 0 ? parts.join('\\n') : undefined\n}\n\ninterface MessageBubbleProps {\n  message: ChatMessageDTO;\n  children?: ReactNode;\n  className?: string;\n}\n\n/**\n * MessageBubble - Styled container for messages\n *\n * @example\n * ```tsx\n * <MessageBubble message={message}>\n *   <MessageContent message={message} />\n *   <ToolInvocations message={message} />\n * </MessageBubble>\n * ```\n */\nexport const MessageBubble = memo(function MessageBubble({\n  message,\n  children,\n  className,\n}: MessageBubbleProps) {\n  const isUser = message.role === ChatMessageRoleUser;\n  const chatWidth = useChatWidth();\n  const maxBubbleWidth = Math.floor(chatWidth * BUBBLE_MAX_RATIO);\n  const userText = getUserText(message);\n  const shrinkWidth = useShrinkwrap(userText, maxBubbleWidth, { paddingX: BUBBLE_PADDING_X });\n\n  return (\n    <div\n      className={cn(\n        'group relative w-full',\n        isUser ? 'flex justify-end' : 'flex justify-start',\n        className\n      )}\n    >\n      <div\n        className={cn(\n          'relative rounded-xl text-sm break-words [&_*]:max-w-full [&_*]:min-w-0 flex flex-col gap-1.5',\n          isUser\n            ? 'bg-muted/50 text-foreground max-w-[70%] min-w-0 p-3'\n            : 'text-foreground max-w-full min-w-0 w-full',\n        )}\n        style={shrinkWidth !== undefined ? { width: shrinkWidth } : undefined}\n      >\n        {children}\n      </div>\n    </div>\n  );\n});\n\nMessageBubble.displayName = 'MessageBubble';\n",
      "type": "registry:component",
      "target": "components/infsh/agent/message-bubble.tsx"
    },
    {
      "path": "components/infsh/agent/message-content.tsx",
      "content": "import React, { memo, useState } from 'react';\nimport { cn } from '@/lib/utils';\nimport { Markdown } from '@/lib/pretext-md/react';\nimport { Button } from '@/components/ui/button';\nimport { FileIcon, ExternalLink } from 'lucide-react';\nimport {\n  ChatMessageRoleUser,\n  ChatMessageContentTypeText,\n  ChatMessageContentTypeImage,\n  ChatMessageContentTypeFile,\n} from '@inferencesh/sdk';\nimport type { ChatMessageDTO } from '@inferencesh/sdk';\n\ninterface MessageContentProps {\n  message: ChatMessageDTO;\n  className?: string;\n  truncate?: boolean;\n  /** Custom markdown renderer - defaults to pretext-md Markdown */\n  renderMarkdown?: (content: string) => React.ReactNode;\n}\n\n// =============================================================================\n// Helper functions\n// =============================================================================\n\nfunction getTextContent(message: ChatMessageDTO): string | undefined {\n  const textContent = message.content.find((c) => c.type === ChatMessageContentTypeText);\n  return textContent?.text;\n}\n\nfunction getImageUrls(message: ChatMessageDTO): string[] {\n  return message.content\n    .filter((c) => c.type === ChatMessageContentTypeImage && c.image)\n    .map((c) => c.image!);\n}\n\nfunction getFileUrls(message: ChatMessageDTO): string[] {\n  return message.content\n    .filter((c) => c.type === ChatMessageContentTypeFile && c.file)\n    .map((c) => c.file!);\n}\n\nfunction getFileName(url: string): string {\n  // Try to extract filename from URL\n  const parts = url.split('/');\n  const lastPart = parts[parts.length - 1];\n  // Remove query params\n  return lastPart.split('?')[0] || 'file';\n}\n\nfunction getFileExtension(filename: string): string {\n  const ext = filename.split('.').pop()?.toUpperCase() || '';\n  return ext.length <= 5 ? ext : ext.slice(0, 5);\n}\n\nfunction isImageUrl(url: string): boolean {\n  const ext = url.split('?')[0].split('.').pop()?.toLowerCase() || '';\n  return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);\n}\n\nfunction isVideoUrl(url: string): boolean {\n  const ext = url.split('?')[0].split('.').pop()?.toLowerCase() || '';\n  return ['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext);\n}\n\n// =============================================================================\n// File Attachment Component\n// =============================================================================\n\ninterface FileAttachmentProps {\n  url: string;\n  className?: string;\n}\n\nconst FileAttachment = memo(function FileAttachment({ url, className }: FileAttachmentProps) {\n  const fileName = getFileName(url);\n  const extension = getFileExtension(fileName);\n  const isImage = isImageUrl(url);\n  const isVideo = isVideoUrl(url);\n\n  return (\n    <a\n      href={url}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className={cn(\n        'flex items-center gap-2.5 rounded-lg border bg-muted/30 p-2 pr-3',\n        'hover:bg-muted/50 transition-colors cursor-pointer',\n        'max-w-[240px]',\n        className\n      )}\n    >\n      {/* Thumbnail */}\n      <div className=\"relative h-10 w-10 shrink-0 overflow-hidden rounded-md border bg-muted\">\n        {isImage ? (\n          <img\n            src={url}\n            alt={fileName}\n            className=\"h-full w-full object-cover\"\n          />\n        ) : isVideo ? (\n          <div className=\"relative h-full w-full bg-black/10\">\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n              <div className=\"rounded-full bg-white/90 p-1\">\n                <svg className=\"h-3 w-3 text-black\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                  <path d=\"M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z\" />\n                </svg>\n              </div>\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex h-full w-full flex-col items-center justify-center\">\n            <FileIcon className=\"h-4 w-4 text-muted-foreground\" />\n            <span className=\"text-[7px] font-medium text-muted-foreground mt-0.5\">\n              {extension}\n            </span>\n          </div>\n        )}\n      </div>\n\n      {/* File info */}\n      <div className=\"flex-1 min-w-0\">\n        <p className=\"truncate text-xs font-medium\">{fileName}</p>\n        <p className=\"text-[10px] text-muted-foreground flex items-center gap-1\">\n          <ExternalLink className=\"h-2.5 w-2.5\" />\n          open file\n        </p>\n      </div>\n    </a>\n  );\n});\n\n// =============================================================================\n// Image Attachment Component\n// =============================================================================\n\ninterface ImageAttachmentProps {\n  url: string;\n  className?: string;\n}\n\nconst ImageAttachment = memo(function ImageAttachment({ url, className }: ImageAttachmentProps) {\n  return (\n    <a\n      href={url}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className={cn(\n        'block overflow-hidden rounded-lg border cursor-pointer',\n        'hover:opacity-90 transition-opacity',\n        className\n      )}\n    >\n      <img\n        src={url}\n        alt=\"Attached image\"\n        className=\"max-w-[300px] max-h-[300px] object-contain\"\n      />\n    </a>\n  );\n});\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * MessageContent - Renders message text with markdown\n *\n * @example\n * ```tsx\n * <MessageContent message={message} />\n * ```\n */\nexport const MessageContent = memo(function MessageContent({\n  message,\n  className,\n  truncate = false,\n  renderMarkdown,\n}: MessageContentProps) {\n  const isUser = message.role === ChatMessageRoleUser;\n  const textContent = getTextContent(message);\n  const imageUrls = getImageUrls(message);\n  const fileUrls = getFileUrls(message);\n\n  const [isExpanded, setIsExpanded] = useState(false);\n  const MAX_LENGTH = 500;\n  const shouldTruncate = truncate && isUser && (textContent?.length || 0) > MAX_LENGTH;\n  const displayContent = shouldTruncate && !isExpanded\n    ? textContent?.slice(0, MAX_LENGTH) + '...'\n    : textContent;\n\n  // Don't render if no content\n  if (!textContent && imageUrls.length === 0 && fileUrls.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className={cn('w-full', className)}>\n      {/* Images */}\n      {imageUrls.length > 0 && (\n        <div className=\"mb-3 flex flex-wrap gap-2\">\n          {imageUrls.map((url, index) => (\n            <ImageAttachment key={index} url={url} />\n          ))}\n        </div>\n      )}\n\n      {/* Files */}\n      {fileUrls.length > 0 && (\n        <div className=\"mb-3 flex flex-wrap gap-2\">\n          {fileUrls.map((url, index) => (\n            <FileAttachment key={index} url={url} />\n          ))}\n        </div>\n      )}\n\n      {/* Text content */}\n      {textContent && textContent.length > 0 && (\n        <div className=\"w-full\">\n          {isUser ? (\n            <div className=\"flex flex-col gap-2\">\n              <div className=\"whitespace-pre-wrap\"><span>{displayContent}</span></div>\n              {shouldTruncate && (\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={() => setIsExpanded(!isExpanded)}\n                  className=\"self-end text-xs h-6 px-2 text-muted-foreground hover:text-foreground cursor-pointer\"\n                >\n                  {isExpanded ? 'show less' : 'show more'}\n                </Button>\n              )}\n            </div>\n          ) : (\n            renderMarkdown ? renderMarkdown(textContent) : <Markdown content={textContent} />\n          )}\n        </div>\n      )}\n    </div>\n  );\n});\n\nMessageContent.displayName = 'MessageContent';\n",
      "type": "registry:component",
      "target": "components/infsh/agent/message-content.tsx"
    },
    {
      "path": "components/infsh/agent/message-reasoning.tsx",
      "content": "import React, { memo, useState } from 'react';\nimport { MessageCircleDashed } from 'lucide-react';\nimport { Spinner } from '@/components/ui/spinner';\nimport { CollapsibleSection } from '@/components/ui/collapsible-section';\nimport { Markdown } from '@/lib/pretext-md/react';\n\n// Collapsed trigger: py-0.5 (4px) + flex row with text-xs (~16px line) + icon (12px)\n// CollapsibleSection wrapper adds no extra height when closed\nconst TRIGGER_HEIGHT = 20\n\n/**\n * Returns the predicted height of a reasoning block.\n * Components own their measurement — strategy just calls this.\n */\nexport function measureReasoning(reasoning: string | undefined, isOpen: boolean): number {\n  if (!reasoning?.trim()) return 0\n  if (!isOpen) return TRIGGER_HEIGHT\n  // Expanded: trigger + mt-1 (4px) + border (2px) + px-2 py-1.5 (12px) + content\n  // Content is capped at max-h-[200px], so worst case = 200\n  return TRIGGER_HEIGHT + 4 + 2 + 12 + 200\n}\n\ninterface MessageReasoningProps {\n  reasoning: string;\n  isReasoning?: boolean;\n  className?: string;\n}\n\n/**\n * MessageReasoning - Collapsible reasoning block\n *\n * @example\n * ```tsx\n * <MessageReasoning reasoning={reasoningText} isReasoning={true} />\n * ```\n */\nexport const MessageReasoning = memo(function MessageReasoning({\n  reasoning,\n  isReasoning = false,\n  className,\n}: MessageReasoningProps) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  if (!reasoning.trim()) return null;\n\n  const getLastLine = () => {\n    const lines = reasoning.trim().split('\\n').filter((line) => line.trim().length > 0);\n    for (let i = lines.length - 1; i >= 0; i--) {\n      const line = lines[i].trim();\n      if (line.length > 50) {\n        return line.length > 60 ? line.slice(0, 60) + '...' : line;\n      }\n    }\n    return null;\n  };\n\n  const label = isReasoning ? 'thinking' : 'thought';\n  const icon = isReasoning\n    ? <Spinner className=\"size-3\" />\n    : <MessageCircleDashed className=\"h-3 w-3\" />;\n  const preview =\n    isReasoning && getLastLine() ? (\n      <span className=\"text-muted-foreground/40 truncate max-w-[300px] lowercase ml-1\">\n        - {getLastLine()}\n      </span>\n    ) : null;\n\n  return (\n    <CollapsibleSection\n      icon={icon}\n      label={label}\n      open={isOpen}\n      onOpenChange={setIsOpen}\n      isActive={isReasoning}\n      preview={preview}\n      className={className}\n    >\n      <div className=\"px-2 py-1.5\">\n        <div className=\"whitespace-pre-wrap text-xs max-h-[200px] overflow-y-auto\">\n          <Markdown content={reasoning} />\n        </div>\n      </div>\n    </CollapsibleSection>\n  );\n});\n\nMessageReasoning.displayName = 'MessageReasoning';\n",
      "type": "registry:component",
      "target": "components/infsh/agent/message-reasoning.tsx"
    },
    {
      "path": "components/infsh/agent/message-status-indicator.tsx",
      "content": "import React, { memo } from 'react';\nimport { cn } from '@/lib/utils';\nimport { Spinner } from '@/components/ui/spinner';\n\n// =============================================================================\n// Props\n// =============================================================================\n\nexport interface MessageStatusIndicatorProps {\n  /** Additional CSS classes */\n  className?: string;\n  /** Size of the loader icon */\n  size?: number;\n  /** Whether to show text label */\n  showLabel?: boolean;\n  /** Custom label text (defaults to \"generating...\") */\n  label?: string;\n}\n\n// =============================================================================\n// Component\n// =============================================================================\n\n/**\n * MessageStatusIndicator - Shows when a message is still being generated\n * \n * Place this at the end of a message to show the assistant is still working.\n * It will automatically hide when the message reaches a terminal status.\n * \n * @example\n * ```tsx\n * <MessageBubble message={message}>\n *   <MessageContent message={message} />\n *   <ToolInvocations message={message} />\n *   <MessageStatusIndicator message={message} />\n * </MessageBubble>\n * ```\n * \n * @example With custom styling\n * ```tsx\n * <MessageStatusIndicator \n *   message={message} \n *   size={16}\n *   showLabel={false}\n *   className=\"mt-2\"\n * />\n * ```\n */\nexport const MessageStatusIndicator = memo(function MessageStatusIndicator({\n  className,\n  size = 12,\n  showLabel = true,\n  label = 'generating...',\n}: MessageStatusIndicatorProps) {\n\n  return (\n    <div\n      className={cn(\n        'flex items-center gap-1 text-muted-foreground py-1',\n        className\n      )}\n    >\n      <Spinner className=\"size-4\" style={{ width: size, height: size }} />\n      {showLabel && (\n        <span className=\"text-xs opacity-70\">{label}</span>\n      )}\n    </div>\n  );\n});\n\nMessageStatusIndicator.displayName = 'MessageStatusIndicator';\n",
      "type": "registry:component",
      "target": "components/infsh/agent/message-status-indicator.tsx"
    },
    {
      "path": "components/infsh/agent/file-upload.tsx",
      "content": "import React, { memo, useCallback, useState, useEffect } from 'react';\nimport { cn } from '@/lib/utils';\nimport { X, FileIcon, Loader2, AlertCircle, Check } from 'lucide-react';\nimport { useAgentActions, type FileRef } from '@inferencesh/sdk/agent';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport type FileUploadStatus = 'pending' | 'uploading' | 'completed' | 'failed';\n\nexport interface FileUpload {\n  id: string;\n  file: File;\n  status: FileUploadStatus;\n  uploadedFile?: FileRef;\n  error?: string;\n}\n\nexport interface FileUploadManagerState {\n  uploads: FileUpload[];\n  addFiles: (files: File[]) => void;\n  removeUpload: (id: string) => void;\n  clearAll: () => void;\n  getFileRefs: () => FileRef[];\n  hasPendingUploads: boolean;\n  hasCompletedUploads: boolean;\n}\n\n// =============================================================================\n// Hook: useFileUploadManager (uploads files on select using SDK)\n// =============================================================================\n\nexport function useFileUploadManager(): FileUploadManagerState {\n  const [uploads, setUploads] = useState<FileUpload[]>([]);\n  const { uploadFile } = useAgentActions();\n\n  // Upload a single file\n  const uploadSingleFile = useCallback(async (upload: FileUpload) => {\n    // Set status to uploading\n    setUploads(prev => prev.map(u =>\n      u.id === upload.id ? { ...u, status: 'uploading' as FileUploadStatus } : u\n    ));\n\n    try {\n      const uploadedFile = await uploadFile(upload.file);\n      setUploads(prev => prev.map(u =>\n        u.id === upload.id\n          ? { ...u, status: 'completed' as FileUploadStatus, uploadedFile }\n          : u\n      ));\n    } catch (err) {\n      setUploads(prev => prev.map(u =>\n        u.id === upload.id\n          ? { ...u, status: 'failed' as FileUploadStatus, error: String(err) }\n          : u\n      ));\n    }\n  }, [uploadFile]);\n\n  const addFiles = useCallback((files: File[]) => {\n    const newUploads: FileUpload[] = files.map(file => ({\n      id: `${file.name}-${Date.now()}-${Math.random().toString(36).slice(2)}`,\n      file,\n      status: 'pending' as FileUploadStatus,\n    }));\n\n    setUploads(prev => [...prev, ...newUploads]);\n\n    // Start uploading each file\n    newUploads.forEach(upload => {\n      uploadSingleFile(upload);\n    });\n  }, [uploadSingleFile]);\n\n  const removeUpload = useCallback((id: string) => {\n    setUploads(prev => prev.filter(u => u.id !== id));\n  }, []);\n\n  const clearAll = useCallback(() => {\n    setUploads([]);\n  }, []);\n\n  const getFileRefs = useCallback(() => {\n    return uploads\n      .filter(u => u.status === 'completed' && u.uploadedFile)\n      .map(u => u.uploadedFile!);\n  }, [uploads]);\n\n  const hasPendingUploads = uploads.some(u => u.status === 'pending' || u.status === 'uploading');\n  const hasCompletedUploads = uploads.some(u => u.status === 'completed');\n\n  return {\n    uploads,\n    addFiles,\n    removeUpload,\n    clearAll,\n    getFileRefs,\n    hasPendingUploads,\n    hasCompletedUploads,\n  };\n}\n\n// =============================================================================\n// File Type Helpers\n// =============================================================================\n\nfunction getFileType(file: File): 'image' | 'video' | 'text' | 'generic' {\n  if (file.type.startsWith('image/')) return 'image';\n  if (file.type.startsWith('video/')) return 'video';\n  if (\n    file.type.startsWith('text/') ||\n    file.name.endsWith('.txt') ||\n    file.name.endsWith('.md') ||\n    file.name.endsWith('.csv') ||\n    file.name.endsWith('.json') ||\n    file.name.endsWith('.xml') ||\n    file.name.endsWith('.yaml') ||\n    file.name.endsWith('.yml')\n  ) {\n    return 'text';\n  }\n  return 'generic';\n}\n\nfunction getFileExtension(filename: string): string {\n  const ext = filename.split('.').pop()?.toUpperCase() || '';\n  return ext.length <= 4 ? ext : ext.slice(0, 4);\n}\n\nfunction formatFileSize(bytes: number): string {\n  if (bytes === 0) return '0 B';\n  const k = 1024;\n  const sizes = ['B', 'KB', 'MB', 'GB'];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;\n}\n\n// =============================================================================\n// FileUploadPreview Component\n// =============================================================================\n\ninterface FileUploadPreviewProps {\n  upload: FileUpload;\n  onRemove: () => void;\n  className?: string;\n}\n\nexport const FileUploadPreview = memo(function FileUploadPreview({\n  upload,\n  onRemove,\n  className,\n}: FileUploadPreviewProps) {\n  const { file, status } = upload;\n  const fileType = getFileType(file);\n  const previewUrl = (fileType === 'image' || fileType === 'video')\n    ? URL.createObjectURL(file)\n    : null;\n  const [textPreview, setTextPreview] = useState<string>('');\n\n  useEffect(() => {\n    if (fileType === 'text') {\n      const reader = new FileReader();\n      reader.onload = (e) => {\n        const text = e.target?.result as string;\n        setTextPreview(text.slice(0, 100));\n      };\n      reader.readAsText(file.slice(0, 200));\n    }\n  }, [file, fileType]);\n\n  // Cleanup object URL on unmount\n  useEffect(() => {\n    return () => {\n      if (previewUrl) {\n        URL.revokeObjectURL(previewUrl);\n      }\n    };\n  }, [previewUrl]);\n\n  const isUploading = status === 'pending' || status === 'uploading';\n  const isFailed = status === 'failed';\n  const isCompleted = status === 'completed';\n\n  return (\n    <div\n      className={cn(\n        'relative flex items-center gap-2.5 rounded-lg border bg-muted/30 p-1.5 pr-8',\n        'max-w-[220px] animate-in fade-in slide-in-from-bottom-2 duration-150',\n        isFailed && 'border-destructive/50 bg-destructive/5',\n        className\n      )}\n    >\n      {/* Thumbnail */}\n      <div className=\"relative h-10 w-10 shrink-0 overflow-hidden rounded-md border bg-muted\">\n        {fileType === 'image' && previewUrl && (\n          <img src={previewUrl} alt={file.name} className=\"h-full w-full object-cover\" />\n        )}\n\n        {fileType === 'video' && previewUrl && (\n          <div className=\"relative h-full w-full\">\n            <video src={previewUrl} className=\"h-full w-full object-cover\" muted />\n            <div className=\"absolute inset-0 flex items-center justify-center bg-black/20\">\n              <div className=\"rounded-full bg-white/90 p-1\">\n                <svg className=\"h-3 w-3 text-black\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                  <path d=\"M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z\" />\n                </svg>\n              </div>\n            </div>\n          </div>\n        )}\n\n        {fileType === 'text' && (\n          <div className=\"flex h-full w-full flex-col items-center justify-center p-0.5\">\n            <div className=\"h-full w-full overflow-hidden rounded-sm bg-background/50 p-0.5\">\n              <div className=\"text-[5px] leading-tight text-muted-foreground/70 line-clamp-4\">\n                {textPreview || '...'}\n              </div>\n            </div>\n          </div>\n        )}\n\n        {fileType === 'generic' && (\n          <div className=\"flex h-full w-full flex-col items-center justify-center\">\n            <FileIcon className=\"h-4 w-4 text-muted-foreground\" />\n            <span className=\"text-[7px] font-medium text-muted-foreground mt-0.5\">\n              {getFileExtension(file.name)}\n            </span>\n          </div>\n        )}\n\n        {/* Upload status overlay */}\n        {isUploading && (\n          <div className=\"absolute inset-0 flex items-center justify-center bg-background/80\">\n            <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n          </div>\n        )}\n        {isFailed && (\n          <div className=\"absolute inset-0 flex items-center justify-center bg-destructive/20\">\n            <AlertCircle className=\"h-4 w-4 text-destructive\" />\n          </div>\n        )}\n        {isCompleted && (\n          <div className=\"absolute bottom-0 right-0 rounded-tl bg-emerald-500 p-0.5\">\n            <Check className=\"h-2.5 w-2.5 text-white\" />\n          </div>\n        )}\n      </div>\n\n      {/* File info */}\n      <div className=\"flex-1 min-w-0\">\n        <p className=\"truncate text-xs font-medium\">{file.name}</p>\n        <p className={cn(\n          \"text-[10px]\",\n          isFailed ? \"text-destructive\" : \"text-muted-foreground\"\n        )}>\n          {isFailed ? 'Upload failed' : isUploading ? 'Uploading...' : formatFileSize(file.size)}\n        </p>\n      </div>\n\n      {/* Remove button */}\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        className=\"absolute right-1 top-1 rounded-full p-1 transition-colors cursor-pointer hover:bg-muted-foreground/20\"\n        aria-label=\"Remove file\"\n      >\n        <X className=\"h-3 w-3\" />\n      </button>\n    </div>\n  );\n});\n\n// =============================================================================\n// FileUploadList Component\n// =============================================================================\n\ninterface FileUploadListProps {\n  uploads: FileUpload[];\n  onRemove: (id: string) => void;\n  className?: string;\n}\n\nexport const FileUploadList = memo(function FileUploadList({\n  uploads,\n  onRemove,\n  className,\n}: FileUploadListProps) {\n  if (uploads.length === 0) return null;\n\n  return (\n    <div className={cn('flex flex-wrap gap-2', className)}>\n      {uploads.map(upload => (\n        <FileUploadPreview\n          key={upload.id}\n          upload={upload}\n          onRemove={() => onRemove(upload.id)}\n        />\n      ))}\n    </div>\n  );\n});\n\n// =============================================================================\n// Utility Functions\n// =============================================================================\n\nexport function showFileUploadDialog(accept: string = '*/*'): Promise<File[] | null> {\n  const input = document.createElement('input');\n  input.type = 'file';\n  input.multiple = true;\n  input.accept = accept;\n  input.click();\n\n  return new Promise((resolve) => {\n    input.onchange = (e) => {\n      const files = (e.currentTarget as HTMLInputElement).files;\n      if (files) {\n        resolve(Array.from(files));\n        return;\n      }\n      resolve(null);\n    };\n  });\n}\n",
      "type": "registry:component",
      "target": "components/infsh/agent/file-upload.tsx"
    },
    {
      "path": "components/ui/collapsible-section.tsx",
      "content": "import React, { memo } from 'react';\nimport { cn } from '@/lib/utils';\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from '@/components/ui/collapsible';\nimport { ChevronDown, ChevronRight } from 'lucide-react';\n\ninterface CollapsibleSectionProps {\n  icon: React.ReactNode;\n  label: React.ReactNode;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  isActive?: boolean;\n  preview?: React.ReactNode;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport const CollapsibleSection = memo(function CollapsibleSection({\n  icon,\n  label,\n  open,\n  onOpenChange,\n  isActive,\n  preview,\n  className,\n  children,\n}: CollapsibleSectionProps) {\n  return (\n    <div className={cn('flex flex-col items-start w-fit', className)}>\n      <Collapsible\n        open={open}\n        onOpenChange={onOpenChange}\n        className=\"group w-full text-muted-foreground\"\n      >\n        <div className=\"flex items-center px-0 py-0.5\">\n          <CollapsibleTrigger\n            render={<button className=\"flex items-center gap-1.5 text-xs text-muted-foreground/50 hover:text-muted-foreground cursor-pointer\" />}\n          >\n            {icon}\n            <span className={cn('lowercase', isActive && 'animate-pulse')}>\n              {label}\n            </span>\n            {!open && preview}\n            {open ? <ChevronDown className=\"h-3 w-3\" /> : <ChevronRight className=\"h-3 w-3\" />}\n          </CollapsibleTrigger>\n        </div>\n        <CollapsibleContent\n          className={cn(\n            open && 'border border-border bg-muted/10 rounded-xl mt-1'\n          )}\n        >\n          {children}\n        </CollapsibleContent>\n      </Collapsible>\n    </div>\n  );\n});\n\nCollapsibleSection.displayName = 'CollapsibleSection';\n",
      "type": "registry:lib",
      "target": "components/ui/collapsible-section.tsx"
    },
    {
      "path": "lib/message-strategy.ts",
      "content": "import {\n  ChatMessageRoleUser,\n  ChatMessageRoleAssistant,\n  ChatMessageContentTypeReasoning,\n  ChatMessageContentTypeText,\n  type ChatMessageDTO,\n} from '@inferencesh/sdk'\nimport { parse } from '@/lib/pretext-md/core/parser'\nimport { measureBlocks } from '@/lib/pretext-md/core/block-layout'\nimport { defaultConfig } from '@/lib/pretext-md/react/context'\nimport { defaultPlugins } from '@/lib/pretext-md/react/plugins'\nimport { measureBubbleChrome } from '@/components/infsh/agent/message-bubble'\nimport { measureReasoning } from '@/components/infsh/agent/message-reasoning'\nimport { measureToolInvocations } from '@/components/infsh/agent/tool-invocations'\nimport type { MeasureStrategy } from '@/lib/virtualize'\n\nconst plugins = defaultPlugins()\n\nexport function messageStrategy(message: ChatMessageDTO): MeasureStrategy {\n  // Pre-parse once — AST doesn't change with width.\n  // Only measureBlocks() depends on width. This avoids re-parsing on resize.\n  const text = message.content?.find(\n    c => c.type === ChatMessageContentTypeText\n  )?.text\n  const blocks = text?.trim() ? parse(text) : null\n\n  // Pre-extract static values\n  const reasoning = message.role === ChatMessageRoleAssistant\n    ? message.content?.find(c => c.type === ChatMessageContentTypeReasoning)?.text\n    : undefined\n  const toolInvocations = message.role === ChatMessageRoleAssistant\n    ? message.tool_invocations\n    : undefined\n\n  return {\n    kind: 'computed',\n    measure: (width) => {\n      const bubble = measureBubbleChrome(message.role, width)\n      let height = bubble.paddingY\n\n      height += measureReasoning(reasoning, false)\n\n      if (blocks) {\n        const result = measureBlocks(blocks, {\n          maxWidth: bubble.innerWidth,\n          fonts: defaultConfig.fonts,\n          lineHeights: defaultConfig.lineHeights,\n          plugins,\n        })\n        height += result.height\n      }\n\n      height += measureToolInvocations(toolInvocations)\n\n      return height\n    },\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/message-strategy.ts"
    },
    {
      "path": "hooks/use-auto-scroll.ts",
      "content": "import { useCallback, useEffect, useRef, useState } from 'react';\n\n// How many pixels from the bottom of the container to enable auto-scroll\nconst ACTIVATION_THRESHOLD = 50;\n// Minimum pixels of scroll-up movement required to disable auto-scroll\nconst MIN_SCROLL_UP_THRESHOLD = 10;\n\nexport function useAutoScroll(dependencies: React.DependencyList) {\n    const containerRef = useRef<HTMLDivElement | null>(null);\n    const previousScrollTop = useRef<number | null>(null);\n    const [shouldAutoScroll, setShouldAutoScroll] = useState(true);\n    // Use a ref to track current value for ResizeObserver callback (avoids re-subscribing)\n    const shouldAutoScrollRef = useRef(shouldAutoScroll);\n    shouldAutoScrollRef.current = shouldAutoScroll;\n\n    const scrollToBottom = useCallback(() => {\n        if (containerRef.current) {\n            containerRef.current.scrollTop = containerRef.current.scrollHeight;\n        }\n    }, []);\n\n    const handleScroll = useCallback(() => {\n        if (containerRef.current) {\n            const { scrollTop, scrollHeight, clientHeight } = containerRef.current;\n\n            // Detect Safari's rubber-band/bounce scrolling at edges.\n            // When bouncing, scrollTop can go negative (top) or exceed max (bottom).\n            // Ignore these events to avoid false \"deliberate scroll up\" detection.\n            const isOverscrolling =\n                scrollTop < 0 || scrollTop + clientHeight > scrollHeight + 1;\n\n            if (isOverscrolling) {\n                // Don't update previousScrollTop during overscroll to avoid\n                // detecting the bounce-back as intentional scrolling\n                return;\n            }\n\n            const distanceFromBottom = Math.abs(\n                scrollHeight - scrollTop - clientHeight\n            );\n\n            const isScrollingUp = previousScrollTop.current !== null\n                ? scrollTop < previousScrollTop.current\n                : false;\n\n            const scrollUpDistance = previousScrollTop.current !== null\n                ? previousScrollTop.current - scrollTop\n                : 0;\n\n            const isDeliberateScrollUp =\n                isScrollingUp && scrollUpDistance > MIN_SCROLL_UP_THRESHOLD;\n\n            // Check if we're at the bottom\n            const isScrolledToBottom = distanceFromBottom < ACTIVATION_THRESHOLD;\n\n            if (isDeliberateScrollUp && !isScrolledToBottom) {\n                // User deliberately scrolled up AND is not at bottom - disable auto-scroll\n                setShouldAutoScroll(false);\n            } else if (!isScrollingUp || isScrolledToBottom) {\n                // Either scrolling down, or content changed, or at bottom\n                setShouldAutoScroll(isScrolledToBottom);\n            }\n            // When scrolling up but not deliberately (small amounts), do nothing\n            // to avoid toggling shouldAutoScroll and causing feedback loops\n\n            previousScrollTop.current = scrollTop;\n        }\n    }, []);\n\n    const handleTouchStart = useCallback(() => {\n        setShouldAutoScroll(false);\n    }, []);\n\n    useEffect(() => {\n        if (containerRef.current) {\n            previousScrollTop.current = containerRef.current.scrollTop;\n        }\n    }, []);\n\n    // Observe content height changes and auto-scroll if needed\n    useEffect(() => {\n        const container = containerRef.current;\n        if (!container) return;\n\n        let previousHeight = container.scrollHeight;\n\n        const resizeObserver = new ResizeObserver(() => {\n            const currentHeight = container.scrollHeight;\n            // Only auto-scroll when content grows, not when it shrinks\n            // (e.g., when generating indicator disappears)\n            if (shouldAutoScrollRef.current && currentHeight > previousHeight) {\n                scrollToBottom();\n            }\n            previousHeight = currentHeight;\n        });\n\n        // Observe the container itself for size changes\n        resizeObserver.observe(container);\n\n        return () => {\n            resizeObserver.disconnect();\n        };\n    }, [scrollToBottom]);\n\n    useEffect(() => {\n        if (shouldAutoScroll) {\n            scrollToBottom();\n        }\n        // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, dependencies);\n\n    return {\n        containerRef,\n        scrollToBottom,\n        handleScroll,\n        shouldAutoScroll,\n        handleTouchStart,\n    };\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-auto-scroll.ts"
    },
    {
      "path": "hooks/use-shrinkwrap.ts",
      "content": "import { useMemo } from 'react'\nimport { shrinkwrap } from '@/lib/pretext-md/core/shrinkwrap'\nimport { usePretextMdConfig } from '@/lib/pretext-md/react/context'\nimport type { FontConfig, LineHeightConfig } from '@/lib/pretext-md/core/types'\n\n/**\n * Hook that returns the tightest pixel width for a markdown string\n * that preserves the same total height.\n *\n * Reads font config from PretextMdContext — wrap your app with\n * <PretextMdContext.Provider> to configure fonts for your project.\n *\n * Returns undefined when shrinkwrap isn't needed (empty, single line).\n */\nexport function useShrinkwrap(\n  text: string | undefined,\n  maxWidth: number,\n  options?: {\n    font?: string\n    lineHeight?: number\n    paddingX?: number\n  },\n): number | undefined {\n  const config = usePretextMdConfig()\n  const font = options?.font ?? config.fonts.body\n  const lineHeight = options?.lineHeight ?? config.lineHeights.body\n  const paddingX = options?.paddingX ?? 12\n\n  return useMemo(() => {\n    if (!text?.trim()) return undefined\n    const contentWidth = maxWidth - paddingX * 2\n    if (contentWidth <= 0) return undefined\n\n    const fonts: FontConfig = {\n      ...config.fonts,\n      body: font,\n      bold: `bold ${font}`,\n      italic: `italic ${font}`,\n      boldItalic: `bold italic ${font}`,\n    }\n    const lineHeights: LineHeightConfig = { ...config.lineHeights, body: lineHeight }\n\n    const result = shrinkwrap(text, { maxWidth: contentWidth, fonts, lineHeights })\n    if (result.width >= contentWidth) return undefined\n\n    return result.width + paddingX * 2\n  }, [text, maxWidth, paddingX, font, lineHeight, config])\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-shrinkwrap.ts"
    },
    {
      "path": "hooks/use-chat-width.ts",
      "content": "'use client'\n\nimport { createContext, useContext } from 'react'\n\n/**\n * context for the chat container's content width.\n * set by ChatMessages, consumed by MessageBubble for shrinkwrap.\n */\nexport const ChatWidthContext = createContext<number>(0)\n\nexport function useChatWidth(): number {\n  return useContext(ChatWidthContext)\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-chat-width.ts"
    }
  ]
}
