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 {
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;
:global(.ant-input-affix-wrapper) {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.documentCard {
:global(.ant-card-body) {
padding: 10px;
position: relative;
}
}
.listWrapper {
padding: 0 10px;
overflow: auto;
max-height: 170px;
}
.inputWrapper {
border-radius: 8px;
}
.deleteIcon {
position: absolute;
right: -4px;
top: -4px;
color: #d92d20;
}
.documentCard {
:global(.ant-card-body) {
padding: 10px;
position: relative;
width: 100%;
}
}
.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,
InfoCircleOutlined,
LoadingOutlined,
PaperClipOutlined,
SendOutlined,
} from '@ant-design/icons';
import type { GetProp, UploadFile } from 'antd';
import {
Button,
Card,
Divider,
Flex,
Input,
List,
@ -25,12 +28,9 @@ import {
Upload,
UploadProps,
} from 'antd';
import classNames from 'classnames';
import get from 'lodash/get';
import { Paperclip } from 'lucide-react';
import {
ChangeEventHandler,
KeyboardEventHandler,
memo,
useCallback,
useEffect,
@ -43,6 +43,8 @@ import styles from './index.less';
type FileType = Parameters<GetProp<UploadProps, 'beforeUpload'>>[0];
const { Text } = Typography;
const { TextArea } = Input;
const getFileId = (file: UploadFile) => get(file, 'response.data.0');
const getFileIds = (fileList: UploadFile[]) => {
@ -99,6 +101,7 @@ const MessageInput = ({
const { data: documentInfos, setDocumentIds } = useFetchDocumentInfosByIds();
const { uploadAndParseDocument } = useUploadAndParseDocument(uploadMethod);
const conversationIdRef = useRef(conversationId);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const handlePreview = async (file: UploadFile) => {
@ -128,7 +131,6 @@ const MessageInput = ({
});
return [...list];
});
const ret = await uploadAndParseDocument({
conversationId: nextConversationId,
fileList: [file],
@ -148,6 +150,19 @@ const MessageInput = ({
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 () => {
if (isUploadingFile) return;
const ids = getFileIds(fileList.filter((x) => isUploadSuccess(x)));
@ -161,14 +176,6 @@ const MessageInput = ({
const handleCompositionStart = () => setIsComposing(true);
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(
async (file: UploadFile) => {
const ids = get(file, 'response.data', []);
@ -215,23 +222,114 @@ const MessageInput = ({
}, [conversationId, setFileList]);
return (
<Flex gap={20} vertical className={styles.messageInputWrapper}>
<Flex align="center" gap={8}>
<Input.TextArea
size="large"
placeholder={t('sendPlaceholder')}
value={value}
disabled={disabled}
className={classNames({
[styles.inputWrapper]: fileList.length === 0,
})}
onKeyDown={handleInputKeyDown}
onChange={onInputChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
autoSize={{ minRows: 1, maxRows: 6 }}
/>
<Space>
<Flex gap={1} vertical className={styles.messageInputWrapper}>
<TextArea
size="large"
placeholder={t('sendPlaceholder')}
value={value}
allowClear
disabled={disabled}
style={{
border: 'none',
boxShadow: 'none',
padding: '0px 10px',
marginTop: 10,
}}
autoSize={{ minRows: 2, maxRows: 10 }}
onKeyDown={handleKeyDown}
onChange={onInputChange}
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 && (
<Upload
onPreview={handlePreview}
@ -243,99 +341,21 @@ const MessageInput = ({
return false;
}}
>
<Button
type={'text'}
disabled={disabled}
icon={<Paperclip />}
></Button>
<Button type={'primary'} disabled={disabled}>
<PaperClipOutlined />
</Button>
</Upload>
)}
<Button
type="primary"
onClick={handlePressEnter}
loading={sendLoading}
disabled={sendDisabled || isUploadingFile}
disabled={sendDisabled || isUploadingFile || sendLoading}
>
{t('send')}
<SendOutlined />
</Button>
</Space>
</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>
);
};