bookwiz.io / components / chat / ChatInput.tsx
ChatInput.tsx
Raw
import React, { useRef, useCallback } from 'react'
import { IoDocumentTextOutline, IoCloseOutline, IoStopOutline } from 'react-icons/io5'
import ModelSelector from '../ModelSelector'
import { UsageInfo } from '@/lib/hooks/useUsageLimit'
import { getBookChatModels } from '@/lib/config/models'

interface ChatInputProps {
  // Input state
  inputValue: string
  setInputValue: (value: string) => void
  
  // Loading/streaming state
  isLoading: boolean
  currentChat: any
  
  // Model selection
  selectedModel: string
  onModelChange: (model: string) => void
  usageInfo: UsageInfo | null
  
  // File inclusion
  bookId?: string
  includedFiles: string[]
  includedFileNames: Map<string, string>
  getDisplayName: (fileIdentifier: string) => string
  removeIncludedFile: (fileIdentifier: string) => void
  
  // Drag and drop
  isDragOver: boolean
  handleDragOver: (e: React.DragEvent) => void
  handleDragEnter: (e: React.DragEvent) => void
  handleDragLeave: (e: React.DragEvent) => void
  handleDrop: (e: React.DragEvent) => void
  
  // Handlers
  onSubmit: (e: React.FormEvent) => void
  onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
  stopExecution: () => void
}

export default function ChatInput({
  inputValue,
  setInputValue,
  isLoading,
  currentChat,
  selectedModel,
  onModelChange,
  usageInfo,
  bookId,
  includedFiles,
  includedFileNames,
  getDisplayName,
  removeIncludedFile,
  isDragOver,
  handleDragOver,
  handleDragEnter,
  handleDragLeave,
  handleDrop,
  onSubmit,
  onKeyDown,
  stopExecution
}: ChatInputProps) {
  const textareaRef = useRef<HTMLTextAreaElement>(null)

  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setInputValue(e.target.value)
    // Auto-resize textarea
    const target = e.target as HTMLTextAreaElement
    target.style.height = 'auto'
    target.style.height = `${Math.min(target.scrollHeight, 120)}px`
  }, [setInputValue])

  const getPlaceholder = () => {
    if (isLoading) return "AI is thinking..."
    if (!currentChat) return "Create a new chat to start messaging..."
    return "What would you like me to search for, update, create, improve, critique, etc.?"
  }

  return (
    <div className="border-t border-slate-700/50 bg-slate-800/40 backdrop-blur-sm px-3 py-2">
      <form onSubmit={onSubmit} onClick={(e) => {
        if (e.target instanceof HTMLElement && !e.target.closest('button[type="submit"]')) {
          e.preventDefault()
        }
      }}>
        {/* Input wrapper with border */}
        <div 
          className={`relative bg-slate-800/50 backdrop-blur-sm border border-slate-700/50 rounded-lg focus-within:ring-1 focus-within:ring-teal-500/50 focus-within:border-transparent transition-all ${
            isDragOver ? 'border-teal-500 bg-slate-700/50' : ''
          }`}
          onDragOver={handleDragOver}
          onDragEnter={handleDragEnter}
          onDragLeave={handleDragLeave}
          onDrop={handleDrop}
        >
          {/* @ Files Section (above textarea) */}
          {(includedFiles.length > 0 || bookId) && (
            <div className="px-3 pt-2 pb-1.5 border-b border-slate-700/50">
              <div className="flex items-center gap-1.5 text-[10px] text-slate-400">
                <span className="text-slate-300">@</span>
                <div className="flex flex-wrap gap-1">
                  {bookId && (
                    <span className="bg-gradient-to-r from-teal-500/20 to-cyan-500/20 px-1.5 py-0.5 rounded text-[10px] text-slate-300 flex items-center gap-1 border border-teal-500/20">
                      <IoDocumentTextOutline className="w-2.5 h-2" />
                      Smart Mode
                    </span>
                  )}
                  {includedFiles.map((fileIdentifier, index) => (
                    <span key={index} className="bg-gradient-to-r from-teal-500 to-cyan-600 px-1.5 py-0.5 rounded text-[10px] text-white flex items-center gap-1">
                      <IoDocumentTextOutline className="w-2.5 h-2.5" />
                      {getDisplayName(fileIdentifier)}
                      <button
                        type="button"
                        onClick={() => removeIncludedFile(fileIdentifier)}
                        className="ml-0.5 hover:bg-teal-700/50 rounded-full p-0.5 transition-colors"
                      >
                        <IoCloseOutline className="w-2 h-2" />
                      </button>
                    </span>
                  ))}
                </div>
              </div>
            </div>
          )}
          
          {/* Drag overlay */}
          {isDragOver && (
            <div className="absolute inset-0 bg-teal-500/20 border-2 border-dashed border-teal-500 rounded-lg flex items-center justify-center z-10">
              <div className="text-teal-300 text-xs font-medium">
                Drop files here to include in context
              </div>
            </div>
          )}
          
          {/* Textarea */}
          <textarea
            ref={textareaRef}
            value={inputValue}
            onChange={handleInputChange}
            onKeyDown={onKeyDown}
            placeholder={getPlaceholder()}
            className="chat-input w-full px-3 py-2 text-sm bg-transparent border-none focus:outline-none focus:ring-0 focus:border-none resize-none overflow-y-auto text-slate-200 placeholder-slate-400 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-150"
            disabled={isLoading || !currentChat}
            style={{
              height: 'auto',
              minHeight: '32px',
              maxHeight: '120px',
              outline: 'none',
              outlineOffset: '0',
              outlineStyle: 'none',
              outlineWidth: '0'
            }}
          />
          
          {/* Bottom section with model picker and send button */}
          <div className="flex items-center justify-between px-3 pb-2">
            <div className="flex items-center gap-1 text-[10px] text-slate-400">
              <ModelSelector
                selectedModel={selectedModel}
                onModelChange={onModelChange}
                disabled={isLoading}
                variant="compact"
                className="min-w-0"
                usageInfo={usageInfo}
                models={getBookChatModels()}
              />
              {isLoading && (
                <div className="w-2 h-2 border border-slate-400 border-t-transparent rounded-full animate-spin ml-1.5"></div>
              )}
            </div>
            
            <button
              type={isLoading ? "button" : "submit"}
              onClick={isLoading ? stopExecution : undefined}
              className={`px-2.5 py-1 text-[10px] rounded-md transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 transform hover:scale-105 active:scale-95 ${
                isLoading 
                  ? 'text-red-400 border border-red-500/50 hover:border-red-500 hover:bg-red-500/10 bg-transparent' 
                  : 'text-white bg-gradient-to-r from-teal-600 to-cyan-600 hover:from-teal-700 hover:to-cyan-700 disabled:hover:from-teal-600 disabled:hover:to-cyan-600 shadow-sm hover:shadow-md'
              }`}
              disabled={!isLoading && (!inputValue.trim() || !currentChat)}
            >
              {isLoading ? (
                <>
                  <IoStopOutline className="w-2.5 h-2.5" />
                  Stop
                </>
              ) : (
                'Send'
              )}
            </button>
          </div>
        </div>
      </form>
    </div>
  )
}