All files / components/chat composer.tsx

93.02% Statements 40/43
85.36% Branches 35/41
91.66% Functions 11/12
97.5% Lines 39/40

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181                2x 2x 2x 2x 2x 2x 2x 2x 2x                                 26x                           35x 35x 35x     6x 6x       2x 2x       3x 3x       35x 23x 23x 23x 23x 23x 23x     35x       2x   2x 2x 1x 1x         3x 3x       2x 2x       1x                           14x   15x                                                                                                                                
"use client";
 
import {
  useEffect,
  useRef,
  useState,
  type FormEventHandler,
  type KeyboardEvent,
} from "react";
import { Text } from "@/components/ui/text";
import { CHAT_COMPOSER_COPY } from "@/constants/chat";
import { cn } from "@/utils/class-name";
import { StopIcon } from "@/components/icons/Stop";
import { SendIcon } from "@/components/icons/Send";
import { AttachMenu } from "@/components/chat/attach-menu";
import { FilePreviewCard } from "@/components/chat/file-preview-card";
import { getFilePreviewType } from "@/utils/file-type";
 
type ChatComposerProps = {
  input: string;
  canSend: boolean;
  isLoading: boolean;
  isProviderReady: boolean;
  inputTooltip?: string;
  helperText?: string;
  errorMessage?: string | null;
  onInputChange: (value: string) => void;
  onSubmitAction: FormEventHandler<HTMLFormElement>;
  onStopAction: () => void;
  onFileAttached?: (file: File | null) => void;
  onFilePreview?: (file: File) => void;
};
 
export function ChatComposer({
  input,
  canSend,
  isLoading,
  isProviderReady,
  inputTooltip,
  helperText,
  errorMessage,
  onInputChange,
  onSubmitAction,
  onStopAction,
  onFileAttached,
  onFilePreview,
}: ChatComposerProps) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const [showTooltip, setShowTooltip] = useState(false);
  const [attachedFile, setAttachedFile] = useState<File | null>(null);
 
  function handleFileSelect(file: File) {
    setAttachedFile(file);
    onFileAttached?.(file);
  }
 
  function handleRemoveFile() {
    setAttachedFile(null);
    onFileAttached?.(null);
  }
 
  function handleOpenPreview() {
    Eif (onFilePreview && attachedFile) {
      onFilePreview(attachedFile);
    }
  }
 
  useEffect(() => {
    const textarea = textareaRef.current;
    Iif (!textarea) return;
    textarea.style.height = "auto";
    const capped = textarea.scrollHeight > 150;
    textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`;
    textarea.style.overflowY = capped ? "auto" : "hidden";
  }, [input]);
 
  const IME_COMPOSING_KEYCODE = 229;
 
  function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
    const isComposing =
      event.nativeEvent.isComposing ||
      event.nativeEvent.keyCode === IME_COMPOSING_KEYCODE;
    Iif (isComposing) return;
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      Eif (canSend) event.currentTarget.form?.requestSubmit();
    }
  }
 
  function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
    onSubmitAction(e);
    setAttachedFile(null);
  }
 
  function handleStopAction(e: React.MouseEvent<HTMLButtonElement>) {
    e.preventDefault();
    onStopAction();
  }
 
  function handleInputChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
    onInputChange(event.target.value);
  }
 
  return (
    <div className="border-t border-white/8 bg-composer backdrop-blur-[1.75rem] px-4 py-3 sm:px-6 lg:px-8 shadow-composer light:border-slate-200 light:bg-white/80">
      <div className="mx-auto w-full max-w-3xl flex flex-col gap-2">
        {errorMessage ? (
          <div className="rounded-2xl border border-red-500/30 bg-red-500/10 px-4 py-3">
            <Text variant="error">{errorMessage}</Text>
          </div>
        ) : null}
 
        <div
          className="relative"
          onMouseEnter={() => !isProviderReady && setShowTooltip(true)}
          onMouseLeave={() => setShowTooltip(false)}
          onClick={() => !isProviderReady && setShowTooltip(true)}
        >
          {showTooltip && !isProviderReady && inputTooltip && (
            <div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded-lg bg-slate-800 px-3 py-1.5 font-dm-sans text-xs text-white shadow-lg">
              {inputTooltip}
              <span className="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-slate-800" />
            </div>
          )}
        <form
          onSubmit={handleSubmit}
          className="flex w-full flex-col rounded-[1.125rem] border border-white/13 bg-composer-input px-4 pt-3 pb-2.5 shadow-composer-input backdrop-blur-xl transition-all duration-200 focus-within:border-violet-400/55 light:border-slate-200 light:bg-white light:focus-within:border-violet-400/70"
        >
          {attachedFile && (
            <div className="mb-2">
              <FilePreviewCard fileName={attachedFile.name} fileType={getFilePreviewType(attachedFile)} onRemove={handleRemoveFile} onPreview={handleOpenPreview} />
            </div>
          )}
          <div className="flex items-center gap-3">
          <AttachMenu onFileSelect={handleFileSelect} />
            <textarea
              ref={textareaRef}
              value={input}
              onChange={handleInputChange}
              onKeyDown={handleKeyDown}
              placeholder={CHAT_COMPOSER_COPY.placeholder}
              aria-label={CHAT_COMPOSER_COPY.ariaLabel}
              disabled={!isProviderReady}
              rows={1}
              className="max-h-[9.375rem] flex-1 resize-none overflow-y-hidden border-none bg-transparent font-dm-sans text-sm leading-[1.55] text-white/90 caret-violet-400/90 outline-none placeholder:text-white/46 disabled:cursor-not-allowed disabled:opacity-50 light:text-slate-900 light:placeholder:text-slate-400 light:caret-violet-600"
            />
 
          <button
            type="submit"
            disabled={!isLoading && !canSend}
            aria-label={isLoading ? CHAT_COMPOSER_COPY.stopButtonLabel : CHAT_COMPOSER_COPY.sendButtonLabel}
            {...(isLoading && { onClick: handleStopAction })}
            className={cn(
              "self-end grid h-10 w-10 shrink-0 place-items-center rounded-xl transition-all duration-200",
              "disabled:opacity-30 disabled:cursor-not-allowed",
              "hover:scale-[1.04] hover:shadow-brand",
              isLoading
                ? "bg-loading"
                : canSend
                  ? "bg-brand-gradient text-white"
                  : "bg-loading",
            )}
          >
            {isLoading ? (
              <StopIcon className="size-4" />
            ) : (
              <SendIcon className="size-4" />
            )}
          </button>
          </div>
        </form>
        </div>
 
        <Text variant="helper" className="px-1 text-center text-white/55 light:text-slate-500">
          {helperText ?? CHAT_COMPOSER_COPY.defaultHelperText}
        </Text>
      </div>
    </div>
  );
}