refactor(frontend): custom chat input (#10984)

This commit is contained in:
Hiep Le 2025-09-19 21:06:18 +07:00 committed by GitHub
parent 9c9fa780b0
commit 0061bcc0b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 850 additions and 360 deletions

View File

@ -0,0 +1,34 @@
import { ConversationStatus } from "#/types/conversation-status";
import { ServerStatus } from "#/components/features/controls/server-status";
import { AgentStatus } from "#/components/features/controls/agent-status";
import { Tools } from "../../controls/tools";
interface ChatInputActionsProps {
conversationStatus: ConversationStatus | null;
disabled: boolean;
handleStop: (onStop?: () => void) => void;
handleResumeAgent: () => void;
onStop?: () => void;
}
export function ChatInputActions({
conversationStatus,
disabled,
handleStop,
handleResumeAgent,
onStop,
}: ChatInputActionsProps) {
return (
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<ServerStatus conversationStatus={conversationStatus} />
</div>
<AgentStatus
handleStop={() => handleStop(onStop)}
handleResumeAgent={handleResumeAgent}
disabled={disabled}
/>
</div>
);
}

View File

@ -0,0 +1,89 @@
import React from "react";
import { ConversationStatus } from "#/types/conversation-status";
import { DragOver } from "../drag-over";
import { UploadedFiles } from "../uploaded-files";
import { ChatInputRow } from "./chat-input-row";
import { ChatInputActions } from "./chat-input-actions";
interface ChatInputContainerProps {
chatContainerRef: React.RefObject<HTMLDivElement | null>;
isDragOver: boolean;
disabled: boolean;
showButton: boolean;
buttonClassName: string;
conversationStatus: ConversationStatus | null;
chatInputRef: React.RefObject<HTMLDivElement | null>;
handleFileIconClick: (isDisabled: boolean) => void;
handleSubmit: () => void;
handleStop: (onStop?: () => void) => void;
handleResumeAgent: () => void;
onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
onDrop: (e: React.DragEvent, isDisabled: boolean) => void;
onInput: () => void;
onPaste: (e: React.ClipboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
onStop?: () => void;
}
export function ChatInputContainer({
chatContainerRef,
isDragOver,
disabled,
showButton,
buttonClassName,
conversationStatus,
chatInputRef,
handleFileIconClick,
handleSubmit,
handleStop,
handleResumeAgent,
onDragOver,
onDragLeave,
onDrop,
onInput,
onPaste,
onKeyDown,
onFocus,
onBlur,
onStop,
}: ChatInputContainerProps) {
return (
<div
ref={chatContainerRef}
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
onDragOver={(e) => onDragOver(e, disabled)}
onDragLeave={(e) => onDragLeave(e, disabled)}
onDrop={(e) => onDrop(e, disabled)}
>
{/* Drag Over UI */}
{isDragOver && <DragOver />}
<UploadedFiles />
<ChatInputRow
chatInputRef={chatInputRef}
disabled={disabled}
showButton={showButton}
buttonClassName={buttonClassName}
handleFileIconClick={handleFileIconClick}
handleSubmit={handleSubmit}
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
/>
<ChatInputActions
conversationStatus={conversationStatus}
disabled={disabled}
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
onStop={onStop}
/>
</div>
);
}

View File

@ -0,0 +1,44 @@
import React from "react";
import { useTranslation } from "react-i18next";
interface ChatInputFieldProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
onInput: () => void;
onPaste: (e: React.ClipboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
}
export function ChatInputField({
chatInputRef,
onInput,
onPaste,
onKeyDown,
onFocus,
onBlur,
}: ChatInputFieldProps) {
const { t } = useTranslation();
return (
<div
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
data-name="Text & caret"
>
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
<div
ref={chatInputRef}
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
contentEditable
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
data-testid="chat-input"
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
import React from "react";
import { cn } from "#/utils/utils";
interface ChatInputGripProps {
gripRef: React.RefObject<HTMLDivElement | null>;
isGripVisible: boolean;
handleTopEdgeClick: (e: React.MouseEvent) => void;
handleGripMouseDown: (e: React.MouseEvent) => void;
handleGripTouchStart: (e: React.TouchEvent) => void;
}
export function ChatInputGrip({
gripRef,
isGripVisible,
handleTopEdgeClick,
handleGripMouseDown,
handleGripTouchStart,
}: ChatInputGripProps) {
return (
<div
className="absolute -top-[12px] left-0 w-full h-6 lg:h-3 z-20 group"
id="resize-grip"
onClick={handleTopEdgeClick}
>
{/* Resize Grip - appears on hover of top edge area, when dragging, or when clicked */}
<div
ref={gripRef}
className={cn(
"absolute top-[4px] left-0 w-full h-[3px] bg-white cursor-ns-resize z-10 transition-opacity duration-200",
isGripVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
onMouseDown={handleGripMouseDown}
onTouchStart={handleGripTouchStart}
style={{ userSelect: "none" }}
/>
</div>
);
}

View File

@ -0,0 +1,62 @@
import React from "react";
import { cn } from "#/utils/utils";
import { ChatAddFileButton } from "../chat-add-file-button";
import { ChatSendButton } from "../chat-send-button";
import { ChatInputField } from "./chat-input-field";
interface ChatInputRowProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
disabled: boolean;
showButton: boolean;
buttonClassName: string;
handleFileIconClick: (isDisabled: boolean) => void;
handleSubmit: () => void;
onInput: () => void;
onPaste: (e: React.ClipboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onFocus?: () => void;
onBlur?: () => void;
}
export function ChatInputRow({
chatInputRef,
disabled,
showButton,
buttonClassName,
handleFileIconClick,
handleSubmit,
onInput,
onPaste,
onKeyDown,
onFocus,
onBlur,
}: ChatInputRowProps) {
return (
<div className="box-border content-stretch flex flex-row items-end justify-between p-0 relative shrink-0 w-full pb-[18px] gap-2">
<div className="basis-0 box-border content-stretch flex flex-row gap-4 grow items-end justify-start min-h-px min-w-px p-0 relative shrink-0">
<ChatAddFileButton
disabled={disabled}
handleFileIconClick={() => handleFileIconClick(disabled)}
/>
<ChatInputField
chatInputRef={chatInputRef}
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
{/* Send Button */}
{showButton && (
<ChatSendButton
buttonClassName={cn(buttonClassName, "translate-y-[3px]")}
handleSubmit={handleSubmit}
disabled={disabled}
/>
)}
</div>
);
}

View File

@ -0,0 +1,23 @@
import React from "react";
interface HiddenFileInputProps {
fileInputRef: React.RefObject<HTMLInputElement | null>;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export function HiddenFileInput({
fileInputRef,
onChange,
}: HiddenFileInputProps) {
return (
<input
type="file"
ref={fileInputRef}
multiple
accept="*/*"
style={{ display: "none" }}
onChange={onChange}
data-testid="upload-image-input"
/>
);
}

View File

@ -1,25 +1,20 @@
import React, { useRef, useCallback, useState, useEffect } from "react";
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { ConversationStatus } from "#/types/conversation-status";
import { ChatSendButton } from "./chat-send-button";
import { ChatAddFileButton } from "./chat-add-file-button";
import { cn, isMobileDevice } from "#/utils/utils";
import { useAutoResize } from "#/hooks/use-auto-resize";
import { DragOver } from "./drag-over";
import { UploadedFiles } from "./uploaded-files";
import { Tools } from "../controls/tools";
import {
clearAllFiles,
setShouldHideSuggestions,
setSubmittedMessage,
setMessageToSend,
setIsRightPanelShown,
} from "#/state/conversation-slice";
import { CHAT_INPUT } from "#/utils/constants";
import { RootState } from "#/store";
import { ServerStatus } from "../controls/server-status";
import { AgentStatus } from "../controls/agent-status";
import { useChatInputLogic } from "#/hooks/chat/use-chat-input-logic";
import { useFileHandling } from "#/hooks/chat/use-file-handling";
import { useGripResize } from "#/hooks/chat/use-grip-resize";
import { useChatInputEvents } from "#/hooks/chat/use-chat-input-events";
import { useChatSubmission } from "#/hooks/chat/use-chat-submission";
import { ChatInputGrip } from "./components/chat-input-grip";
import { ChatInputContainer } from "./components/chat-input-container";
import { HiddenFileInput } from "./components/hidden-file-input";
export interface CustomChatInputProps {
disabled?: boolean;
@ -46,13 +41,9 @@ export function CustomChatInput({
className = "",
buttonClassName = "",
}: CustomChatInputProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isGripVisible, setIsGripVisible] = useState(false);
const { messageToSend, submittedMessage, hasRightPanelToggled } = useSelector(
const { submittedMessage } = useSelector(
(state: RootState) => state.conversation,
);
const dispatch = useDispatch();
// Disable input when conversation is stopped
@ -68,87 +59,55 @@ export function CustomChatInput({
dispatch(setSubmittedMessage(null));
}, [submittedMessage, disabled, onSubmit, dispatch]);
const { t } = useTranslation();
const chatInputRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const gripRef = useRef<HTMLDivElement>(null);
// Save current input value when drawer state changes
useEffect(() => {
if (chatInputRef.current) {
const currentText = chatInputRef.current?.innerText || "";
// Dispatch to save current input value when drawer state changes
dispatch(setMessageToSend(currentText));
dispatch(setIsRightPanelShown(hasRightPanelToggled));
}
}, [hasRightPanelToggled, dispatch]);
// Helper function to check if contentEditable is truly empty
const isContentEmpty = useCallback((): boolean => {
if (!chatInputRef.current) return true;
const text =
chatInputRef.current.innerText || chatInputRef.current.textContent || "";
return text.trim() === "";
}, []);
// Helper function to properly clear contentEditable for placeholder display
const clearEmptyContent = useCallback((): void => {
if (chatInputRef.current && isContentEmpty()) {
chatInputRef.current.innerHTML = "";
chatInputRef.current.textContent = "";
}
}, [isContentEmpty]);
// Drag state management callbacks
const handleDragStart = useCallback(() => {
// Keep grip visible during drag by adding a CSS class
if (gripRef.current) {
gripRef.current.classList.add("opacity-100");
gripRef.current.classList.remove("opacity-0");
}
}, []);
const handleDragEnd = useCallback(() => {
// Restore hover-based visibility
if (gripRef.current) {
gripRef.current.classList.remove("opacity-100");
gripRef.current.classList.add("opacity-0");
}
}, []);
// Handle click on top edge area to toggle grip visibility
const handleTopEdgeClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsGripVisible((prev) => !prev);
};
// Callback to handle height changes and manage suggestions visibility
const handleHeightChange = useCallback(
(height: number) => {
// Hide suggestions when input height exceeds the threshold
const shouldHideChatSuggestions = height > CHAT_INPUT.HEIGHT_THRESHOLD;
dispatch(setShouldHideSuggestions(shouldHideChatSuggestions));
},
[dispatch],
);
// Use the auto-resize hook with height change callback
// Custom hooks
const {
chatInputRef,
messageToSend,
checkIsContentEmpty,
clearEmptyContentHandler,
} = useChatInputLogic();
const {
fileInputRef,
chatContainerRef,
isDragOver,
handleFileIconClick,
handleFileInputChange,
handleDragOver,
handleDragLeave,
handleDrop,
} = useFileHandling(onFilesPaste);
const {
gripRef,
isGripVisible,
handleTopEdgeClick,
smartResize,
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
} = useAutoResize(chatInputRef, {
minHeight: 20,
maxHeight: 400,
onHeightChange: handleHeightChange,
onGripDragStart: handleDragStart,
onGripDragEnd: handleDragEnd,
value: messageToSend ?? undefined,
enableManualResize: true,
});
} = useGripResize(
chatInputRef as React.RefObject<HTMLDivElement | null>,
messageToSend,
);
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
chatInputRef as React.RefObject<HTMLDivElement | null>,
fileInputRef as React.RefObject<HTMLInputElement | null>,
smartResize,
onSubmit,
);
const { handleInput, handlePaste, handleKeyDown, handleBlur, handleFocus } =
useChatInputEvents(
chatInputRef as React.RefObject<HTMLDivElement | null>,
smartResize,
increaseHeightForEmptyContent,
checkIsContentEmpty,
clearEmptyContentHandler,
onFocus,
onBlur,
);
// Cleanup: reset suggestions visibility when component unmounts
useEffect(
@ -159,283 +118,46 @@ export function CustomChatInput({
[dispatch],
);
// Function to add files and notify parent
const addFiles = useCallback(
(files: File[]) => {
// Call onFilesPaste if provided with the new files
if (onFilesPaste && files.length > 0) {
onFilesPaste(files);
}
},
[onFilesPaste],
);
// File icon click handler
const handleFileIconClick = () => {
if (!isDisabled && fileInputRef.current) {
fileInputRef.current.click();
}
};
// File input change handler
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
addFiles(files);
};
// Drag and drop event handlers
const handleDragOver = (e: React.DragEvent) => {
if (isDisabled) return;
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
if (isDisabled) return;
e.preventDefault();
// Only remove drag-over class if we're leaving the container entirely
if (!chatContainerRef.current?.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
};
const handleDrop = (e: React.DragEvent) => {
if (isDisabled) return;
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
addFiles(files);
};
// Send button click handler
const handleSubmit = () => {
const message = chatInputRef.current?.innerText || "";
if (message.trim()) {
onSubmit(message);
// Clear the input
if (chatInputRef.current) {
chatInputRef.current.textContent = "";
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
// Reset height and show suggestions again
smartResize();
}
};
// Resume agent button click handler
const handleResumeAgent = () => {
const message = chatInputRef.current?.innerText || "continue";
onSubmit(message.trim());
// Clear the input
if (chatInputRef.current) {
chatInputRef.current.textContent = "";
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
// Reset height and show suggestions again
smartResize();
};
// Handle stop button click
const handleStop = () => {
if (onStop) {
onStop();
}
};
// Handle input events
const handleInput = () => {
smartResize();
// Clear empty content to ensure placeholder shows
if (chatInputRef.current) {
clearEmptyContent();
}
// Ensure cursor stays visible when content is scrollable
if (!chatInputRef.current) {
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (
!range.getBoundingClientRect ||
!chatInputRef.current.getBoundingClientRect
) {
return;
}
const rect = range.getBoundingClientRect();
const inputRect = chatInputRef.current.getBoundingClientRect();
// If cursor is below the visible area, scroll to show it
if (rect.bottom > inputRect.bottom) {
chatInputRef.current.scrollTop =
chatInputRef.current.scrollHeight - chatInputRef.current.clientHeight;
}
};
// Handle paste events to clean up formatting
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
// Get plain text from clipboard
const text = e.clipboardData.getData("text/plain");
// Insert plain text
document.execCommand("insertText", false, text);
// Trigger resize
setTimeout(smartResize, 0);
};
// Handle key events
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "Enter") {
return;
}
if (isContentEmpty()) {
e.preventDefault();
increaseHeightForEmptyContent();
return;
}
// Original submit logic - only for desktop without shift key
if (!isMobileDevice() && !e.shiftKey && !disabled) {
e.preventDefault();
handleSubmit();
}
};
// Handle blur events to ensure placeholder shows when empty
const handleBlur = () => {
// Clear empty content to ensure placeholder shows
if (chatInputRef.current) {
clearEmptyContent();
}
// Call the original onBlur callback if provided
if (onBlur) {
onBlur();
}
};
return (
<div className={`w-full ${className}`}>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
multiple
accept="*/*"
style={{ display: "none" }}
<HiddenFileInput
fileInputRef={fileInputRef}
onChange={handleFileInputChange}
data-testid="upload-image-input"
/>
{/* Container with grip */}
<div className="relative w-full">
{/* Top edge hover area - invisible area that triggers grip visibility */}
<div
className="absolute -top-[12px] left-0 w-full h-6 lg:h-3 z-20 group"
id="resize-grip"
onClick={handleTopEdgeClick}
>
{/* Resize Grip - appears on hover of top edge area, when dragging, or when clicked */}
<div
ref={gripRef}
className={cn(
"absolute top-[4px] left-0 w-full h-[3px] bg-white cursor-ns-resize z-10 transition-opacity duration-200",
isGripVisible
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
)}
onMouseDown={handleGripMouseDown}
onTouchStart={handleGripTouchStart}
style={{ userSelect: "none" }}
/>
</div>
<ChatInputGrip
gripRef={gripRef}
isGripVisible={isGripVisible}
handleTopEdgeClick={handleTopEdgeClick}
handleGripMouseDown={handleGripMouseDown}
handleGripTouchStart={handleGripTouchStart}
/>
{/* Chat Input Component */}
<div
ref={chatContainerRef}
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
<ChatInputContainer
chatContainerRef={chatContainerRef}
isDragOver={isDragOver}
disabled={isDisabled}
showButton={showButton}
buttonClassName={buttonClassName}
conversationStatus={conversationStatus}
chatInputRef={chatInputRef}
handleFileIconClick={handleFileIconClick}
handleSubmit={handleSubmit}
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag Over UI */}
{isDragOver && <DragOver />}
<UploadedFiles />
{/* Main Input Row */}
<div className="box-border content-stretch flex flex-row items-end justify-between p-0 relative shrink-0 w-full pb-[18px] gap-2">
<div className="basis-0 box-border content-stretch flex flex-row gap-4 grow items-end justify-start min-h-px min-w-px p-0 relative shrink-0">
<ChatAddFileButton
disabled={disabled}
handleFileIconClick={handleFileIconClick}
/>
{/* Chat Input Area */}
<div
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
data-name="Text & caret"
>
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
<div
ref={chatInputRef}
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
contentEditable
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
data-testid="chat-input"
onInput={handleInput}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={onFocus}
onBlur={handleBlur}
/>
</div>
</div>
</div>
{/* Send Button */}
{showButton && (
<ChatSendButton
buttonClassName={cn(buttonClassName, "translate-y-[3px]")}
handleSubmit={handleSubmit}
disabled={disabled}
/>
)}
</div>
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<ServerStatus conversationStatus={conversationStatus} />
</div>
<AgentStatus
handleStop={handleStop}
handleResumeAgent={handleResumeAgent}
disabled={disabled}
/>
</div>
</div>
onInput={handleInput}
onPaste={handlePaste}
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
onFocus={handleFocus}
onBlur={handleBlur}
onStop={onStop}
/>
</div>
</div>
);

View File

@ -0,0 +1,75 @@
/**
* Utility functions for chat input component
*/
/* eslint-disable no-param-reassign */
/**
* Check if contentEditable element is truly empty
*/
export const isContentEmpty = (element: HTMLDivElement | null): boolean => {
if (!element) {
return true;
}
const text = element.innerText || element.textContent || "";
return text.trim() === "";
};
/**
* Clear empty content from contentEditable element for placeholder display
*/
export const clearEmptyContent = (element: HTMLDivElement | null): void => {
if (element && isContentEmpty(element)) {
element.innerHTML = "";
element.textContent = "";
}
};
/**
* Get text content from contentEditable element
*/
export const getTextContent = (element: HTMLDivElement | null): string =>
element?.innerText || "";
/**
* Clear text content from contentEditable element
*/
export const clearTextContent = (element: HTMLDivElement | null): void => {
if (element) {
element.textContent = "";
}
};
/**
* Clear file input value
*/
export const clearFileInput = (element: HTMLInputElement | null): void => {
if (element) {
element.value = "";
}
};
/**
* Ensure cursor stays visible when content is scrollable
*/
export const ensureCursorVisible = (element: HTMLDivElement | null): void => {
if (!element) {
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (!range.getBoundingClientRect || !element.getBoundingClientRect) {
return;
}
const rect = range.getBoundingClientRect();
const inputRect = element.getBoundingClientRect();
// If cursor is below the visible area, scroll to show it
if (rect.bottom > inputRect.bottom) {
element.scrollTop = element.scrollHeight - element.clientHeight;
}
};

View File

@ -0,0 +1,99 @@
import { useCallback } from "react";
import { isMobileDevice } from "#/utils/utils";
import {
ensureCursorVisible,
clearEmptyContent,
} from "#/components/features/chat/utils/chat-input.utils";
/**
* Hook for handling chat input events
*/
export const useChatInputEvents = (
chatInputRef: React.RefObject<HTMLDivElement | null>,
smartResize: () => void,
increaseHeightForEmptyContent: () => void,
checkIsContentEmpty: () => boolean,
clearEmptyContentHandler: () => void,
onFocus?: () => void,
onBlur?: () => void,
) => {
// Handle input events
const handleInput = useCallback(() => {
smartResize();
// Clear empty content to ensure placeholder shows
if (chatInputRef.current) {
clearEmptyContent(chatInputRef.current);
}
// Ensure cursor stays visible when content is scrollable
ensureCursorVisible(chatInputRef.current);
}, [smartResize, chatInputRef]);
// Handle paste events to clean up formatting
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault();
// Get plain text from clipboard
const text = e.clipboardData.getData("text/plain");
// Insert plain text
document.execCommand("insertText", false, text);
// Trigger resize
setTimeout(smartResize, 0);
},
[smartResize],
);
// Handle key events
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, disabled: boolean, handleSubmit: () => void) => {
if (e.key !== "Enter") {
return;
}
if (checkIsContentEmpty()) {
e.preventDefault();
increaseHeightForEmptyContent();
return;
}
// Original submit logic - only for desktop without shift key
if (!isMobileDevice() && !e.shiftKey && !disabled) {
e.preventDefault();
handleSubmit();
}
},
[checkIsContentEmpty, increaseHeightForEmptyContent],
);
// Handle blur events to ensure placeholder shows when empty
const handleBlur = useCallback(() => {
// Clear empty content to ensure placeholder shows
if (chatInputRef.current) {
clearEmptyContent(chatInputRef.current);
}
// Call the original onBlur callback if provided
if (onBlur) {
onBlur();
}
}, [chatInputRef, onBlur]);
// Handle focus events
const handleFocus = useCallback(() => {
if (onFocus) {
onFocus();
}
}, [onFocus]);
return {
handleInput,
handlePaste,
handleKeyDown,
handleBlur,
handleFocus,
};
};

View File

@ -0,0 +1,59 @@
import { useRef, useCallback, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
setMessageToSend,
setIsRightPanelShown,
} from "#/state/conversation-slice";
import { RootState } from "#/store";
import {
isContentEmpty,
clearEmptyContent,
getTextContent,
} from "#/components/features/chat/utils/chat-input.utils";
/**
* Hook for managing chat input content logic
*/
export const useChatInputLogic = () => {
const chatInputRef = useRef<HTMLDivElement | null>(null);
const { messageToSend, hasRightPanelToggled } = useSelector(
(state: RootState) => state.conversation,
);
const dispatch = useDispatch();
// Save current input value when drawer state changes
useEffect(() => {
if (chatInputRef.current) {
const currentText = getTextContent(chatInputRef.current);
dispatch(setMessageToSend(currentText));
dispatch(setIsRightPanelShown(hasRightPanelToggled));
}
}, [hasRightPanelToggled, dispatch]);
// Helper function to check if contentEditable is truly empty
const checkIsContentEmpty = useCallback(
(): boolean => isContentEmpty(chatInputRef.current),
[],
);
// Helper function to properly clear contentEditable for placeholder display
const clearEmptyContentHandler = useCallback((): void => {
clearEmptyContent(chatInputRef.current);
}, []);
// Get current message text
const getCurrentMessage = useCallback(
(): string => getTextContent(chatInputRef.current),
[],
);
return {
chatInputRef,
messageToSend,
checkIsContentEmpty,
clearEmptyContentHandler,
getCurrentMessage,
};
};

View File

@ -0,0 +1,61 @@
import { useCallback } from "react";
import {
clearTextContent,
clearFileInput,
} from "#/components/features/chat/utils/chat-input.utils";
/**
* Hook for handling chat message submission
*/
export const useChatSubmission = (
chatInputRef: React.RefObject<HTMLDivElement | null>,
fileInputRef: React.RefObject<HTMLInputElement | null>,
smartResize: () => void,
onSubmit: (message: string) => void,
) => {
// Send button click handler
const handleSubmit = useCallback(() => {
const message = chatInputRef.current?.innerText || "";
const trimmedMessage = message.trim();
if (!trimmedMessage) {
return;
}
onSubmit(message);
// Clear the input
clearTextContent(chatInputRef.current);
clearFileInput(fileInputRef.current);
// Reset height and show suggestions again
smartResize();
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
// Resume agent button click handler
const handleResumeAgent = useCallback(() => {
const message = chatInputRef.current?.innerText || "continue";
onSubmit(message.trim());
// Clear the input
clearTextContent(chatInputRef.current);
clearFileInput(fileInputRef.current);
// Reset height and show suggestions again
smartResize();
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
// Handle stop button click
const handleStop = useCallback((onStop?: () => void) => {
if (onStop) {
onStop();
}
}, []);
return {
handleSubmit,
handleResumeAgent,
handleStop,
};
};

View File

@ -0,0 +1,103 @@
import React, { useRef, useCallback, useState } from "react";
interface UseFileHandlingReturn {
fileInputRef: React.RefObject<HTMLInputElement | null>;
chatContainerRef: React.RefObject<HTMLDivElement | null>;
isDragOver: boolean;
handleFileIconClick: (isDisabled: boolean) => void;
handleFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
handleDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
handleDrop: (e: React.DragEvent, isDisabled: boolean) => void;
}
/**
* Hook for handling file operations (upload, drag & drop)
*/
export const useFileHandling = (
onFilesPaste?: (files: File[]) => void,
): UseFileHandlingReturn => {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const chatContainerRef = useRef<HTMLDivElement | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
// Function to add files and notify parent
const addFiles = useCallback(
(files: File[]) => {
if (onFilesPaste && files.length > 0) {
onFilesPaste(files);
}
},
[onFilesPaste],
);
// File icon click handler
const handleFileIconClick = useCallback((isDisabled: boolean) => {
if (!isDisabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
// File input change handler
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
addFiles(files);
},
[addFiles],
);
// Drag and drop event handlers
const handleDragOver = useCallback(
(e: React.DragEvent, isDisabled: boolean) => {
if (isDisabled) {
return;
}
e.preventDefault();
setIsDragOver(true);
},
[],
);
const handleDragLeave = useCallback(
(e: React.DragEvent, isDisabled: boolean) => {
if (
isDisabled ||
chatContainerRef.current?.contains(e.relatedTarget as Node)
) {
return;
}
e.preventDefault();
setIsDragOver(false);
},
[],
);
const handleDrop = useCallback(
(e: React.DragEvent, isDisabled: boolean) => {
if (isDisabled) {
return;
}
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
addFiles(files);
},
[addFiles],
);
return {
fileInputRef,
chatContainerRef,
isDragOver,
handleFileIconClick,
handleFileInputChange,
handleDragOver,
handleDragLeave,
handleDrop,
};
};

View File

@ -0,0 +1,81 @@
import { useRef, useState, useCallback } from "react";
import { useDispatch } from "react-redux";
import { useAutoResize } from "#/hooks/use-auto-resize";
import {
IMessageToSend,
setShouldHideSuggestions,
} from "#/state/conversation-slice";
import { CHAT_INPUT } from "#/utils/constants";
/**
* Hook for managing grip resize functionality
*/
export const useGripResize = (
chatInputRef: React.RefObject<HTMLDivElement | null>,
messageToSend: IMessageToSend | null,
) => {
const gripRef = useRef<HTMLDivElement | null>(null);
const [isGripVisible, setIsGripVisible] = useState(false);
const dispatch = useDispatch();
// Drag state management callbacks
const handleDragStart = useCallback(() => {
// Keep grip visible during drag by adding a CSS class
if (gripRef.current) {
gripRef.current.classList.add("opacity-100");
gripRef.current.classList.remove("opacity-0");
}
}, []);
const handleDragEnd = useCallback(() => {
// Restore hover-based visibility
if (gripRef.current) {
gripRef.current.classList.remove("opacity-100");
gripRef.current.classList.add("opacity-0");
}
}, []);
// Handle click on top edge area to toggle grip visibility
const handleTopEdgeClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setIsGripVisible((prev) => !prev);
}, []);
// Callback to handle height changes and manage suggestions visibility
const handleHeightChange = useCallback(
(height: number) => {
// Hide suggestions when input height exceeds the threshold
const shouldHideChatSuggestions = height > CHAT_INPUT.HEIGHT_THRESHOLD;
dispatch(setShouldHideSuggestions(shouldHideChatSuggestions));
},
[dispatch],
);
// Use the auto-resize hook with height change callback
const {
smartResize,
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
} = useAutoResize(chatInputRef as React.RefObject<HTMLElement | null>, {
minHeight: 20,
maxHeight: 400,
onHeightChange: handleHeightChange,
onGripDragStart: handleDragStart,
onGripDragEnd: handleDragEnd,
value: messageToSend ?? undefined,
enableManualResize: true,
});
return {
gripRef,
isGripVisible,
handleTopEdgeClick,
smartResize,
handleGripMouseDown,
handleGripTouchStart,
increaseHeightForEmptyContent,
};
};