@gravity-ui/aikit 1.3.4 → 1.4.0
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.
- package/dist/components/organisms/MessageList/MessageList.js +4 -4
- package/dist/components/organisms/PromptInput/usePromptInput.js +2 -0
- package/dist/components/pages/ChatContainer/__stories__/ChatContainer.stories.d.ts +6 -0
- package/dist/components/pages/ChatContainer/__stories__/ChatContainer.stories.js +102 -2
- package/dist/hooks/useSmartScroll.d.ts +0 -1
- package/dist/hooks/useSmartScroll.js +6 -5
- package/dist/types/chat.d.ts +1 -1
- package/package.json +1 -1
|
@@ -9,11 +9,11 @@ import { UserMessage } from '../UserMessage';
|
|
|
9
9
|
import { ErrorAlert } from './ErrorAlert';
|
|
10
10
|
import './MessageList.scss';
|
|
11
11
|
const b = block('message-list');
|
|
12
|
-
export function MessageList({ messages, messageRendererRegistry, transformOptions, shouldParseIncompleteMarkdown, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, loaderStatuses = ['submitted'], className, qa, status, errorMessage, onRetry, hasPreviousMessages = false, onLoadPreviousMessages, }) {
|
|
13
|
-
const isStreaming = status === 'streaming';
|
|
12
|
+
export function MessageList({ messages, messageRendererRegistry, transformOptions, shouldParseIncompleteMarkdown, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, loaderStatuses = ['submitted', 'streaming_loading'], className, qa, status, errorMessage, onRetry, hasPreviousMessages = false, onLoadPreviousMessages, }) {
|
|
13
|
+
const isStreaming = status === 'streaming' || status === 'streaming_loading';
|
|
14
14
|
const isSubmitted = status === 'submitted';
|
|
15
15
|
const showLoader = status && loaderStatuses.includes(status);
|
|
16
|
-
const { containerRef
|
|
16
|
+
const { containerRef } = useSmartScroll({
|
|
17
17
|
isStreaming: isStreaming || isSubmitted,
|
|
18
18
|
messagesCount: messages.length,
|
|
19
19
|
status,
|
|
@@ -38,5 +38,5 @@ export function MessageList({ messages, messageRendererRegistry, transformOption
|
|
|
38
38
|
}
|
|
39
39
|
return null;
|
|
40
40
|
};
|
|
41
|
-
return (_jsxs("div", { ref: containerRef, className: b(null, className), "data-qa": qa, children: [hasPreviousMessages && (_jsx(IntersectionContainer, { onIntersect: onLoadPreviousMessages, className: b('load-trigger'), children: _jsx(Loader, { view: "loading" }) })), _jsx("div", { className: b('messages'), "data-qa": qa, children: messages.map(renderMessage) }), showLoader && _jsx(Loader, { className: b('loader') }), status === 'error' && (_jsx(ErrorAlert, { className: b('error-alert'), onRetry: onRetry, errorMessage: errorMessage }))
|
|
41
|
+
return (_jsxs("div", { ref: containerRef, className: b(null, className), "data-qa": qa, children: [hasPreviousMessages && (_jsx(IntersectionContainer, { onIntersect: onLoadPreviousMessages, className: b('load-trigger'), children: _jsx(Loader, { view: "loading" }) })), _jsx("div", { className: b('messages'), "data-qa": qa, children: messages.map(renderMessage) }), showLoader && _jsx(Loader, { className: b('loader') }), status === 'error' && (_jsx(ErrorAlert, { className: b('error-alert'), onRetry: onRetry, errorMessage: errorMessage }))] }));
|
|
42
42
|
}
|
|
@@ -16,6 +16,7 @@ export function usePromptInput(props) {
|
|
|
16
16
|
// ChatStatus.ready → submitButtonState.enabled
|
|
17
17
|
// ChatStatus.error → submitButtonState.enabled
|
|
18
18
|
// ChatStatus.streaming → submitButtonState.cancelable
|
|
19
|
+
// ChatStatus.streaming_loading → submitButtonState.cancelable (same as streaming)
|
|
19
20
|
// ChatStatus.submitted → submitButtonState.loading
|
|
20
21
|
let submitButtonState = 'disabled';
|
|
21
22
|
// disabled by props or empty value and status is ready
|
|
@@ -32,6 +33,7 @@ export function usePromptInput(props) {
|
|
|
32
33
|
submitButtonState = 'enabled';
|
|
33
34
|
break;
|
|
34
35
|
case 'streaming':
|
|
36
|
+
case 'streaming_loading':
|
|
35
37
|
submitButtonState = onCancel ? 'cancelable' : 'enabled';
|
|
36
38
|
break;
|
|
37
39
|
case 'submitted':
|
|
@@ -72,6 +72,12 @@ export declare const FullStreamingExample: Story;
|
|
|
72
72
|
* and preview when the chat is empty (no messages)
|
|
73
73
|
*/
|
|
74
74
|
export declare const HiddenTitleOnEmpty: Story;
|
|
75
|
+
/**
|
|
76
|
+
* Embedded in page with tall content and streaming.
|
|
77
|
+
* Chat is placed in a sidebar; main content has large height and is scrollable.
|
|
78
|
+
* When long text streams in the chat, only the chat panel scrolls — the main page does not jump.
|
|
79
|
+
*/
|
|
80
|
+
export declare const EmbeddedInPageWithStreaming: Story;
|
|
75
81
|
/**
|
|
76
82
|
* With Additional Actions
|
|
77
83
|
* Demonstrates passing additional actions to Header and custom actions to BaseMessage
|
|
@@ -359,9 +359,16 @@ export const WithStreaming = {
|
|
|
359
359
|
actions: createMessageActions(assistantMessageId, 'assistant'),
|
|
360
360
|
},
|
|
361
361
|
]);
|
|
362
|
-
// Simulate word-by-word streaming
|
|
362
|
+
// Simulate word-by-word streaming with a streaming_loading pause in the middle
|
|
363
363
|
const words = fullResponse.split(' ');
|
|
364
|
+
const pauseIndex = Math.floor(words.length / 2);
|
|
364
365
|
for (let i = 0; i < words.length; i++) {
|
|
366
|
+
// Pause streaming at the midpoint: switch to streaming_loading for 5 seconds
|
|
367
|
+
if (i === pauseIndex) {
|
|
368
|
+
setStatus('streaming_loading');
|
|
369
|
+
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
370
|
+
setStatus('streaming');
|
|
371
|
+
}
|
|
365
372
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
366
373
|
const currentText = words.slice(0, i + 1).join(' ');
|
|
367
374
|
setMessages((prev) => prev.map((msg) => msg.id === assistantMessageId
|
|
@@ -747,7 +754,10 @@ export const FullStreamingExample = {
|
|
|
747
754
|
const [controller, setController] = useState(null);
|
|
748
755
|
const isProcessingRef = React.useRef(false);
|
|
749
756
|
const handleSendMessage = async (data) => {
|
|
750
|
-
if (isProcessingRef.current ||
|
|
757
|
+
if (isProcessingRef.current ||
|
|
758
|
+
status === 'streaming' ||
|
|
759
|
+
status === 'streaming_loading' ||
|
|
760
|
+
status === 'submitted') {
|
|
751
761
|
return;
|
|
752
762
|
}
|
|
753
763
|
isProcessingRef.current = true;
|
|
@@ -949,6 +959,96 @@ const customAssistantActions = [
|
|
|
949
959
|
const addCustomActionsToMessages = (messages) => {
|
|
950
960
|
return messages.map((msg) => (Object.assign(Object.assign({}, msg), { actions: msg.role === 'user' ? customUserActions : customAssistantActions })));
|
|
951
961
|
};
|
|
962
|
+
/**
|
|
963
|
+
* Embedded in page with tall content and streaming.
|
|
964
|
+
* Chat is placed in a sidebar; main content has large height and is scrollable.
|
|
965
|
+
* When long text streams in the chat, only the chat panel scrolls — the main page does not jump.
|
|
966
|
+
*/
|
|
967
|
+
export const EmbeddedInPageWithStreaming = {
|
|
968
|
+
args: {
|
|
969
|
+
messages: [],
|
|
970
|
+
showActionsOnHover: true,
|
|
971
|
+
welcomeConfig: {
|
|
972
|
+
title: 'Sidebar Chat',
|
|
973
|
+
description: 'Send a message to see streaming. Only the chat area scrolls.',
|
|
974
|
+
suggestionTitle: 'Try:',
|
|
975
|
+
suggestions: [{ id: '1', title: 'Stream a long response' }],
|
|
976
|
+
},
|
|
977
|
+
},
|
|
978
|
+
render: (args) => {
|
|
979
|
+
const [messages, setMessages] = useState([]);
|
|
980
|
+
const [status, setStatus] = useState('ready');
|
|
981
|
+
const handleSendMessage = async (data) => {
|
|
982
|
+
const userMessageId = Date.now().toString();
|
|
983
|
+
const userMessage = {
|
|
984
|
+
id: userMessageId,
|
|
985
|
+
role: 'user',
|
|
986
|
+
content: data.content,
|
|
987
|
+
actions: createMessageActions(userMessageId, 'user'),
|
|
988
|
+
};
|
|
989
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
990
|
+
setStatus('streaming');
|
|
991
|
+
const assistantMessageId = (Date.now() + 1).toString();
|
|
992
|
+
const fullResponse = [
|
|
993
|
+
'This is a simulated long streaming response. When the chat is embedded in an application,',
|
|
994
|
+
'the scroll logic must only scroll the chat container, not the main page.',
|
|
995
|
+
'Using containerRef.scrollTo({ top: scrollHeight, behavior }) ensures that only the message list',
|
|
996
|
+
'container scrolls to the bottom, so the main content stays in place and there is no jerking.',
|
|
997
|
+
'',
|
|
998
|
+
'**Before the fix**, scrollIntoView on the end element could scroll all scrollable ancestors',
|
|
999
|
+
'(including the document), which caused the whole page to jump during streaming.',
|
|
1000
|
+
'',
|
|
1001
|
+
'**After the fix**, we explicitly scroll only the container element that holds the messages.',
|
|
1002
|
+
'The main content on the left can stay scrolled at any position while the chat streams.',
|
|
1003
|
+
'',
|
|
1004
|
+
'You can scroll the left column up or down and then send a message — only the chat panel',
|
|
1005
|
+
'will scroll to show the new content.',
|
|
1006
|
+
].join('\n\n');
|
|
1007
|
+
setMessages((prev) => [
|
|
1008
|
+
...prev,
|
|
1009
|
+
{
|
|
1010
|
+
id: assistantMessageId,
|
|
1011
|
+
role: 'assistant',
|
|
1012
|
+
content: '',
|
|
1013
|
+
actions: createMessageActions(assistantMessageId, 'assistant'),
|
|
1014
|
+
},
|
|
1015
|
+
]);
|
|
1016
|
+
const words = fullResponse.split(' ');
|
|
1017
|
+
for (let i = 0; i < words.length; i++) {
|
|
1018
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
1019
|
+
const currentText = words.slice(0, i + 1).join(' ');
|
|
1020
|
+
setMessages((prev) => prev.map((msg) => msg.id === assistantMessageId ? Object.assign(Object.assign({}, msg), { content: currentText }) : msg));
|
|
1021
|
+
}
|
|
1022
|
+
setStatus('ready');
|
|
1023
|
+
};
|
|
1024
|
+
const handleCancel = async () => {
|
|
1025
|
+
setStatus('ready');
|
|
1026
|
+
};
|
|
1027
|
+
const tallContent = Array.from({ length: 30 }, (_, i) => (_jsxs("p", { children: ["Main application content paragraph ", i + 1, ". This area has large height so the page is scrollable. When the chat on the right streams a long response, only the chat container should scroll \u2014 this column must not move."] }, i)));
|
|
1028
|
+
return (_jsxs("div", { style: {
|
|
1029
|
+
display: 'flex',
|
|
1030
|
+
minHeight: '100vh',
|
|
1031
|
+
}, children: [_jsxs("div", { style: {
|
|
1032
|
+
flex: 1,
|
|
1033
|
+
minWidth: 0,
|
|
1034
|
+
padding: 24,
|
|
1035
|
+
borderRight: '1px solid var(--g-color-line-generic, #e5e5e5)',
|
|
1036
|
+
}, children: [_jsx("h2", { style: { marginTop: 0 }, children: "Main content (tall, scrollable)" }), tallContent] }), _jsx("div", { style: {
|
|
1037
|
+
width: 420,
|
|
1038
|
+
flexShrink: 0,
|
|
1039
|
+
position: 'sticky',
|
|
1040
|
+
top: 0,
|
|
1041
|
+
alignSelf: 'flex-start',
|
|
1042
|
+
height: '100vh',
|
|
1043
|
+
display: 'flex',
|
|
1044
|
+
flexDirection: 'column',
|
|
1045
|
+
minHeight: 0,
|
|
1046
|
+
}, children: _jsx(ChatContainer, Object.assign({}, args, { messages: messages, onSendMessage: handleSendMessage, onCancel: handleCancel, status: status })) })] }));
|
|
1047
|
+
},
|
|
1048
|
+
decorators: [
|
|
1049
|
+
(Story) => (_jsx("div", { style: { minHeight: '100vh', overflow: 'visible' }, children: _jsx(Story, {}) })),
|
|
1050
|
+
],
|
|
1051
|
+
};
|
|
952
1052
|
/**
|
|
953
1053
|
* With Additional Actions
|
|
954
1054
|
* Demonstrates passing additional actions to Header and custom actions to BaseMessage
|
|
@@ -2,7 +2,6 @@ import { type RefObject } from 'react';
|
|
|
2
2
|
import type { ChatStatus } from '../types';
|
|
3
3
|
export interface UseSmartScrollReturn<T extends HTMLElement> {
|
|
4
4
|
containerRef: RefObject<T>;
|
|
5
|
-
endRef: RefObject<T>;
|
|
6
5
|
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
|
7
6
|
}
|
|
8
7
|
export declare function useSmartScroll<T extends HTMLElement>({ isStreaming, messagesCount, status, }: {
|
|
@@ -2,13 +2,15 @@ import { useCallback, useEffect, useRef } from 'react';
|
|
|
2
2
|
const SCROLL_THRESHOLD = 10;
|
|
3
3
|
export function useSmartScroll({ isStreaming = false, messagesCount, status, }) {
|
|
4
4
|
const containerRef = useRef(null);
|
|
5
|
-
const endRef = useRef(null);
|
|
6
5
|
const userScrolledUpRef = useRef(false);
|
|
7
6
|
const scrollToBottom = useCallback((behavior = 'instant') => {
|
|
8
7
|
if (!userScrolledUpRef.current) {
|
|
9
|
-
const
|
|
10
|
-
if (
|
|
11
|
-
|
|
8
|
+
const container = containerRef.current;
|
|
9
|
+
if (container) {
|
|
10
|
+
container.scrollTo({
|
|
11
|
+
top: container.scrollHeight,
|
|
12
|
+
behavior,
|
|
13
|
+
});
|
|
12
14
|
}
|
|
13
15
|
}
|
|
14
16
|
}, []);
|
|
@@ -66,7 +68,6 @@ export function useSmartScroll({ isStreaming = false, messagesCount, status, })
|
|
|
66
68
|
}, [messagesCount]);
|
|
67
69
|
return {
|
|
68
70
|
containerRef,
|
|
69
|
-
endRef,
|
|
70
71
|
scrollToBottom,
|
|
71
72
|
};
|
|
72
73
|
}
|
package/dist/types/chat.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type ChatType = {
|
|
|
5
5
|
lastMessage?: string;
|
|
6
6
|
metadata?: Record<string, unknown>;
|
|
7
7
|
};
|
|
8
|
-
export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error';
|
|
8
|
+
export type ChatStatus = 'submitted' | 'streaming' | 'streaming_loading' | 'ready' | 'error';
|
|
9
9
|
/**
|
|
10
10
|
* List item type for chat history that can be either a chat or a date header
|
|
11
11
|
*/
|