@lobehub/chat 0.119.9 → 0.119.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/locales/de-DE/chat.json +9 -4
  3. package/locales/en-US/chat.json +9 -4
  4. package/locales/es-ES/chat.json +9 -4
  5. package/locales/fr-FR/chat.json +9 -4
  6. package/locales/it-IT/chat.json +9 -4
  7. package/locales/ja-JP/chat.json +9 -4
  8. package/locales/ko-KR/chat.json +9 -4
  9. package/locales/nl-NL/chat.json +9 -4
  10. package/locales/pl-PL/chat.json +9 -4
  11. package/locales/pt-BR/chat.json +9 -4
  12. package/locales/ru-RU/chat.json +9 -4
  13. package/locales/tr-TR/chat.json +9 -4
  14. package/locales/vi-VN/chat.json +9 -4
  15. package/locales/zh-CN/chat.json +9 -4
  16. package/locales/zh-TW/chat.json +9 -4
  17. package/package.json +1 -1
  18. package/src/app/chat/(desktop)/features/ChatInput/Footer/index.tsx +164 -0
  19. package/src/app/chat/(desktop)/features/ChatInput/Header/index.tsx +33 -0
  20. package/src/app/chat/(desktop)/features/ChatInput/TextArea.test.tsx +316 -0
  21. package/src/app/chat/(desktop)/features/ChatInput/TextArea.tsx +118 -0
  22. package/src/app/chat/(desktop)/features/ChatInput/__tests__/useAutoFocus.test.ts +23 -1
  23. package/src/app/chat/(desktop)/features/ChatInput/index.tsx +41 -110
  24. package/src/app/chat/(desktop)/features/ChatInput/useAutoFocus.ts +5 -2
  25. package/src/app/chat/(mobile)/features/ChatHeaderTitle.tsx +1 -2
  26. package/src/app/chat/features/ChatHeader/SettingButton.tsx +1 -1
  27. package/src/app/chat/features/ChatHeader/ShareButton/useScreenshot.ts +1 -1
  28. package/src/features/ChatInput/useChatInput.ts +2 -8
  29. package/src/features/ChatInput/useSend.ts +9 -2
  30. package/src/locales/default/chat.ts +8 -3
  31. package/src/store/chat/slices/message/action.test.ts +7 -7
  32. package/src/store/chat/slices/message/action.ts +11 -2
  33. package/src/store/chat/slices/message/initialState.ts +0 -3
  34. package/src/store/global/slices/common/initialState.ts +5 -0
  35. package/src/store/global/slices/common/selectors.ts +4 -1
  36. package/src/utils/keyboard.ts +13 -0
  37. package/src/utils/platform.ts +4 -0
  38. /package/src/app/chat/(desktop)/features/ChatInput/{LocalFiles.tsx → Footer/LocalFiles.tsx} +0 -0
  39. /package/src/app/chat/(desktop)/features/ChatInput/{DragUpload.tsx → Header/DragUpload.tsx} +0 -0
@@ -0,0 +1,164 @@
1
+ import { Icon } from '@lobehub/ui';
2
+ import { Button, Dropdown, Space } from 'antd';
3
+ import { createStyles } from 'antd-style';
4
+ import {
5
+ ChevronUp,
6
+ CornerDownLeft,
7
+ Loader2,
8
+ LucideCheck,
9
+ LucideChevronDown,
10
+ LucideCommand,
11
+ LucidePlus,
12
+ } from 'lucide-react';
13
+ import { memo } from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { Center, Flexbox } from 'react-layout-kit';
16
+
17
+ import SaveTopic from '@/features/ChatInput/Topic';
18
+ import { useSendMessage } from '@/features/ChatInput/useSend';
19
+ import { useChatStore } from '@/store/chat';
20
+ import { useGlobalStore } from '@/store/global';
21
+ import { preferenceSelectors } from '@/store/global/selectors';
22
+ import { useSessionStore } from '@/store/session';
23
+ import { agentSelectors } from '@/store/session/selectors';
24
+ import { isMacOS } from '@/utils/platform';
25
+
26
+ import { LocalFiles } from './LocalFiles';
27
+
28
+ const useStyles = createStyles(({ css, prefixCls }) => {
29
+ return {
30
+ arrow: css`
31
+ &.${prefixCls}-btn.${prefixCls}-btn-icon-only {
32
+ width: 28px;
33
+ }
34
+ `,
35
+ overrideAntdIcon: css`
36
+ .${prefixCls}-btn.${prefixCls}-btn-icon-only {
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: center;
40
+ }
41
+ `,
42
+ };
43
+ });
44
+
45
+ const isMac = isMacOS();
46
+
47
+ const Footer = memo(() => {
48
+ const { t } = useTranslation('chat');
49
+
50
+ const { theme, styles } = useStyles();
51
+ const canUpload = useSessionStore(agentSelectors.modelHasVisionAbility);
52
+ const [loading, stopGenerateMessage] = useChatStore((s) => [
53
+ !!s.chatLoadingId,
54
+ s.stopGenerateMessage,
55
+ ]);
56
+ const [useCmdEnterToSend, updatePreference] = useGlobalStore((s) => [
57
+ preferenceSelectors.useCmdEnterToSend(s),
58
+ s.updatePreference,
59
+ ]);
60
+ const sendMessage = useSendMessage();
61
+
62
+ const cmdEnter = (
63
+ <Flexbox gap={2} horizontal>
64
+ <Icon icon={isMac ? LucideCommand : ChevronUp} />
65
+ <Icon icon={CornerDownLeft} />
66
+ </Flexbox>
67
+ );
68
+
69
+ const enter = (
70
+ <Center>
71
+ <Icon icon={CornerDownLeft} />
72
+ </Center>
73
+ );
74
+
75
+ const sendShortcut = useCmdEnterToSend ? cmdEnter : enter;
76
+
77
+ const wrapperShortcut = useCmdEnterToSend ? enter : cmdEnter;
78
+
79
+ return (
80
+ <Flexbox
81
+ align={'end'}
82
+ className={styles.overrideAntdIcon}
83
+ distribution={'space-between'}
84
+ flex={'none'}
85
+ gap={8}
86
+ horizontal
87
+ padding={'0 24px'}
88
+ >
89
+ <Flexbox align={'center'} gap={8} horizontal>
90
+ {canUpload && <LocalFiles />}
91
+ </Flexbox>
92
+ <Flexbox align={'center'} gap={8} horizontal>
93
+ <Flexbox
94
+ gap={4}
95
+ horizontal
96
+ style={{ color: theme.colorTextDescription, fontSize: 12, marginRight: 12 }}
97
+ >
98
+ {sendShortcut}
99
+ <span>{t('input.send')}</span>
100
+ <span>/</span>
101
+ {wrapperShortcut}
102
+ <span>{t('input.warp')}</span>
103
+ </Flexbox>
104
+ <SaveTopic />
105
+ <Flexbox style={{ minWidth: 92 }}>
106
+ {loading ? (
107
+ <Button icon={loading && <Icon icon={Loader2} spin />} onClick={stopGenerateMessage}>
108
+ {t('input.stop')}
109
+ </Button>
110
+ ) : (
111
+ <Space.Compact>
112
+ <Button onClick={() => sendMessage()} type={'primary'}>
113
+ {t('input.send')}
114
+ </Button>
115
+ <Dropdown
116
+ menu={{
117
+ items: [
118
+ {
119
+ icon: !useCmdEnterToSend ? <Icon icon={LucideCheck} /> : <div />,
120
+ key: 'sendWithEnter',
121
+ label: t('input.sendWithEnter'),
122
+ onClick: () => {
123
+ updatePreference({ useCmdEnterToSend: false });
124
+ },
125
+ },
126
+ {
127
+ icon: useCmdEnterToSend ? <Icon icon={LucideCheck} /> : <div />,
128
+ key: 'sendWithCmdEnter',
129
+ label: t('input.sendWithCmdEnter', {
130
+ meta: isMac ? 'Cmd' : 'Ctrl',
131
+ }),
132
+ onClick: () => {
133
+ updatePreference({ useCmdEnterToSend: true });
134
+ },
135
+ },
136
+ { type: 'divider' },
137
+ {
138
+ icon: <Icon icon={LucidePlus} />,
139
+ key: 'onlyAdd',
140
+ label: t('input.onlyAdd'),
141
+ onClick: () => {
142
+ sendMessage(true);
143
+ },
144
+ },
145
+ ],
146
+ }}
147
+ placement={'topRight'}
148
+ trigger={['hover']}
149
+ >
150
+ <Button
151
+ className={styles.arrow}
152
+ icon={<Icon icon={LucideChevronDown} />}
153
+ type={'primary'}
154
+ />
155
+ </Dropdown>
156
+ </Space.Compact>
157
+ )}
158
+ </Flexbox>
159
+ </Flexbox>
160
+ </Flexbox>
161
+ );
162
+ });
163
+
164
+ export default Footer;
@@ -0,0 +1,33 @@
1
+ import { ActionIcon } from '@lobehub/ui';
2
+ import { Maximize2, Minimize2 } from 'lucide-react';
3
+ import { memo } from 'react';
4
+
5
+ import ActionBar from '@/features/ChatInput/ActionBar';
6
+ import { useSessionStore } from '@/store/session';
7
+ import { agentSelectors } from '@/store/session/selectors';
8
+
9
+ import DragUpload from './DragUpload';
10
+
11
+ const Header = memo<{ expand: boolean; setExpand: (expand: boolean) => void }>(
12
+ ({ expand, setExpand }) => {
13
+ const canUpload = useSessionStore(agentSelectors.modelHasVisionAbility);
14
+
15
+ return (
16
+ <>
17
+ {canUpload && <DragUpload />}
18
+ <ActionBar
19
+ rightAreaEndRender={
20
+ <ActionIcon
21
+ icon={expand ? Minimize2 : Maximize2}
22
+ onClick={() => {
23
+ setExpand(!expand);
24
+ }}
25
+ />
26
+ }
27
+ />
28
+ </>
29
+ );
30
+ },
31
+ );
32
+
33
+ export default Header;
@@ -0,0 +1,316 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { useChatStore } from '@/store/chat';
6
+ import { useGlobalStore } from '@/store/global';
7
+
8
+ import InputArea from './TextArea';
9
+
10
+ let setExpandMock: (expand: boolean) => void;
11
+
12
+ beforeEach(() => {
13
+ setExpandMock = vi.fn();
14
+ });
15
+
16
+ describe('<InputArea />', () => {
17
+ it('renders the TextArea component correctly', () => {
18
+ render(<InputArea />);
19
+ const textArea = screen.getByRole('textbox');
20
+ expect(textArea).toBeInTheDocument();
21
+ });
22
+
23
+ it('auto-focuses the TextArea component on mount', () => {
24
+ render(<InputArea />);
25
+ const textArea = screen.getByRole('textbox');
26
+
27
+ // The document's active element should be the textarea if it was auto-focused
28
+ expect(document.activeElement).toBe(textArea);
29
+ });
30
+
31
+ it('renders with correct placeholder text', () => {
32
+ render(<InputArea setExpand={setExpandMock} />);
33
+ const textArea = screen.getByPlaceholderText('sendPlaceholder');
34
+ expect(textArea).toBeInTheDocument();
35
+ });
36
+
37
+ it('has the correct initial value', () => {
38
+ render(<InputArea setExpand={setExpandMock} />);
39
+ const textArea = screen.getByRole('textbox');
40
+ expect(textArea).toHaveValue('');
41
+ });
42
+
43
+ describe('input behavior', () => {
44
+ it('calls updateInputMessage on input change', async () => {
45
+ const updateInputMessageMock = vi.fn();
46
+ act(() => {
47
+ useChatStore.setState({ updateInputMessage: updateInputMessageMock });
48
+ });
49
+
50
+ render(<InputArea />);
51
+
52
+ const textArea = screen.getByRole('textbox');
53
+ const newValue = 'New message';
54
+ fireEvent.change(textArea, { target: { value: newValue } });
55
+
56
+ expect(updateInputMessageMock).toHaveBeenCalledWith(newValue);
57
+ });
58
+
59
+ it('handles composition events for IME input correctly', () => {
60
+ const updateInputMessageMock = vi.fn();
61
+ act(() => {
62
+ useChatStore.setState({ updateInputMessage: updateInputMessageMock });
63
+ });
64
+
65
+ render(<InputArea />);
66
+ const textArea = screen.getByRole('textbox');
67
+
68
+ // Start composition (IME input starts)
69
+ fireEvent.compositionStart(textArea);
70
+ fireEvent.change(textArea, { target: { value: '正在' } });
71
+ expect(updateInputMessageMock).toHaveBeenCalledWith('正在');
72
+
73
+ // End composition (IME input ends)
74
+ fireEvent.compositionEnd(textArea);
75
+ fireEvent.change(textArea, { target: { value: '正在输入' } });
76
+ expect(updateInputMessageMock).toHaveBeenCalledWith('正在输入');
77
+ });
78
+
79
+ it('does not send a message when Enter is pressed during IME composition', () => {
80
+ const updateInputMessageMock = vi.fn();
81
+ act(() => {
82
+ useChatStore.setState({ updateInputMessage: updateInputMessageMock });
83
+ });
84
+
85
+ render(<InputArea setExpand={setExpandMock} />);
86
+ const textArea = screen.getByRole('textbox');
87
+
88
+ // Start composition (IME input starts)
89
+ fireEvent.compositionStart(textArea);
90
+
91
+ // Simulate pressing Enter during IME composition
92
+ fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
93
+
94
+ // Since we are in the middle of IME composition, the message should not be sent
95
+ expect(setExpandMock).not.toHaveBeenCalled();
96
+ expect(updateInputMessageMock).not.toHaveBeenCalled();
97
+
98
+ // End composition (IME input ends)
99
+ fireEvent.compositionEnd(textArea);
100
+
101
+ // Now simulate pressing Enter after IME composition
102
+ fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
103
+
104
+ // Since IME composition has ended, now the message should be sent
105
+ expect(setExpandMock).toHaveBeenCalled();
106
+ expect(updateInputMessageMock).toHaveBeenCalled();
107
+ });
108
+
109
+ it('updates the input message when TextArea loses focus', () => {
110
+ const updateInputMessageMock = vi.fn();
111
+ act(() => {
112
+ useChatStore.setState({ updateInputMessage: updateInputMessageMock });
113
+ });
114
+
115
+ render(<InputArea setExpand={setExpandMock} />);
116
+ const textArea = screen.getByRole('textbox');
117
+ const newText = 'New input text';
118
+
119
+ fireEvent.change(textArea, { target: { value: newText } });
120
+ fireEvent.blur(textArea);
121
+
122
+ expect(updateInputMessageMock).toHaveBeenCalledWith(newText);
123
+ });
124
+ });
125
+
126
+ describe('leaving protect', () => {
127
+ it('triggers a warning when trying to leave the page with unsaved input', () => {
128
+ const beforeUnloadEvent = new Event('beforeunload', { cancelable: true });
129
+
130
+ act(() => {
131
+ useChatStore.setState({ inputMessage: 'Unsaved input' });
132
+ });
133
+
134
+ render(<InputArea />);
135
+
136
+ // trigger beforeunload
137
+ window.dispatchEvent(beforeUnloadEvent);
138
+
139
+ // 检查 returnValue 是否被设置为 true,这是触发警告的标识
140
+ expect(beforeUnloadEvent.returnValue).toBeTruthy();
141
+ });
142
+
143
+ it('does not trigger a warning when trying to leave the page with empty input', () => {
144
+ act(() => {
145
+ useChatStore.setState({ inputMessage: '' });
146
+ });
147
+
148
+ // 模拟 window.addEventListener 来捕获 beforeunload 事件处理程序
149
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
150
+ const beforeUnloadHandler = vi.fn();
151
+
152
+ addEventListenerSpy.mockImplementation((event, handler) => {
153
+ if (event === 'beforeunload') {
154
+ beforeUnloadHandler.mockImplementation(handler as any);
155
+ }
156
+ });
157
+
158
+ // 渲染组件
159
+ render(<InputArea />);
160
+
161
+ // 触发 beforeunload 事件
162
+ const event = new Event('beforeunload', { cancelable: true });
163
+ window.dispatchEvent(event);
164
+
165
+ // 检查 beforeunload 事件的处理程序是否没有被调用
166
+ expect(beforeUnloadHandler).not.toHaveBeenCalled();
167
+
168
+ // 清理模拟
169
+ addEventListenerSpy.mockRestore();
170
+ });
171
+
172
+ describe('cleanup', () => {
173
+ it('removes beforeunload listener on unmount', () => {
174
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
175
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
176
+
177
+ // 渲染并立即卸载组件
178
+ const { unmount } = render(<InputArea />);
179
+ unmount();
180
+
181
+ // 检查是否为 beforeunload 事件添加了监听器
182
+ expect(addEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
183
+
184
+ // 检查是否移除了对应的监听器
185
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
186
+
187
+ // 清理 spy
188
+ addEventListenerSpy.mockRestore();
189
+ removeEventListenerSpy.mockRestore();
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('message sending behavior', () => {
195
+ it('does not send message when loading or shift key is pressed', () => {
196
+ const sendMessageMock = vi.fn();
197
+ act(() => {
198
+ useChatStore.setState({ chatLoadingId: '123', sendMessage: sendMessageMock });
199
+ });
200
+
201
+ render(<InputArea setExpand={setExpandMock} />);
202
+ const textArea = screen.getByRole('textbox');
203
+
204
+ fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', shiftKey: true });
205
+ expect(sendMessageMock).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('sends message on Enter press when not loading and no shift key', () => {
209
+ const sendMessageMock = vi.fn();
210
+ act(() => {
211
+ useChatStore.setState({
212
+ chatLoadingId: '',
213
+ inputMessage: 'abc',
214
+ sendMessage: sendMessageMock,
215
+ });
216
+ });
217
+
218
+ render(<InputArea />);
219
+ const textArea = screen.getByRole('textbox');
220
+ fireEvent.change(textArea, { target: { value: 'Test message' } });
221
+
222
+ fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
223
+ expect(sendMessageMock).toHaveBeenCalled();
224
+ });
225
+
226
+ describe('metaKey behavior for sending messages', () => {
227
+ it('windows: sends message on ctrl + enter when useCmdEnterToSend is true', () => {
228
+ const sendMessageMock = vi.fn();
229
+ act(() => {
230
+ useChatStore.setState({
231
+ chatLoadingId: '',
232
+ inputMessage: '123',
233
+ sendMessage: sendMessageMock,
234
+ });
235
+ useGlobalStore.getState().updatePreference({ useCmdEnterToSend: true });
236
+ });
237
+
238
+ render(<InputArea setExpand={setExpandMock} />);
239
+ const textArea = screen.getByRole('textbox');
240
+
241
+ fireEvent.keyDown(textArea, { code: 'Enter', ctrlKey: true, key: 'Enter' });
242
+ expect(sendMessageMock).toHaveBeenCalled();
243
+ });
244
+
245
+ it('windows: inserts a new line on ctrl + enter when useCmdEnterToSend is false', () => {
246
+ const sendMessageMock = vi.fn();
247
+ const updateInputMessageMock = vi.fn();
248
+ act(() => {
249
+ useChatStore.setState({
250
+ chatLoadingId: '',
251
+ inputMessage: 'Test',
252
+ sendMessage: sendMessageMock,
253
+ updateInputMessage: updateInputMessageMock,
254
+ });
255
+ useGlobalStore.getState().updatePreference({ useCmdEnterToSend: false });
256
+ });
257
+
258
+ render(<InputArea setExpand={setExpandMock} />);
259
+ const textArea = screen.getByRole('textbox');
260
+
261
+ fireEvent.keyDown(textArea, { code: 'Enter', ctrlKey: true, key: 'Enter' });
262
+ expect(updateInputMessageMock).toHaveBeenCalledWith('Test\n');
263
+ expect(sendMessageMock).not.toHaveBeenCalled(); // sendMessage should not be called
264
+ });
265
+
266
+ it('macOS: sends message on cmd + enter when useCmdEnterToSend is true', () => {
267
+ vi.stubGlobal('navigator', {
268
+ userAgent:
269
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
270
+ });
271
+ const sendMessageMock = vi.fn();
272
+ act(() => {
273
+ useChatStore.setState({
274
+ chatLoadingId: '',
275
+ inputMessage: '123',
276
+ sendMessage: sendMessageMock,
277
+ });
278
+ useGlobalStore.getState().updatePreference({ useCmdEnterToSend: true });
279
+ });
280
+
281
+ render(<InputArea setExpand={setExpandMock} />);
282
+ const textArea = screen.getByRole('textbox');
283
+
284
+ fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', metaKey: true });
285
+ expect(sendMessageMock).toHaveBeenCalled();
286
+ vi.restoreAllMocks();
287
+ });
288
+
289
+ it('macOS: inserts a new line on cmd + enter when useCmdEnterToSend is false', () => {
290
+ vi.stubGlobal('navigator', {
291
+ userAgent:
292
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
293
+ });
294
+ const sendMessageMock = vi.fn();
295
+ const updateInputMessageMock = vi.fn();
296
+ act(() => {
297
+ useChatStore.setState({
298
+ chatLoadingId: '',
299
+ inputMessage: 'Test',
300
+ sendMessage: sendMessageMock,
301
+ updateInputMessage: updateInputMessageMock,
302
+ });
303
+ useGlobalStore.getState().updatePreference({ useCmdEnterToSend: false });
304
+ });
305
+
306
+ render(<InputArea setExpand={setExpandMock} />);
307
+ const textArea = screen.getByRole('textbox');
308
+
309
+ fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', metaKey: true });
310
+ expect(updateInputMessageMock).toHaveBeenCalledWith('Test\n');
311
+ expect(sendMessageMock).not.toHaveBeenCalled(); // sendMessage should not be called
312
+ vi.restoreAllMocks();
313
+ });
314
+ });
315
+ });
316
+ });
@@ -0,0 +1,118 @@
1
+ import { TextArea } from '@lobehub/ui';
2
+ import { createStyles } from 'antd-style';
3
+ import { TextAreaRef } from 'antd/es/input/TextArea';
4
+ import { memo, useEffect, useRef } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ import { useSendMessage } from '@/features/ChatInput/useSend';
8
+ import { useChatStore } from '@/store/chat';
9
+ import { useGlobalStore } from '@/store/global';
10
+ import { preferenceSelectors } from '@/store/global/selectors';
11
+ import { isCommandPressed } from '@/utils/keyboard';
12
+
13
+ import { useAutoFocus } from './useAutoFocus';
14
+
15
+ const useStyles = createStyles(({ css }) => {
16
+ return {
17
+ textarea: css`
18
+ resize: none !important;
19
+
20
+ height: 100% !important;
21
+ padding: 0 24px;
22
+
23
+ line-height: 1.5;
24
+
25
+ box-shadow: none !important;
26
+ `,
27
+ textareaContainer: css`
28
+ position: relative;
29
+ flex: 1;
30
+ `,
31
+ };
32
+ });
33
+
34
+ const InputArea = memo<{ setExpand?: (expand: boolean) => void }>(({ setExpand }) => {
35
+ const { t } = useTranslation('chat');
36
+ const { styles } = useStyles();
37
+ const ref = useRef<TextAreaRef>(null);
38
+ const isChineseInput = useRef(false);
39
+
40
+ const [loading, value, updateInputMessage] = useChatStore((s) => [
41
+ !!s.chatLoadingId,
42
+ s.inputMessage,
43
+ s.updateInputMessage,
44
+ ]);
45
+
46
+ const useCmdEnterToSend = useGlobalStore(preferenceSelectors.useCmdEnterToSend);
47
+
48
+ const sendMessage = useSendMessage();
49
+
50
+ useAutoFocus(ref);
51
+
52
+ const hasValue = !!value;
53
+
54
+ useEffect(() => {
55
+ const fn = (e: BeforeUnloadEvent) => {
56
+ if (hasValue) {
57
+ // set returnValue to trigger alert modal
58
+ // Note: No matter what value is set, the browser will display the standard text
59
+ e.returnValue = '你有正在输入中的内容,确定要离开吗?';
60
+ }
61
+ };
62
+
63
+ window.addEventListener('beforeunload', fn);
64
+ return () => {
65
+ window.removeEventListener('beforeunload', fn);
66
+ };
67
+ }, [hasValue]);
68
+
69
+ return (
70
+ <div className={styles.textareaContainer}>
71
+ <TextArea
72
+ autoFocus
73
+ className={styles.textarea}
74
+ onBlur={(e) => {
75
+ updateInputMessage?.(e.target.value);
76
+ }}
77
+ onChange={(e) => {
78
+ updateInputMessage?.(e.target.value);
79
+ }}
80
+ onCompositionEnd={() => {
81
+ isChineseInput.current = false;
82
+ }}
83
+ onCompositionStart={() => {
84
+ isChineseInput.current = true;
85
+ }}
86
+ onPressEnter={(e) => {
87
+ if (loading || e.shiftKey || isChineseInput.current) return;
88
+
89
+ // eslint-disable-next-line unicorn/consistent-function-scoping
90
+ const send = () => {
91
+ sendMessage();
92
+ setExpand?.(false);
93
+ };
94
+ const commandKey = isCommandPressed(e);
95
+
96
+ // when user like cmd + enter to send message
97
+ if (useCmdEnterToSend) {
98
+ if (commandKey) send();
99
+ } else {
100
+ // cmd + enter to wrap
101
+ if (commandKey) {
102
+ updateInputMessage?.((e.target as any).value + '\n');
103
+ return;
104
+ }
105
+
106
+ send();
107
+ }
108
+ }}
109
+ placeholder={t('sendPlaceholder')}
110
+ ref={ref}
111
+ type={'pure'}
112
+ value={value}
113
+ />
114
+ </div>
115
+ );
116
+ });
117
+
118
+ export default InputArea;
@@ -7,6 +7,7 @@ enum ElType {
7
7
  div,
8
8
  input,
9
9
  markdown,
10
+ debug,
10
11
  }
11
12
 
12
13
  // 模拟elementFromPoint方法
@@ -19,6 +20,10 @@ document.elementFromPoint = function (x) {
19
20
  return document.createElement('input');
20
21
  }
21
22
 
23
+ if (x === ElType.debug) {
24
+ return document.createElement('pre');
25
+ }
26
+
22
27
  if (x === ElType.markdown) {
23
28
  const markdownEl = document.createElement('article');
24
29
  const markdownChildEl = document.createElement('p');
@@ -68,7 +73,7 @@ describe('useAutoFocus', () => {
68
73
  const inputRef = { current: { focus: vi.fn() } } as RefObject<any>;
69
74
  renderHook(() => useAutoFocus(inputRef));
70
75
 
71
- // Simulate a mousedown event on an input element
76
+ // Simulate a mousedown event on a markdown element
72
77
  act(() => {
73
78
  document.dispatchEvent(
74
79
  new MouseEvent('mousedown', { bubbles: true, clientX: ElType.markdown }),
@@ -82,4 +87,21 @@ describe('useAutoFocus', () => {
82
87
 
83
88
  expect(inputRef.current?.focus).not.toHaveBeenCalled();
84
89
  });
90
+
91
+ it('should not focus inputRef when mouseup event happens inside of debug element', () => {
92
+ const inputRef = { current: { focus: vi.fn() } } as RefObject<any>;
93
+ renderHook(() => useAutoFocus(inputRef));
94
+
95
+ // Simulate a mousedown event on a debug element
96
+ act(() => {
97
+ document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: ElType.debug }));
98
+ });
99
+
100
+ // Simulate a mouseup event
101
+ act(() => {
102
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
103
+ });
104
+
105
+ expect(inputRef.current?.focus).not.toHaveBeenCalled();
106
+ });
85
107
  });