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 { }
:global(.ant-card-body) {
padding: 10px; .documentCard {
position: relative; :global(.ant-card-body) {
} padding: 10px;
} position: relative;
.listWrapper { width: 100%;
padding: 0 10px;
overflow: auto;
max-height: 170px;
}
.inputWrapper {
border-radius: 8px;
}
.deleteIcon {
position: absolute;
right: -4px;
top: -4px;
color: #d92d20;
} }
} }
.listWrapper {
padding: 0 10px;
overflow: auto;
max-height: 170px;
width: 100%;
}
.inputWrapper {
border-radius: 8px;
}
.deleteIcon {
position: absolute;
right: -4px;
top: -4px;
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,23 +222,114 @@ 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',
onChange={onInputChange} marginTop: 10,
onCompositionStart={handleCompositionStart} }}
onCompositionEnd={handleCompositionEnd} autoSize={{ minRows: 2, maxRows: 10 }}
autoSize={{ minRows: 1, maxRows: 6 }} onKeyDown={handleKeyDown}
/> onChange={onInputChange}
<Space> onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
<Divider style={{ margin: '5px 30px 10px 0px' }} />
<Flex justify="space-between" align="center">
{fileList.length > 0 && (
<List
grid={{
gutter: 16,
xs: 1,
sm: 1,
md: 1,
lg: 1,
xl: 2,
xxl: 4,
}}
dataSource={fileList}
className={styles.listWrapper}
renderItem={(item) => {
const id = getFileId(item);
const documentInfo = getDocumentInfoById(id);
const fileExtension = getExtension(documentInfo?.name ?? '');
const fileName = item.originFileObj?.name ?? '';
return (
<List.Item>
<Card className={styles.documentCard}>
<Flex gap={10} align="center">
{item.status === 'uploading' ? (
<Spin
indicator={
<LoadingOutlined style={{ fontSize: 24 }} spin />
}
/>
) : item.status === 'error' ? (
<InfoCircleOutlined size={30}></InfoCircleOutlined>
) : (
<FileIcon id={id} name={fileName}></FileIcon>
)}
<Flex vertical style={{ width: '90%' }}>
<Text
ellipsis={{ tooltip: fileName }}
className={styles.nameText}
>
<b> {fileName}</b>
</Text>
{item.status === 'error' ? (
t('uploadFailed')
) : (
<>
{item.percent !== 100 ? (
t('uploading')
) : !item.response ? (
t('parsing')
) : (
<Space>
<span>{fileExtension?.toUpperCase()},</span>
<span>
{formatBytes(
getDocumentInfoById(id)?.size ?? 0,
)}
</span>
</Space>
)}
</>
)}
</Flex>
</Flex>
{item.status !== 'uploading' && (
<span className={styles.deleteIcon}>
<CloseCircleOutlined
onClick={() => handleRemove(item)}
/>
</span>
)}
</Card>
</List.Item>
);
}}
/>
)}
<Flex
gap={5}
align="center"
justify="flex-end"
style={{
paddingRight: 10,
paddingBottom: 10,
width: fileList.length > 0 ? '50%' : '100%',
}}
>
{showUploadIcon && ( {showUploadIcon && (
<Upload <Upload
onPreview={handlePreview} onPreview={handlePreview}
@ -243,99 +341,21 @@ const MessageInput = ({
return false; return false;
}} }}
> >
<Button <Button type={'primary'} disabled={disabled}>
type={'text'} <PaperClipOutlined />
disabled={disabled} </Button>
icon={<Paperclip />}
></Button>
</Upload> </Upload>
)} )}
<Button <Button
type="primary" type="primary"
onClick={handlePressEnter} onClick={handlePressEnter}
loading={sendLoading} loading={sendLoading}
disabled={sendDisabled || isUploadingFile} disabled={sendDisabled || isUploadingFile || sendLoading}
> >
{t('send')} <SendOutlined />
</Button> </Button>
</Space> </Flex>
</Flex> </Flex>
{fileList.length > 0 && (
<List
grid={{
gutter: 16,
xs: 1,
sm: 1,
md: 1,
lg: 1,
xl: 2,
xxl: 4,
}}
dataSource={fileList}
className={styles.listWrapper}
renderItem={(item) => {
const id = getFileId(item);
const documentInfo = getDocumentInfoById(id);
const fileExtension = getExtension(documentInfo?.name ?? '');
const fileName = item.originFileObj?.name ?? '';
return (
<List.Item>
<Card className={styles.documentCard}>
<Flex gap={10} align="center">
{item.status === 'uploading' ? (
<Spin
indicator={
<LoadingOutlined style={{ fontSize: 24 }} spin />
}
/>
) : item.status === 'error' ? (
<InfoCircleOutlined size={30}></InfoCircleOutlined>
) : (
<FileIcon id={id} name={fileName}></FileIcon>
)}
<Flex vertical style={{ width: '90%' }}>
<Text
ellipsis={{ tooltip: fileName }}
className={styles.nameText}
>
<b> {fileName}</b>
</Text>
{item.status === 'error' ? (
t('uploadFailed')
) : (
<>
{item.percent !== 100 ? (
t('uploading')
) : !item.response ? (
t('parsing')
) : (
<Space>
<span>{fileExtension?.toUpperCase()},</span>
<span>
{formatBytes(
getDocumentInfoById(id)?.size ?? 0,
)}
</span>
</Space>
)}
</>
)}
</Flex>
</Flex>
{item.status !== 'uploading' && (
<span className={styles.deleteIcon}>
<CloseCircleOutlined onClick={() => handleRemove(item)} />
</span>
)}
</Card>
</List.Item>
);
}}
/>
)}
</Flex> </Flex>
); );
}; };