Feat: multiline text input for chat (#5317)

### What problem does this PR solve?

Improves the chat interface by adding a multiline chat area that grows
when multiple lines exists.

Some images:

* Empty:
<img width="1334" alt="image"
src="https://github.com/user-attachments/assets/e8a68b46-def9-45af-b5b1-db0f0b67e6d8"
/>

* With multiple lines and documents:
<img width="1070" alt="image"
src="https://github.com/user-attachments/assets/ff976c5c-08fa-492f-9fc0-17512c95f9f2"
/>


### Type of change
- [X] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Omar Leonardo Sanchez Granados 2025-02-28 05:05:50 -05:00 committed by GitHub
parent 7600ebd263
commit 06e0c7d1a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 162 additions and 133 deletions

View File

@ -1,29 +1,38 @@
.messageInputWrapper { .messageInputWrapper {
margin-right: 20px; margin-right: 20px;
background-color: rgba(255, 255, 255, 0.1); padding: '0px 0px 10px 0px';
background-color: #ffffff;
border: 1px solid #d9d9d9;
&:hover {
border-color: #40a9ff;
box-shadow: #40a9ff;
}
border-radius: 8px; border-radius: 8px;
:global(.ant-input-affix-wrapper) { :global(.ant-input-affix-wrapper) {
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
.documentCard { }
.documentCard {
:global(.ant-card-body) { :global(.ant-card-body) {
padding: 10px; padding: 10px;
position: relative; position: relative;
width: 100%;
} }
} }
.listWrapper { .listWrapper {
padding: 0 10px; padding: 0 10px;
overflow: auto; overflow: auto;
max-height: 170px; max-height: 170px;
} width: 100%;
.inputWrapper { }
.inputWrapper {
border-radius: 8px; border-radius: 8px;
} }
.deleteIcon { .deleteIcon {
position: absolute; position: absolute;
right: -4px; right: -4px;
top: -4px; top: -4px;
color: #d92d20; color: #d92d20;
}
} }

View File

@ -11,11 +11,14 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
InfoCircleOutlined, InfoCircleOutlined,
LoadingOutlined, LoadingOutlined,
PaperClipOutlined,
SendOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { GetProp, UploadFile } from 'antd'; import type { GetProp, UploadFile } from 'antd';
import { import {
Button, Button,
Card, Card,
Divider,
Flex, Flex,
Input, Input,
List, List,
@ -25,12 +28,9 @@ import {
Upload, Upload,
UploadProps, UploadProps,
} from 'antd'; } from 'antd';
import classNames from 'classnames';
import get from 'lodash/get'; import get from 'lodash/get';
import { Paperclip } from 'lucide-react';
import { import {
ChangeEventHandler, ChangeEventHandler,
KeyboardEventHandler,
memo, memo,
useCallback, useCallback,
useEffect, useEffect,
@ -43,6 +43,8 @@ import styles from './index.less';
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0]; type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input;
const getFileId = (file: UploadFile) => get(file, 'response.data.0'); const getFileId = (file: UploadFile) => get(file, 'response.data.0');
const getFileIds = (fileList: UploadFile[]) => { const getFileIds = (fileList: UploadFile[]) => {
@ -99,6 +101,7 @@ const MessageInput = ({
const { data: documentInfos, setDocumentIds } = useFetchDocumentInfosByIds(); const { data: documentInfos, setDocumentIds } = useFetchDocumentInfosByIds();
const { uploadAndParseDocument } = useUploadAndParseDocument(uploadMethod); const { uploadAndParseDocument } = useUploadAndParseDocument(uploadMethod);
const conversationIdRef = useRef(conversationId); const conversationIdRef = useRef(conversationId);
const [fileList, setFileList] = useState<UploadFile[]>([]); const [fileList, setFileList] = useState<UploadFile[]>([]);
const handlePreview = async (file: UploadFile) => { const handlePreview = async (file: UploadFile) => {
@ -128,7 +131,6 @@ const MessageInput = ({
}); });
return [...list]; return [...list];
}); });
const ret = await uploadAndParseDocument({ const ret = await uploadAndParseDocument({
conversationId: nextConversationId, conversationId: nextConversationId,
fileList: [file], fileList: [file],
@ -148,6 +150,19 @@ const MessageInput = ({
const isUploadingFile = fileList.some((x) => x.status === 'uploading'); const isUploadingFile = fileList.some((x) => x.status === 'uploading');
const handleKeyDown = useCallback(
async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// check if it was shift + enter
if (event.key === 'Enter' && event.shiftKey) return;
if (event.key !== 'Enter') return;
if (sendDisabled || isUploadingFile || sendLoading) return;
event.preventDefault();
handlePressEnter();
},
[fileList, onPressEnter, isUploadingFile],
);
const handlePressEnter = useCallback(async () => { const handlePressEnter = useCallback(async () => {
if (isUploadingFile) return; if (isUploadingFile) return;
const ids = getFileIds(fileList.filter((x) => isUploadSuccess(x))); const ids = getFileIds(fileList.filter((x) => isUploadSuccess(x)));
@ -161,14 +176,6 @@ const MessageInput = ({
const handleCompositionStart = () => setIsComposing(true); const handleCompositionStart = () => setIsComposing(true);
const handleCompositionEnd = () => setIsComposing(false); const handleCompositionEnd = () => setIsComposing(false);
const handleInputKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === 'Enter' && !e.nativeEvent.shiftKey) {
if (isComposing || sendLoading) return;
e.preventDefault();
handlePressEnter();
}
};
const handleRemove = useCallback( const handleRemove = useCallback(
async (file: UploadFile) => { async (file: UploadFile) => {
const ids = get(file, 'response.data', []); const ids = get(file, 'response.data', []);
@ -215,52 +222,27 @@ const MessageInput = ({
}, [conversationId, setFileList]); }, [conversationId, setFileList]);
return ( return (
<Flex gap={20} vertical className={styles.messageInputWrapper}> <Flex gap={1} vertical className={styles.messageInputWrapper}>
<Flex align="center" gap={8}> <TextArea
<Input.TextArea
size="large" size="large"
placeholder={t('sendPlaceholder')} placeholder={t('sendPlaceholder')}
value={value} value={value}
allowClear
disabled={disabled} disabled={disabled}
className={classNames({ style={{
[styles.inputWrapper]: fileList.length === 0, border: 'none',
})} boxShadow: 'none',
onKeyDown={handleInputKeyDown} padding: '0px 10px',
marginTop: 10,
}}
autoSize={{ minRows: 2, maxRows: 10 }}
onKeyDown={handleKeyDown}
onChange={onInputChange} onChange={onInputChange}
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
autoSize={{ minRows: 1, maxRows: 6 }}
/> />
<Space> <Divider style={{ margin: '5px 30px 10px 0px' }} />
{showUploadIcon && ( <Flex justify="space-between" align="center">
<Upload
onPreview={handlePreview}
onChange={handleChange}
multiple={false}
onRemove={handleRemove}
showUploadList={false}
beforeUpload={() => {
return false;
}}
>
<Button
type={'text'}
disabled={disabled}
icon={<Paperclip />}
></Button>
</Upload>
)}
<Button
type="primary"
onClick={handlePressEnter}
loading={sendLoading}
disabled={sendDisabled || isUploadingFile}
>
{t('send')}
</Button>
</Space>
</Flex>
{fileList.length > 0 && ( {fileList.length > 0 && (
<List <List
grid={{ grid={{
@ -327,7 +309,9 @@ const MessageInput = ({
{item.status !== 'uploading' && ( {item.status !== 'uploading' && (
<span className={styles.deleteIcon}> <span className={styles.deleteIcon}>
<CloseCircleOutlined onClick={() => handleRemove(item)} /> <CloseCircleOutlined
onClick={() => handleRemove(item)}
/>
</span> </span>
)} )}
</Card> </Card>
@ -336,6 +320,42 @@ const MessageInput = ({
}} }}
/> />
)} )}
<Flex
gap={5}
align="center"
justify="flex-end"
style={{
paddingRight: 10,
paddingBottom: 10,
width: fileList.length > 0 ? '50%' : '100%',
}}
>
{showUploadIcon && (
<Upload
onPreview={handlePreview}
onChange={handleChange}
multiple={false}
onRemove={handleRemove}
showUploadList={false}
beforeUpload={() => {
return false;
}}
>
<Button type={'primary'} disabled={disabled}>
<PaperClipOutlined />
</Button>
</Upload>
)}
<Button
type="primary"
onClick={handlePressEnter}
loading={sendLoading}
disabled={sendDisabled || isUploadingFile || sendLoading}
>
<SendOutlined />
</Button>
</Flex>
</Flex>
</Flex> </Flex>
); );
}; };