@gravity-ui/aikit 0.2.2 → 0.3.1
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/README.md +4 -0
- package/dist/components/atoms/ContextItem/index.d.ts +1 -1
- package/dist/components/atoms/ContextItem/index.js +1 -1
- package/dist/components/atoms/IntersectionContainer/IntersectionContainer.d.ts +10 -0
- package/dist/components/atoms/IntersectionContainer/IntersectionContainer.js +14 -0
- package/dist/components/atoms/IntersectionContainer/IntersectionContainer.scss +7 -0
- package/dist/components/atoms/IntersectionContainer/index.d.ts +1 -0
- package/dist/components/atoms/IntersectionContainer/index.js +1 -0
- package/dist/components/atoms/Shimmer/Shimmer.scss +6 -6
- package/dist/components/atoms/index.d.ts +1 -0
- package/dist/components/atoms/index.js +1 -0
- package/dist/components/molecules/PromptInputHeader/PromptInputHeader.d.ts +1 -1
- package/dist/components/organisms/AssistantMessage/defaultMessageTypeRegistry.js +1 -1
- package/dist/components/organisms/Header/Header.js +3 -2
- package/dist/components/organisms/Header/types.d.ts +1 -0
- package/dist/components/organisms/Header/useHeader.d.ts +1 -0
- package/dist/components/organisms/Header/useHeader.js +2 -1
- package/dist/components/organisms/MessageList/MessageList.d.ts +5 -1
- package/dist/components/organisms/MessageList/MessageList.js +14 -5
- package/dist/components/organisms/MessageList/MessageList.scss +6 -1
- package/dist/components/organisms/PromptInput/PromptInput.d.ts +2 -0
- package/dist/components/organisms/PromptInput/PromptInput.js +2 -1
- package/dist/components/organisms/PromptInput/usePromptInput.d.ts +0 -2
- package/dist/components/organisms/PromptInput/usePromptInput.js +10 -16
- package/dist/components/organisms/ThinkingMessage/index.d.ts +2 -9
- package/dist/components/organisms/ThinkingMessage/useThinkingMessage.d.ts +2 -12
- package/dist/components/organisms/ThinkingMessage/useThinkingMessage.js +1 -2
- package/dist/components/pages/ChatContainer/ChatContainer.js +5 -9
- package/dist/components/pages/ChatContainer/index.d.ts +1 -1
- package/dist/components/pages/ChatContainer/types.d.ts +9 -0
- package/dist/components/templates/ChatContent/ChatContent.js +1 -1
- package/dist/components/templates/ChatContent/ChatContent.scss +4 -1
- package/dist/components/templates/EmptyContainer/EmptyContainer.d.ts +2 -0
- package/dist/components/templates/EmptyContainer/EmptyContainer.js +2 -2
- package/dist/components/templates/History/History.d.ts +2 -0
- package/dist/components/templates/History/History.js +2 -2
- package/dist/components/templates/History/History.scss +8 -0
- package/dist/components/templates/History/HistoryList.d.ts +2 -0
- package/dist/components/templates/History/HistoryList.js +3 -2
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useScrollPreservation.d.ts +9 -0
- package/dist/hooks/useScrollPreservation.js +28 -0
- package/dist/themes/dark.css +3 -0
- package/dist/types/messages.d.ts +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @gravity-ui/aikit
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@gravity-ui/aikit) [](https://github.com/gravity-ui/aikit/actions/workflows/ci.yml?query=branch:main) [](https://preview.gravity-ui.com/aikit/?path=/docs/pages-chatcontainer--docs)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
3
7
|
UI component library for AI chats built with Atomic Design principles.
|
|
4
8
|
|
|
5
9
|
## Description
|
|
@@ -2,7 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { QAProps } from '@gravity-ui/uikit';
|
|
3
3
|
type ContextItemProps = QAProps & {
|
|
4
4
|
content: React.ReactNode;
|
|
5
|
-
onClick
|
|
5
|
+
onClick?: () => void;
|
|
6
6
|
className?: string;
|
|
7
7
|
};
|
|
8
8
|
export declare const ContextItem: (props: ContextItemProps) => import("react/jsx-runtime").JSX.Element;
|
|
@@ -4,5 +4,5 @@ import { block } from '../../../utils/cn';
|
|
|
4
4
|
const b = block('context-item');
|
|
5
5
|
export const ContextItem = (props) => {
|
|
6
6
|
const { content, onClick, className, qa } = props;
|
|
7
|
-
return (_jsx(Label, { size: "s", theme: "clear", onCloseClick: onClick, type:
|
|
7
|
+
return (_jsx(Label, Object.assign({ size: "s", theme: "clear" }, (onClick && { onCloseClick: onClick, type: 'close' }), { className: b(null, className), "data-qa": qa, children: content })));
|
|
8
8
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import './IntersectionContainer.scss';
|
|
3
|
+
interface IIntersectionContainerProps {
|
|
4
|
+
children?: React.ReactNode;
|
|
5
|
+
onIntersect?: () => void;
|
|
6
|
+
options?: IntersectionObserverInit;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const IntersectionContainer: ({ children, onIntersect, options, className, }: IIntersectionContainerProps) => string | number | boolean | Iterable<React.ReactNode> | import("react/jsx-runtime").JSX.Element | null | undefined;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { useIntersection } from '@gravity-ui/uikit';
|
|
4
|
+
import { block } from '../../../utils/cn';
|
|
5
|
+
import './IntersectionContainer.scss';
|
|
6
|
+
const b = block('intersection-container');
|
|
7
|
+
export const IntersectionContainer = ({ children, onIntersect, options, className, }) => {
|
|
8
|
+
const [ref, setRef] = React.useState(null);
|
|
9
|
+
useIntersection({ element: ref, onIntersect, options });
|
|
10
|
+
if (onIntersect) {
|
|
11
|
+
return (_jsx("div", { className: b('container', className), ref: setRef, children: children }));
|
|
12
|
+
}
|
|
13
|
+
return children;
|
|
14
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './IntersectionContainer';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './IntersectionContainer';
|
|
@@ -7,7 +7,8 @@ $block: '.#{variables.$ns}shimmer';
|
|
|
7
7
|
position: relative;
|
|
8
8
|
display: inline-block;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
color: var(--g-aikit-text-primary, inherit);
|
|
11
|
+
mask-image: linear-gradient(
|
|
11
12
|
90deg,
|
|
12
13
|
var(--g-aikit-shimmer-color-from) 0%,
|
|
13
14
|
var(--g-aikit-shimmer-color-from) 40%,
|
|
@@ -15,18 +16,17 @@ $block: '.#{variables.$ns}shimmer';
|
|
|
15
16
|
var(--g-aikit-shimmer-color-from) 60%,
|
|
16
17
|
var(--g-aikit-shimmer-color-from) 100%
|
|
17
18
|
);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-webkit-text-fill-color: transparent;
|
|
19
|
+
mask-size: var(--g-aikit-shimmer-gradient-size) 100%;
|
|
20
|
+
mask-clip: text;
|
|
21
21
|
animation: shimmer var(--g-aikit-shimmer-duration) infinite linear;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
@keyframes shimmer {
|
|
26
26
|
0% {
|
|
27
|
-
|
|
27
|
+
mask-position: 200% 50%;
|
|
28
28
|
}
|
|
29
29
|
100% {
|
|
30
|
-
|
|
30
|
+
mask-position: 0% 50%;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -6,6 +6,7 @@ export * from './ContextItem';
|
|
|
6
6
|
export * from './DiffStat';
|
|
7
7
|
export * from './Disclaimer';
|
|
8
8
|
export * from './InlineCitation';
|
|
9
|
+
export * from './IntersectionContainer';
|
|
9
10
|
export * from './Loader';
|
|
10
11
|
export * from './MarkdownRenderer';
|
|
11
12
|
export * from './MessageBalloon';
|
|
@@ -7,6 +7,7 @@ export * from './ContextItem';
|
|
|
7
7
|
export * from './DiffStat';
|
|
8
8
|
export * from './Disclaimer';
|
|
9
9
|
export * from './InlineCitation';
|
|
10
|
+
export * from './IntersectionContainer';
|
|
10
11
|
export * from './Loader';
|
|
11
12
|
export * from './MarkdownRenderer';
|
|
12
13
|
export * from './MessageBalloon';
|
|
@@ -10,7 +10,7 @@ export type ContextItemConfig = {
|
|
|
10
10
|
/** Content to display in the context item */
|
|
11
11
|
content: ReactNode;
|
|
12
12
|
/** Callback when context item is removed */
|
|
13
|
-
onRemove
|
|
13
|
+
onRemove?: () => void;
|
|
14
14
|
};
|
|
15
15
|
/**
|
|
16
16
|
* Props for the PromptInputHeader component
|
|
@@ -12,7 +12,7 @@ export function createDefaultMessageRegistry(transformOptions) {
|
|
|
12
12
|
component: ({ part }) => _jsx(ToolMessage, Object.assign({}, part.data)),
|
|
13
13
|
});
|
|
14
14
|
registerMessageRenderer(registry, 'thinking', {
|
|
15
|
-
component: ({ part }) => _jsx(ThinkingMessage, {
|
|
15
|
+
component: ({ part }) => _jsx(ThinkingMessage, Object.assign({}, part.data)),
|
|
16
16
|
});
|
|
17
17
|
return registry;
|
|
18
18
|
}
|
|
@@ -29,7 +29,7 @@ const FOLDING_ICONS = {
|
|
|
29
29
|
* @returns Header component
|
|
30
30
|
*/
|
|
31
31
|
export function Header(props) {
|
|
32
|
-
const { title, preview, icon, baseActions, additionalActions, titlePosition, className, historyButtonRef, } = useHeader(props);
|
|
32
|
+
const { title, preview, icon, baseActions, additionalActions, titlePosition, withIcon, className, historyButtonRef, } = useHeader(props);
|
|
33
33
|
// Render base action
|
|
34
34
|
const renderBaseAction = (action) => {
|
|
35
35
|
let IconComponent = ACTION_ICONS[action.id];
|
|
@@ -61,5 +61,6 @@ export function Header(props) {
|
|
|
61
61
|
};
|
|
62
62
|
// Determine class for title positioning
|
|
63
63
|
const titlePositionClass = b('title-container', { position: titlePosition });
|
|
64
|
-
|
|
64
|
+
const iconElement = icon ? (_jsx("div", { className: b('icon'), children: icon })) : (_jsx(Icon, { data: Sparkles, size: 16 }));
|
|
65
|
+
return (_jsxs("div", { className: b('', className), children: [withIcon && iconElement, _jsxs("div", { className: titlePositionClass, children: [title && (_jsx(Text, { as: "div", variant: "subheader-2", className: b('title'), children: title })), preview && _jsx("div", { className: b('preview'), children: preview })] }), _jsxs(ButtonGroup, { children: [additionalActions.map((action, index) => renderAdditionalAction(action, index)), baseActions.map((action) => renderBaseAction(action))] })] }));
|
|
65
66
|
}
|
|
@@ -16,6 +16,7 @@ export declare function useHeader(props: HeaderProps): {
|
|
|
16
16
|
baseActions: ActionItem[];
|
|
17
17
|
additionalActions: ActionItem[];
|
|
18
18
|
titlePosition: 'left' | 'center';
|
|
19
|
+
withIcon: boolean;
|
|
19
20
|
className?: string;
|
|
20
21
|
historyButtonRef?: React.RefObject<HTMLElement>;
|
|
21
22
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useMemo } from 'react';
|
|
2
2
|
import { HeaderAction } from './types';
|
|
3
3
|
export function useHeader(props) {
|
|
4
|
-
const { icon, title, preview, baseActions = [], handleNewChat, handleHistoryToggle, handleClose, handleFolding, foldingState = 'opened', additionalActions = [], titlePosition = 'left', className, historyButtonRef, } = props;
|
|
4
|
+
const { icon, title, preview, baseActions = [], handleNewChat, handleHistoryToggle, handleClose, handleFolding, foldingState = 'opened', additionalActions = [], titlePosition = 'left', withIcon = true, className, historyButtonRef, } = props;
|
|
5
5
|
// Build base actions
|
|
6
6
|
const baseActionsList = useMemo(() => {
|
|
7
7
|
const actions = [];
|
|
@@ -65,6 +65,7 @@ export function useHeader(props) {
|
|
|
65
65
|
baseActions: baseActionsList,
|
|
66
66
|
additionalActions: additionalActionsList,
|
|
67
67
|
titlePosition,
|
|
68
|
+
withIcon,
|
|
68
69
|
className,
|
|
69
70
|
historyButtonRef,
|
|
70
71
|
};
|
|
@@ -17,7 +17,11 @@ export type MessageListProps<TContent extends TMessageContent = never> = {
|
|
|
17
17
|
showAvatar?: boolean;
|
|
18
18
|
userActions?: DefaultMessageAction<TUserMessage<TMessageMetadata>>[];
|
|
19
19
|
assistantActions?: DefaultMessageAction<TAssistantMessage<TContent, TMessageMetadata>>[];
|
|
20
|
+
/** Array of chat statuses that should display the loader */
|
|
21
|
+
loaderStatuses?: ChatStatus[];
|
|
20
22
|
className?: string;
|
|
21
23
|
qa?: string;
|
|
24
|
+
hasPreviousMessages?: boolean;
|
|
25
|
+
onLoadPreviousMessages?: () => void;
|
|
22
26
|
};
|
|
23
|
-
export declare function MessageList<TContent extends TMessageContent = never>({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, className, qa, status, errorMessage, onRetry, }: MessageListProps<TContent>): import("react/jsx-runtime").JSX.Element;
|
|
27
|
+
export declare function MessageList<TContent extends TMessageContent = never>({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, loaderStatuses, className, qa, status, errorMessage, onRetry, hasPreviousMessages, onLoadPreviousMessages, }: MessageListProps<TContent>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
import { useScrollPreservation, useSmartScroll } from '../../../hooks';
|
|
3
4
|
import { isAssistantMessage, isUserMessage, resolveMessageActions, } from '../../../utils';
|
|
4
5
|
import { block } from '../../../utils/cn';
|
|
6
|
+
import { IntersectionContainer } from '../../atoms/IntersectionContainer';
|
|
5
7
|
import { Loader } from '../../atoms/Loader';
|
|
6
8
|
import { AssistantMessage } from '../AssistantMessage';
|
|
7
9
|
import { UserMessage } from '../UserMessage';
|
|
8
10
|
import { ErrorAlert } from './ErrorAlert';
|
|
9
11
|
import './MessageList.scss';
|
|
10
12
|
const b = block('message-list');
|
|
11
|
-
export function MessageList({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, className, qa, status, errorMessage, onRetry, }) {
|
|
13
|
+
export function MessageList({ messages, messageRendererRegistry, transformOptions, showActionsOnHover, showTimestamp, showAvatar, userActions, assistantActions, loaderStatuses = ['submitted'], className, qa, status, errorMessage, onRetry, hasPreviousMessages = false, onLoadPreviousMessages, }) {
|
|
12
14
|
const isStreaming = status === 'streaming';
|
|
13
15
|
const messagesCount = messages.length;
|
|
14
|
-
const
|
|
16
|
+
const showLoader = status && loaderStatuses.includes(status);
|
|
17
|
+
const { containerRef, endRef, scrollToBottom } = useSmartScroll(isStreaming, messagesCount);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
scrollToBottom();
|
|
20
|
+
}, []);
|
|
21
|
+
// Preserve scroll position when older messages are loaded
|
|
22
|
+
useScrollPreservation(containerRef, messages.length);
|
|
15
23
|
const renderMessage = (message, index) => {
|
|
16
24
|
if (isUserMessage(message)) {
|
|
17
25
|
const actions = resolveMessageActions(message, userActions);
|
|
18
26
|
return (_jsx(UserMessage, { content: message.content, actions: actions, timestamp: message.timestamp, format: message.format, avatarUrl: message.avatarUrl, transformOptions: transformOptions, showActionsOnHover: showActionsOnHover, showTimestamp: showTimestamp, showAvatar: showAvatar }, message.id || `message-${index}`));
|
|
19
27
|
}
|
|
20
28
|
if (isAssistantMessage(message)) {
|
|
21
|
-
const
|
|
29
|
+
const isLastMessage = index === messages.length - 1;
|
|
30
|
+
const showActions = !(isLastMessage && isStreaming);
|
|
22
31
|
const actions = showActions
|
|
23
32
|
? resolveMessageActions(message, assistantActions)
|
|
24
33
|
: undefined;
|
|
@@ -26,5 +35,5 @@ export function MessageList({ messages, messageRendererRegistry, transformOption
|
|
|
26
35
|
}
|
|
27
36
|
return null;
|
|
28
37
|
};
|
|
29
|
-
return (_jsxs("div", { ref: containerRef, className: b(null, className), "data-qa": qa, children: [_jsx("div", { className: b('messages'
|
|
38
|
+
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 })), _jsx("div", { ref: endRef })] }));
|
|
30
39
|
}
|
|
@@ -9,7 +9,6 @@ $block: '.#{variables.$ns}message-list';
|
|
|
9
9
|
min-height: 0;
|
|
10
10
|
flex: 1;
|
|
11
11
|
align-self: stretch;
|
|
12
|
-
width: 100%;
|
|
13
12
|
|
|
14
13
|
&__messages {
|
|
15
14
|
display: flex;
|
|
@@ -27,4 +26,10 @@ $block: '.#{variables.$ns}message-list';
|
|
|
27
26
|
&__error-alert {
|
|
28
27
|
margin-top: var(--g-spacing-4);
|
|
29
28
|
}
|
|
29
|
+
|
|
30
|
+
&__load-trigger {
|
|
31
|
+
display: flex;
|
|
32
|
+
justify-content: center;
|
|
33
|
+
padding: var(--g-spacing-2);
|
|
34
|
+
}
|
|
30
35
|
}
|
|
@@ -11,6 +11,8 @@ export type PromptInputProps = {
|
|
|
11
11
|
onSend: (data: TSubmitData) => Promise<void>;
|
|
12
12
|
/** Callback when sending is cancelled */
|
|
13
13
|
onCancel?: () => Promise<void>;
|
|
14
|
+
/** Initial value */
|
|
15
|
+
initialValue?: string;
|
|
14
16
|
/** Disabled state */
|
|
15
17
|
disabled?: boolean;
|
|
16
18
|
/** Chat status to determine input behavior */
|
|
@@ -13,10 +13,11 @@ import './PromptInput.scss';
|
|
|
13
13
|
* @returns React component
|
|
14
14
|
*/
|
|
15
15
|
export function PromptInput(props) {
|
|
16
|
-
const { view = 'simple', onSend, onCancel, disabled = false, status = 'ready', maxLength, headerProps, bodyProps, footerProps, suggestionsProps, topPanel, bottomPanel, className, qa, } = props;
|
|
16
|
+
const { view = 'simple', onSend, onCancel, initialValue, disabled = false, status = 'ready', maxLength, headerProps, bodyProps, footerProps, suggestionsProps, topPanel, bottomPanel, className, qa, } = props;
|
|
17
17
|
const hookState = usePromptInput({
|
|
18
18
|
onSend,
|
|
19
19
|
onCancel,
|
|
20
|
+
initialValue,
|
|
20
21
|
disabled,
|
|
21
22
|
status,
|
|
22
23
|
maxLength,
|
|
@@ -8,21 +8,22 @@ import { useCallback, useState } from 'react';
|
|
|
8
8
|
export function usePromptInput(props) {
|
|
9
9
|
const { onSend, onCancel, initialValue = '', maxLength, disabled = false, status = 'ready', } = props;
|
|
10
10
|
const [value, setValue] = useState(initialValue);
|
|
11
|
-
const [isSending, setIsSending] = useState(false);
|
|
12
11
|
const [attachments, setAttachments] = useState([]);
|
|
12
|
+
const isSubmitted = status === 'submitted';
|
|
13
13
|
const trimmedValue = value.trim();
|
|
14
|
-
const canSubmit = !disabled && !
|
|
14
|
+
const canSubmit = !disabled && !isSubmitted && trimmedValue.length > 0;
|
|
15
15
|
// Map ChatStatus to submit button state
|
|
16
16
|
// ChatStatus.ready → submitButtonState.enabled
|
|
17
17
|
// ChatStatus.error → submitButtonState.enabled
|
|
18
18
|
// ChatStatus.streaming → submitButtonState.cancelable
|
|
19
19
|
// ChatStatus.submitted → submitButtonState.loading
|
|
20
20
|
let submitButtonState = 'disabled';
|
|
21
|
-
|
|
21
|
+
// disabled by props or empty value and status is ready
|
|
22
|
+
if (disabled) {
|
|
22
23
|
submitButtonState = 'disabled';
|
|
23
24
|
}
|
|
24
|
-
else if (
|
|
25
|
-
submitButtonState = '
|
|
25
|
+
else if (!trimmedValue && (status === 'ready' || status === 'error')) {
|
|
26
|
+
submitButtonState = 'disabled';
|
|
26
27
|
}
|
|
27
28
|
else {
|
|
28
29
|
switch (status) {
|
|
@@ -56,16 +57,10 @@ export function usePromptInput(props) {
|
|
|
56
57
|
if (!canSubmit) {
|
|
57
58
|
return;
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
setValue('');
|
|
64
|
-
setAttachments([]);
|
|
65
|
-
}
|
|
66
|
-
finally {
|
|
67
|
-
setIsSending(false);
|
|
68
|
-
}
|
|
60
|
+
const submitData = Object.assign({ content: trimmedValue }, (attachments.length > 0 && { attachments }));
|
|
61
|
+
onSend(submitData);
|
|
62
|
+
setValue('');
|
|
63
|
+
setAttachments([]);
|
|
69
64
|
}, [submitButtonState, canSubmit, trimmedValue, attachments, onSend, onCancel]);
|
|
70
65
|
const handleKeyDown = useCallback((event) => {
|
|
71
66
|
const isEnter = event.code === 'Enter' || event.code === 'NumpadEnter';
|
|
@@ -89,7 +84,6 @@ export function usePromptInput(props) {
|
|
|
89
84
|
return {
|
|
90
85
|
value,
|
|
91
86
|
setValue,
|
|
92
|
-
isSending,
|
|
93
87
|
canSubmit,
|
|
94
88
|
submitButtonState,
|
|
95
89
|
isInputDisabled,
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { DOMProps, QAProps } from '@gravity-ui/uikit';
|
|
2
|
-
import {
|
|
2
|
+
import type { ThinkingMessageContentData } from '../../../types/messages';
|
|
3
3
|
import './ThinkingMessage.scss';
|
|
4
4
|
/**
|
|
5
5
|
* Props for the ThinkingMessage component.
|
|
6
6
|
* Combines DOM props, QA props, and thinking message data.
|
|
7
7
|
*/
|
|
8
|
-
export type ThinkingMessageProps = DOMProps & QAProps &
|
|
9
|
-
/** Whether the thinking content should be expanded by default */
|
|
10
|
-
defaultExpanded?: boolean;
|
|
11
|
-
/** Whether to show the status indicator (loader) */
|
|
12
|
-
showStatusIndicator?: boolean;
|
|
13
|
-
/** Callback fired when the copy button is clicked */
|
|
14
|
-
onCopyClick?: () => void;
|
|
15
|
-
};
|
|
8
|
+
export type ThinkingMessageProps = DOMProps & QAProps & ThinkingMessageContentData;
|
|
16
9
|
/**
|
|
17
10
|
* ThinkingMessage component displays AI model's internal reasoning process.
|
|
18
11
|
* Shows a collapsible block with thinking content and a copy button.
|
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
export type ThinkingMessageData = Omit<ThinkingMessageContent, 'type'>;
|
|
4
|
-
export type UseThinkingMessageOptions = ThinkingMessageData & {
|
|
5
|
-
defaultExpanded?: boolean;
|
|
6
|
-
showStatusIndicator?: boolean;
|
|
7
|
-
};
|
|
8
|
-
export type ThinkingMessageAction = {
|
|
9
|
-
type: BaseMessageAction | string;
|
|
10
|
-
onClick: () => void;
|
|
11
|
-
};
|
|
12
|
-
export declare function useThinkingMessage(options: UseThinkingMessageOptions): {
|
|
1
|
+
import type { ThinkingMessageContentData } from '../../../types/messages';
|
|
2
|
+
export declare function useThinkingMessage(options: ThinkingMessageContentData): {
|
|
13
3
|
isExpanded: boolean;
|
|
14
4
|
toggleExpanded: () => void;
|
|
15
5
|
buttonTitle: string;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { useCallback, useMemo, useState } from 'react';
|
|
2
2
|
import { i18n } from './i18n';
|
|
3
3
|
export function useThinkingMessage(options) {
|
|
4
|
-
const { defaultExpanded = true, showStatusIndicator = true,
|
|
5
|
-
const { status, content } = data;
|
|
4
|
+
const { defaultExpanded = true, showStatusIndicator = true, status, content } = options;
|
|
6
5
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
|
7
6
|
const buttonTitle = useMemo(() => {
|
|
8
7
|
return i18n(`title-${status}`);
|
|
@@ -19,7 +19,7 @@ const b = block('chat-container');
|
|
|
19
19
|
*/
|
|
20
20
|
export function ChatContainer(props) {
|
|
21
21
|
var _a, _b;
|
|
22
|
-
const { chats = [], messages = [], onSendMessage, onDeleteChat, onCancel, onRetry, status = 'ready', error = null, showActionsOnHover = false, contextItems = [], transformOptions, headerProps = {}, contentProps = {}, emptyContainerProps = {}, promptInputProps = {}, historyProps = {}, welcomeConfig, i18nConfig = {}, className, headerClassName, contentClassName, footerClassName, qa, } = props;
|
|
22
|
+
const { chats = [], messages = [], onSendMessage, onDeleteChat, onCancel, onRetry, status = 'ready', error = null, showActionsOnHover = false, contextItems = [], transformOptions, messageListConfig, headerProps = {}, contentProps = {}, emptyContainerProps = {}, promptInputProps = {}, historyProps = {}, welcomeConfig, i18nConfig = {}, className, headerClassName, contentClassName, footerClassName, qa, } = props;
|
|
23
23
|
const hookState = useChatContainer(props);
|
|
24
24
|
// Collect i18n texts with overrides
|
|
25
25
|
const headerTitle = useMemo(() => {
|
|
@@ -46,21 +46,17 @@ export function ChatContainer(props) {
|
|
|
46
46
|
((_b = i18nConfig.emptyState) === null || _b === void 0 ? void 0 : _b.description) ||
|
|
47
47
|
i18n('empty-state-description'), suggestionTitle: (welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.suggestionTitle) ||
|
|
48
48
|
((_c = i18nConfig.emptyState) === null || _c === void 0 ? void 0 : _c.suggestionsTitle) ||
|
|
49
|
-
i18n('empty-state-suggestions-title'), suggestions: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.suggestions, alignment: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.alignment, wrapText: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.wrapText, showMore: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.showMore, showMoreText: (welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.showMoreText) ||
|
|
49
|
+
i18n('empty-state-suggestions-title'), suggestions: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.suggestions, alignment: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.alignment, layout: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.layout, wrapText: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.wrapText, showMore: welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.showMore, showMoreText: (welcomeConfig === null || welcomeConfig === void 0 ? void 0 : welcomeConfig.showMoreText) ||
|
|
50
50
|
((_d = i18nConfig.emptyState) === null || _d === void 0 ? void 0 : _d.showMoreText) ||
|
|
51
51
|
i18n('empty-state-show-more'), onSuggestionClick: async (clickedTitle) => {
|
|
52
52
|
await onSendMessage({ content: clickedTitle });
|
|
53
53
|
} });
|
|
54
54
|
}, [welcomeConfig, i18nConfig.emptyState, emptyContainerProps, onSendMessage]);
|
|
55
55
|
// Build props for MessageList
|
|
56
|
-
const messageListProps = useMemo(() => ({
|
|
57
|
-
|
|
58
|
-
status,
|
|
59
|
-
errorMessage: error ? { text: error.message } : undefined,
|
|
60
|
-
onRetry,
|
|
56
|
+
const messageListProps = useMemo(() => (Object.assign(Object.assign({}, messageListConfig), { messages,
|
|
57
|
+
status, errorMessage: error ? { text: error.message } : undefined, onRetry,
|
|
61
58
|
showActionsOnHover,
|
|
62
|
-
transformOptions,
|
|
63
|
-
}), [messages, status, error, onRetry, showActionsOnHover, transformOptions]);
|
|
59
|
+
transformOptions })), [messages, status, error, onRetry, showActionsOnHover, transformOptions, messageListConfig]);
|
|
64
60
|
// Build props for PromptInput
|
|
65
61
|
const finalPromptInputProps = useMemo(() => {
|
|
66
62
|
var _a, _b;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { ChatContainer } from './ChatContainer';
|
|
2
|
-
export type { ChatContainerProps, ChatContainerI18nConfig, WelcomeConfig } from './types';
|
|
2
|
+
export type { ChatContainerProps, ChatContainerI18nConfig, WelcomeConfig, MessageListConfig, } from './types';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OptionsType } from '@diplodoc/transform/lib/typings';
|
|
2
|
+
import { MessageListProps } from 'src/components/organisms/MessageList';
|
|
2
3
|
import type { ChatStatus, ChatType, TChatMessage, TSubmitData } from '../../../types';
|
|
3
4
|
import type { ContextItemConfig } from '../../molecules/PromptInputHeader';
|
|
4
5
|
import type { SuggestionsItem } from '../../molecules/Suggestions';
|
|
@@ -71,6 +72,8 @@ export interface WelcomeConfig {
|
|
|
71
72
|
suggestions?: SuggestionsItem[];
|
|
72
73
|
/** Alignment configuration for image, title, and description */
|
|
73
74
|
alignment?: AlignmentConfig;
|
|
75
|
+
/** Layout orientation for suggestions: 'grid' for horizontal, 'list' for vertical */
|
|
76
|
+
layout?: 'grid' | 'list';
|
|
74
77
|
/** Enable text wrapping inside suggestion buttons instead of ellipsis */
|
|
75
78
|
wrapText?: boolean;
|
|
76
79
|
/** Show more suggestions callback */
|
|
@@ -78,6 +81,10 @@ export interface WelcomeConfig {
|
|
|
78
81
|
/** Show more button text */
|
|
79
82
|
showMoreText?: string;
|
|
80
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* MessageList configuration
|
|
86
|
+
*/
|
|
87
|
+
export type MessageListConfig = Omit<MessageListProps, 'messages' | 'status' | 'errorMessage' | 'onRetry' | 'showActionsOnHover' | 'transformOptions'>;
|
|
81
88
|
/**
|
|
82
89
|
* Props for ChatContainer component
|
|
83
90
|
*/
|
|
@@ -114,6 +121,8 @@ export interface ChatContainerProps {
|
|
|
114
121
|
contextItems?: ContextItemConfig[];
|
|
115
122
|
/** Transform options for markdown rendering */
|
|
116
123
|
transformOptions?: OptionsType;
|
|
124
|
+
/** MessageList configuration for actions and loader behavior */
|
|
125
|
+
messageListConfig?: MessageListConfig;
|
|
117
126
|
/** Props override for Header component */
|
|
118
127
|
headerProps?: Partial<Omit<HeaderProps, 'handleNewChat' | 'handleHistoryToggle' | 'handleClose'>>;
|
|
119
128
|
/** Props override for ChatContent component */
|
|
@@ -15,5 +15,5 @@ export function ChatContent(props) {
|
|
|
15
15
|
const isEmptyView = view === 'empty';
|
|
16
16
|
return (_jsx("div", { className: b(null, className), "data-qa": qa, children: isEmptyView
|
|
17
17
|
? emptyContainerProps && _jsx(EmptyContainer, Object.assign({}, emptyContainerProps))
|
|
18
|
-
: messageListProps && (_jsx("div", { className: b('message-list-container'), children: _jsx(MessageList, Object.assign({}, messageListProps)) })) }));
|
|
18
|
+
: messageListProps && (_jsx("div", { className: b('message-list-container'), children: _jsx(MessageList, Object.assign({}, messageListProps, { className: b('message-list', messageListProps.className) })) })) }));
|
|
19
19
|
}
|
|
@@ -16,7 +16,6 @@ $block: '.#{variables.$ns}chat-content';
|
|
|
16
16
|
|
|
17
17
|
&__message-list-container {
|
|
18
18
|
display: flex;
|
|
19
|
-
padding: var(--g-spacing-2) var(--g-spacing-2) 0 var(--g-spacing-2);
|
|
20
19
|
flex-direction: column;
|
|
21
20
|
align-items: flex-start;
|
|
22
21
|
gap: var(--g-spacing-4);
|
|
@@ -24,4 +23,8 @@ $block: '.#{variables.$ns}chat-content';
|
|
|
24
23
|
flex: 1;
|
|
25
24
|
min-height: 0;
|
|
26
25
|
}
|
|
26
|
+
|
|
27
|
+
&__message-list {
|
|
28
|
+
padding: var(--g-spacing-2) var(--g-spacing-2) 0 var(--g-spacing-2);
|
|
29
|
+
}
|
|
27
30
|
}
|
|
@@ -38,6 +38,8 @@ export interface EmptyContainerProps {
|
|
|
38
38
|
onSuggestionClick?: (content: string, id?: string) => void;
|
|
39
39
|
/** Alignment configuration for image, title, and description */
|
|
40
40
|
alignment?: AlignmentConfig;
|
|
41
|
+
/** Layout orientation for suggestions: 'grid' for horizontal, 'list' for vertical */
|
|
42
|
+
layout?: 'grid' | 'list';
|
|
41
43
|
/** Enable text wrapping inside suggestion buttons instead of ellipsis */
|
|
42
44
|
wrapText?: boolean;
|
|
43
45
|
/** Callback for showing more suggestions */
|
|
@@ -14,7 +14,7 @@ const b = block('empty-container');
|
|
|
14
14
|
* @returns React component
|
|
15
15
|
*/
|
|
16
16
|
export function EmptyContainer(props) {
|
|
17
|
-
const { image, title, description, suggestionTitle, suggestions = [], onSuggestionClick, alignment, wrapText = false, showMore, showMoreText, className, qa, } = props;
|
|
17
|
+
const { image, title, description, suggestionTitle, suggestions = [], onSuggestionClick, alignment, layout = 'grid', wrapText = false, showMore, showMoreText, className, qa, } = props;
|
|
18
18
|
const hasContent = title || description || (suggestions && suggestions.length > 0);
|
|
19
19
|
// Define alignment for each element
|
|
20
20
|
const imageAlignment = (alignment === null || alignment === void 0 ? void 0 : alignment.image) || 'left';
|
|
@@ -22,5 +22,5 @@ export function EmptyContainer(props) {
|
|
|
22
22
|
const descriptionAlignment = (alignment === null || alignment === void 0 ? void 0 : alignment.description) || 'left';
|
|
23
23
|
// Define text for "Show more" button with localization support
|
|
24
24
|
const showMoreButtonText = showMoreText || i18n('show-more-button');
|
|
25
|
-
return (_jsx("div", { className: b(null, className), "data-qa": qa, children: _jsx("div", { className: b('content'), children: hasContent && (_jsxs(_Fragment, { children: [_jsxs("div", { className: b('welcome-section'), children: [image && (_jsx("div", { className: b('image-container', { align: imageAlignment }), children: image })), _jsxs("div", { className: b('text-container'), children: [title && (_jsx(Text, { variant: "header-2", className: b('title', { align: titleAlignment }), children: title })), description && (_jsx(Text, { variant: "body-2", color: "complementary", className: b('description', { align: descriptionAlignment }), children: description }))] })] }), suggestions && suggestions.length > 0 && onSuggestionClick && (_jsxs("div", { className: b('suggestions-section'), children: [suggestionTitle && (_jsx("div", { className: b('suggestions-title'), children: _jsx(Text, { variant: "subheader-3", color: "primary", children: suggestionTitle }) })), _jsx("div", { children: _jsx(Suggestions, { items: suggestions, onClick: onSuggestionClick, layout:
|
|
25
|
+
return (_jsx("div", { className: b(null, className), "data-qa": qa, children: _jsx("div", { className: b('content'), children: hasContent && (_jsxs(_Fragment, { children: [_jsxs("div", { className: b('welcome-section'), children: [image && (_jsx("div", { className: b('image-container', { align: imageAlignment }), children: image })), _jsxs("div", { className: b('text-container'), children: [title && (_jsx(Text, { variant: "header-2", className: b('title', { align: titleAlignment }), children: title })), description && (_jsx(Text, { variant: "body-2", color: "complementary", className: b('description', { align: descriptionAlignment }), children: description }))] })] }), suggestions && suggestions.length > 0 && onSuggestionClick && (_jsxs("div", { className: b('suggestions-section'), children: [suggestionTitle && (_jsx("div", { className: b('suggestions-title'), children: _jsx(Text, { variant: "subheader-3", color: "primary", children: suggestionTitle }) })), _jsx("div", { children: _jsx(Suggestions, { items: suggestions, onClick: onSuggestionClick, layout: layout, wrapText: wrapText }) }), showMore && (_jsx("div", { className: b('show-more'), children: _jsxs(Button, { view: "flat-secondary", size: "l", onClick: showMore, className: b('show-more-button'), children: [_jsx(Button.Icon, { children: _jsx(ArrowRotateRight, {}) }), showMoreButtonText] }) }))] }))] })) }) }));
|
|
26
26
|
}
|
|
@@ -30,6 +30,8 @@ export interface HistoryProps extends QAProps, DOMProps {
|
|
|
30
30
|
className?: string;
|
|
31
31
|
/** Custom filter function for search */
|
|
32
32
|
filterFunction?: ChatFilterFunction;
|
|
33
|
+
/** Loading state */
|
|
34
|
+
loading?: boolean;
|
|
33
35
|
/** Control popup open state */
|
|
34
36
|
open?: boolean;
|
|
35
37
|
/** Callback when popup open state changes */
|
|
@@ -8,9 +8,9 @@ import { HistoryList } from './HistoryList';
|
|
|
8
8
|
* @returns React component
|
|
9
9
|
*/
|
|
10
10
|
export function History(props) {
|
|
11
|
-
const { chats, selectedChat, onSelectChat, onDeleteChat, onLoadMore, hasMore = false, searchable = true, groupBy = 'date', showActions = true, emptyPlaceholder, className, qa, style, filterFunction, open = false, onOpenChange, anchorElement, } = props;
|
|
11
|
+
const { chats, selectedChat, onSelectChat, onDeleteChat, onLoadMore, hasMore = false, searchable = true, groupBy = 'date', showActions = true, emptyPlaceholder, className, qa, style, filterFunction, loading = false, open = false, onOpenChange, anchorElement, } = props;
|
|
12
12
|
const handleChatClick = () => {
|
|
13
13
|
onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(false);
|
|
14
14
|
};
|
|
15
|
-
return (_jsx(Popup, { anchorElement: anchorElement, placement: "bottom-end", open: open, onOpenChange: onOpenChange, children: _jsx(HistoryList, { chats: chats, selectedChat: selectedChat, onSelectChat: onSelectChat, onDeleteChat: onDeleteChat, onLoadMore: onLoadMore, hasMore: hasMore, searchable: searchable, groupBy: groupBy, showActions: showActions, emptyPlaceholder: emptyPlaceholder, className: className, qa: qa, style: style, filterFunction: filterFunction, onChatClick: handleChatClick }) }));
|
|
15
|
+
return (_jsx(Popup, { anchorElement: anchorElement, placement: "bottom-end", open: open, onOpenChange: onOpenChange, children: _jsx(HistoryList, { chats: chats, selectedChat: selectedChat, onSelectChat: onSelectChat, onDeleteChat: onDeleteChat, onLoadMore: onLoadMore, hasMore: hasMore, searchable: searchable, groupBy: groupBy, showActions: showActions, emptyPlaceholder: emptyPlaceholder, className: className, qa: qa, style: style, filterFunction: filterFunction, loading: loading, onChatClick: handleChatClick }) }));
|
|
16
16
|
}
|
|
@@ -31,6 +31,8 @@ export interface HistoryListProps extends QAProps, DOMProps {
|
|
|
31
31
|
className?: string;
|
|
32
32
|
/** Custom filter function for search */
|
|
33
33
|
filterFunction?: ChatFilterFunction;
|
|
34
|
+
/** Loading state */
|
|
35
|
+
loading?: boolean;
|
|
34
36
|
/** Callback when chat is clicked (for closing popup in parent) */
|
|
35
37
|
onChatClick?: (chat: ChatType) => void;
|
|
36
38
|
}
|
|
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
|
|
|
3
3
|
import { Button, List } from '@gravity-ui/uikit';
|
|
4
4
|
import { defaultChatFilter, groupChatsByDate } from '../../../utils/chatUtils';
|
|
5
5
|
import { block } from '../../../utils/cn';
|
|
6
|
+
import { Loader } from '../../atoms/Loader';
|
|
6
7
|
import { ChatItem } from './ChatItem';
|
|
7
8
|
import { DateHeaderItem } from './DateHeaderItem';
|
|
8
9
|
import { i18n } from './i18n';
|
|
@@ -15,7 +16,7 @@ const b = block('history');
|
|
|
15
16
|
* @returns React component
|
|
16
17
|
*/
|
|
17
18
|
export function HistoryList(props) {
|
|
18
|
-
const { chats, selectedChat, onSelectChat, onDeleteChat, onLoadMore, hasMore = false, searchable = true, groupBy = 'date', showActions = true, emptyPlaceholder, className, qa, style, filterFunction = defaultChatFilter, onChatClick, } = props;
|
|
19
|
+
const { chats, selectedChat, onSelectChat, onDeleteChat, onLoadMore, hasMore = false, searchable = true, groupBy = 'date', showActions = true, emptyPlaceholder, className, qa, style, filterFunction = defaultChatFilter, loading = false, onChatClick, } = props;
|
|
19
20
|
// Group chats if needed
|
|
20
21
|
const groupedChats = useMemo(() => {
|
|
21
22
|
if (groupBy === 'none') {
|
|
@@ -83,5 +84,5 @@ export function HistoryList(props) {
|
|
|
83
84
|
return (_jsx(ChatItem, { chat: item, showActions: showActions, onChatClick: handleChatClick, onDeleteClick: onDeleteChat ? handleDeleteClick : undefined }, item.id));
|
|
84
85
|
};
|
|
85
86
|
const emptyState = emptyPlaceholder || _jsx("div", { className: b('empty'), children: i18n('empty-state') });
|
|
86
|
-
return (_jsxs("div", { className: b('container', className), "data-qa": qa, style: style, children: [_jsx("div", { className: b('list-wrapper'), children: _jsx(List, { items: listItems, renderItem: renderItem, virtualized: false, filterable: searchable, filterItem: wrappedFilterFunction, filterPlaceholder: i18n('search-placeholder'), filterClassName: b('filter'), emptyPlaceholder: emptyState, selectedItemIndex: selectedItemIndex, itemsClassName: b('list'), itemClassName: b('list-item') }) }), hasMore && onLoadMore && (_jsx(Button, { view: "flat-action", size: "m", width: "max", onClick: onLoadMore, children: i18n('action-load-more') }))] }));
|
|
87
|
+
return (_jsxs("div", { className: b('container', className), "data-qa": qa, style: style, children: [_jsx("div", { className: b('list-wrapper'), children: loading ? (_jsx("div", { className: b('loader-wrapper'), children: _jsx(Loader, { view: "loading" }) })) : (_jsx(List, { items: listItems, renderItem: renderItem, virtualized: false, filterable: searchable, filterItem: wrappedFilterFunction, filterPlaceholder: i18n('search-placeholder'), filterClassName: b('filter'), emptyPlaceholder: emptyState, selectedItemIndex: selectedItemIndex, itemsClassName: b('list'), itemClassName: b('list-item') })) }), hasMore && onLoadMore && (_jsx(Button, { view: "flat-action", size: "m", width: "max", onClick: onLoadMore, children: i18n('action-load-more') }))] }));
|
|
87
88
|
}
|
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/index.js
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Hook to preserve scroll position when older messages are loaded at the top
|
|
4
|
+
* This prevents the chat from jumping when new content is prepended
|
|
5
|
+
* @param {RefObject<T>} containerRef - Reference to the scrollable container element
|
|
6
|
+
* @param {number} messagesCount - Current count of messages in the list
|
|
7
|
+
* @returns {void}
|
|
8
|
+
*/
|
|
9
|
+
export declare function useScrollPreservation<T extends HTMLElement>(containerRef: RefObject<T>, messagesCount: number): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Hook to preserve scroll position when older messages are loaded at the top
|
|
4
|
+
* This prevents the chat from jumping when new content is prepended
|
|
5
|
+
* @param {RefObject<T>} containerRef - Reference to the scrollable container element
|
|
6
|
+
* @param {number} messagesCount - Current count of messages in the list
|
|
7
|
+
* @returns {void}
|
|
8
|
+
*/
|
|
9
|
+
export function useScrollPreservation(containerRef, messagesCount) {
|
|
10
|
+
const prevScrollHeight = useRef(0);
|
|
11
|
+
const prevMessagesCount = useRef(messagesCount);
|
|
12
|
+
useLayoutEffect(() => {
|
|
13
|
+
const container = containerRef.current;
|
|
14
|
+
if (!container)
|
|
15
|
+
return;
|
|
16
|
+
const currentScrollHeight = container.scrollHeight;
|
|
17
|
+
// Only adjust scroll if messages were added at the top (older messages loaded)
|
|
18
|
+
if (prevScrollHeight.current > 0 &&
|
|
19
|
+
messagesCount > prevMessagesCount.current &&
|
|
20
|
+
currentScrollHeight > prevScrollHeight.current) {
|
|
21
|
+
// Calculate the height difference and adjust scroll position
|
|
22
|
+
const heightDiff = currentScrollHeight - prevScrollHeight.current;
|
|
23
|
+
container.scrollTop += heightDiff;
|
|
24
|
+
}
|
|
25
|
+
prevScrollHeight.current = currentScrollHeight;
|
|
26
|
+
prevMessagesCount.current = messagesCount;
|
|
27
|
+
}, [containerRef, messagesCount]);
|
|
28
|
+
}
|
package/dist/themes/dark.css
CHANGED
package/dist/types/messages.d.ts
CHANGED
|
@@ -21,7 +21,6 @@ export type TSubmitData = {
|
|
|
21
21
|
attachments?: File[];
|
|
22
22
|
metadata?: TMessageMetadata;
|
|
23
23
|
};
|
|
24
|
-
export type TMessageStatus = 'sending' | 'complete' | 'error' | 'streaming';
|
|
25
24
|
export type TMessageRole = 'user' | 'assistant' | 'system';
|
|
26
25
|
export type TMessageContent<Type extends string = string, Data = unknown> = {
|
|
27
26
|
id?: string;
|
|
@@ -36,6 +35,11 @@ export type ThinkingMessageContentData = {
|
|
|
36
35
|
title?: string;
|
|
37
36
|
content: string | string[];
|
|
38
37
|
status: 'thinking' | 'thought';
|
|
38
|
+
defaultExpanded?: boolean;
|
|
39
|
+
showStatusIndicator?: boolean;
|
|
40
|
+
onCopyClick?: () => void;
|
|
41
|
+
className?: string;
|
|
42
|
+
qa?: string;
|
|
39
43
|
};
|
|
40
44
|
export type ThinkingMessageContent = TMessageContent<'thinking', ThinkingMessageContentData>;
|
|
41
45
|
export type ToolMessageContentData = ToolMessageProps;
|
|
@@ -45,7 +49,6 @@ export type TMessageContentUnion<TCustomMessageContent extends TMessageContent =
|
|
|
45
49
|
export type TAssistantMessageContent<TCustomMessageContent extends TMessageContent = never> = string | TMessageContentUnion<TCustomMessageContent> | TMessageContentUnion<TCustomMessageContent>[];
|
|
46
50
|
export type TBaseMessage<Metadata = TMessageMetadata> = Pick<BaseMessageProps, 'actions' | 'timestamp'> & {
|
|
47
51
|
id?: string;
|
|
48
|
-
status?: TMessageStatus;
|
|
49
52
|
error?: unknown;
|
|
50
53
|
metadata?: Metadata;
|
|
51
54
|
};
|