{"version":1,"id":"composer","framework":"next","kind":"block","files":[{"path":"src/blocks/gfa/composer.tsx","content":"\"use client\";\n\nimport type { FC, ReactNode } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { ArrowUp, Paperclip, Plus, Wrench } from \"lucide-react\";\nimport {\n  ComposerFilePreview,\n  type ComposerUploadedFile,\n} from \"@/blocks/gfa/composer-file-preview\";\nimport {\n  ComposerSlashDropdown,\n  type ComposerSlashMatch,\n  type ComposerTool,\n} from \"@/blocks/gfa/composer-slash-dropdown\";\nimport { cn } from \"@/lib/utils\";\n\nexport type { ComposerTool, ComposerSlashMatch, ComposerUploadedFile };\nexport type Tool = ComposerTool;\nexport type UploadedFile = ComposerUploadedFile;\n\nexport type ComposerContextOption = {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  description?: string;\n  onClick?: () => void;\n};\n\nexport type ComposerProps = {\n  placeholder?: string;\n  onSubmit?: (message: string, files?: ComposerUploadedFile[]) => void;\n  onChange?: (value: string) => void;\n  disabled?: boolean;\n  showToolsButton?: boolean;\n  onToolSelect?: (tool: ComposerTool) => void;\n  tools?: ComposerTool[];\n  onAttachClick?: () => void;\n  contextOptions?: ComposerContextOption[];\n  autoFocus?: boolean;\n  maxRows?: number;\n  defaultValue?: string;\n  value?: string;\n  className?: string;\n  attachedFiles?: ComposerUploadedFile[];\n  onRemoveFile?: (id: string) => void;\n  isLoading?: boolean;\n};\n\n/** GFA Composer — chat input with tools + context menu (no third-party registry). */\nexport const Composer: FC<ComposerProps> = ({\n  placeholder = \"What can I do for you today?\",\n  onSubmit,\n  onChange,\n  disabled = false,\n  showToolsButton = true,\n  onToolSelect,\n  tools = [],\n  onAttachClick,\n  contextOptions,\n  autoFocus = false,\n  maxRows = 8,\n  defaultValue = \"\",\n  value,\n  className,\n  attachedFiles = [],\n  onRemoveFile,\n  isLoading = false,\n}) => {\n  const [inputValue, setInputValue] = useState(defaultValue);\n  const [isToolsDropdownOpen, setIsToolsDropdownOpen] = useState(false);\n  const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);\n  const [selectedCategory, setSelectedCategory] = useState(\"all\");\n  const selectedToolIndex = 0;\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const composerRef = useRef<HTMLDivElement>(null);\n\n  const currentValue = value !== undefined ? value : inputValue;\n\n  const handleInputChange = useCallback(\n    (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n      const next = e.target.value;\n      if (value === undefined) setInputValue(next);\n      onChange?.(next);\n    },\n    [onChange, value],\n  );\n\n  useEffect(() => {\n    const textarea = textareaRef.current;\n    if (!textarea) return;\n    textarea.style.height = \"auto\";\n    const lineHeight = 24;\n    const maxHeight = lineHeight * maxRows;\n    textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;\n  }, [currentValue, maxRows]);\n\n  const handleSubmit = useCallback(\n    (e?: React.FormEvent) => {\n      e?.preventDefault();\n      if (isLoading) return;\n      if (currentValue.trim() || attachedFiles.length > 0) {\n        onSubmit?.(currentValue, attachedFiles);\n        if (value === undefined) setInputValue(\"\");\n      }\n    },\n    [currentValue, attachedFiles, onSubmit, value, isLoading],\n  );\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n      if (e.key === \"Enter\" && !e.shiftKey && !disabled && !isLoading) {\n        e.preventDefault();\n        handleSubmit();\n      }\n      if (e.key === \"Escape\") {\n        setIsToolsDropdownOpen(false);\n        setIsContextMenuOpen(false);\n      }\n    },\n    [handleSubmit, disabled, isLoading],\n  );\n\n  const handleToolsClick = useCallback(() => {\n    if (isLoading) return;\n    setIsToolsDropdownOpen((open) => !open);\n    setIsContextMenuOpen(false);\n  }, [isLoading]);\n\n  const handleContextClick = useCallback(() => {\n    if (isLoading) return;\n    if (contextOptions && contextOptions.length > 0) {\n      setIsContextMenuOpen((open) => !open);\n      setIsToolsDropdownOpen(false);\n    } else {\n      onAttachClick?.();\n    }\n  }, [contextOptions, onAttachClick, isLoading]);\n\n  const handleToolSelect = useCallback(\n    (match: ComposerSlashMatch) => {\n      onToolSelect?.(match.tool);\n      setIsToolsDropdownOpen(false);\n    },\n    [onToolSelect],\n  );\n\n  useEffect(() => {\n    const onDocClick = (event: MouseEvent) => {\n      const target = event.target as Element;\n      if (\n        !composerRef.current?.contains(target) &&\n        !target.closest(\".slash-command-dropdown\")\n      ) {\n        setIsToolsDropdownOpen(false);\n        setIsContextMenuOpen(false);\n      }\n    };\n    document.addEventListener(\"click\", onDocClick);\n    return () => document.removeEventListener(\"click\", onDocClick);\n  }, []);\n\n  useEffect(() => {\n    if (autoFocus) textareaRef.current?.focus();\n  }, [autoFocus]);\n\n  const toolMatches: ComposerSlashMatch[] = tools.map((tool) => ({\n    tool,\n    score: 1,\n  }));\n  const categories = [\"all\", ...new Set(tools.map((t) => t.category))];\n  const canSubmit = Boolean(currentValue.trim() || attachedFiles.length > 0);\n\n  return (\n    <div className={cn(\"relative w-full\", className)}>\n      <div\n        ref={composerRef}\n        data-gfa-composer=\"\"\n        className=\"relative rounded-3xl bg-muted px-1 pt-1 pb-2\"\n      >\n        {showToolsButton && tools.length > 0 && isToolsDropdownOpen ? (\n          <div className=\"absolute right-0 bottom-full left-0 z-50 mb-2\">\n            <ComposerSlashDropdown\n              matches={toolMatches}\n              selectedIndex={selectedToolIndex}\n              onSelect={handleToolSelect}\n              onClose={() => setIsToolsDropdownOpen(false)}\n              selectedCategory={selectedCategory}\n              categories={categories}\n              onCategoryChange={setSelectedCategory}\n              className=\"w-full\"\n            />\n          </div>\n        ) : null}\n\n        <ComposerFilePreview\n          files={attachedFiles}\n          onRemove={onRemoveFile}\n          className=\"rounded-xl\"\n        />\n\n        <form onSubmit={handleSubmit}>\n          <div className=\"relative px-3\">\n            <textarea\n              ref={textareaRef}\n              value={currentValue}\n              onChange={handleInputChange}\n              onKeyDown={handleKeyDown}\n              placeholder={placeholder}\n              disabled={disabled || isLoading}\n              rows={1}\n              className={cn(\n                \"w-full resize-none bg-transparent py-3 pr-24 text-base font-light text-foreground transition-all\",\n                \"placeholder:text-muted-foreground focus:outline-none\",\n                \"disabled:cursor-not-allowed disabled:opacity-50\",\n              )}\n              style={{\n                minHeight: \"24px\",\n                maxHeight: `${24 * maxRows}px`,\n              }}\n            />\n            <div className=\"pointer-events-none absolute top-1/2 right-3 flex -translate-y-1/2 items-center gap-1 text-xs text-muted-foreground\">\n              <kbd className=\"rounded bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground\">\n                /\n              </kbd>\n              <span>for tools</span>\n            </div>\n          </div>\n        </form>\n\n        <div className=\"flex items-center justify-between px-2 pt-1\">\n          <div className=\"flex items-center gap-1\">\n            <div className=\"relative\">\n              <button\n                type=\"button\"\n                onClick={handleContextClick}\n                disabled={disabled || isLoading}\n                className={cn(\n                  \"relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-secondary transition-colors\",\n                  \"hover:bg-accent disabled:cursor-wait disabled:opacity-70\",\n                  isContextMenuOpen && \"bg-accent\",\n                )}\n                aria-label=\"Add context or attach files\"\n              >\n                {contextOptions?.length ? (\n                  <Plus className=\"size-5 text-muted-foreground\" />\n                ) : (\n                  <Paperclip className=\"size-4 text-muted-foreground\" />\n                )}\n              </button>\n\n              {isContextMenuOpen && contextOptions ? (\n                <div className=\"absolute bottom-full left-0 mb-2 min-w-[220px] overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl\">\n                  {contextOptions.map((option) => (\n                    <button\n                      key={option.id}\n                      type=\"button\"\n                      onClick={() => {\n                        option.onClick?.();\n                        setIsContextMenuOpen(false);\n                      }}\n                      className=\"flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-muted\"\n                    >\n                      {option.icon ? (\n                        <span className=\"shrink-0 text-foreground\">\n                          {option.icon}\n                        </span>\n                      ) : null}\n                      <span className=\"flex flex-col\">\n                        <span>{option.label}</span>\n                        {option.description ? (\n                          <span className=\"text-xs text-muted-foreground\">\n                            {option.description}\n                          </span>\n                        ) : null}\n                      </span>\n                    </button>\n                  ))}\n                </div>\n              ) : null}\n            </div>\n\n            {showToolsButton ? (\n              <button\n                type=\"button\"\n                onClick={handleToolsClick}\n                disabled={disabled || isLoading}\n                className={cn(\n                  \"relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-secondary text-muted-foreground transition-colors\",\n                  \"hover:bg-accent disabled:cursor-wait disabled:opacity-70\",\n                  isToolsDropdownOpen &&\n                    \"bg-primary text-primary-foreground hover:bg-primary/90\",\n                )}\n                aria-label=\"Browse all tools\"\n              >\n                <Wrench className=\"size-4\" />\n                {isToolsDropdownOpen ? (\n                  <span\n                    className=\"absolute top-0 right-0 h-2 w-2 rounded-full bg-primary\"\n                    aria-hidden\n                  />\n                ) : null}\n              </button>\n            ) : null}\n          </div>\n\n          <button\n            type=\"button\"\n            onClick={() => handleSubmit()}\n            disabled={disabled || isLoading || !canSubmit}\n            className={cn(\n              \"flex h-9 w-9 min-w-9 max-w-9 cursor-pointer items-center justify-center rounded-full transition-colors\",\n              \"disabled:cursor-not-allowed\",\n              canSubmit\n                ? \"bg-primary text-primary-foreground hover:bg-primary/90\"\n                : \"bg-secondary text-muted-foreground\",\n            )}\n            aria-label=\"Send message\"\n          >\n            <ArrowUp className=\"size-4\" />\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Composer;\n"}],"meta":{"studioName":"GFA Development & Design Studio","siteUrl":"https://ui.gkhn.dev"}}