mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
refactor(frontend): custom chat input (#10984)
This commit is contained in:
parent
9c9fa780b0
commit
0061bcc0b0
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
99
frontend/src/hooks/chat/use-chat-input-events.ts
Normal file
99
frontend/src/hooks/chat/use-chat-input-events.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
59
frontend/src/hooks/chat/use-chat-input-logic.ts
Normal file
59
frontend/src/hooks/chat/use-chat-input-logic.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
61
frontend/src/hooks/chat/use-chat-submission.ts
Normal file
61
frontend/src/hooks/chat/use-chat-submission.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
103
frontend/src/hooks/chat/use-file-handling.ts
Normal file
103
frontend/src/hooks/chat/use-file-handling.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
81
frontend/src/hooks/chat/use-grip-resize.ts
Normal file
81
frontend/src/hooks/chat/use-grip-resize.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user