@carto/ps-react-ui 4.9.1 → 4.11.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/dist/category-Dnd2_j0x.js +719 -0
- package/dist/category-Dnd2_j0x.js.map +1 -0
- package/dist/change-column-DjjwoPt1.js +1143 -0
- package/dist/change-column-DjjwoPt1.js.map +1 -0
- package/dist/chat.js +1507 -0
- package/dist/chat.js.map +1 -0
- package/dist/components.js +122 -120
- package/dist/components.js.map +1 -1
- package/dist/copy-button-DGL1tyli.js +26 -0
- package/dist/copy-button-DGL1tyli.js.map +1 -0
- package/dist/{data-zoom-layout-0QSptXG_.js → data-zoom-layout-CkVnm6ej.js} +3 -3
- package/dist/{data-zoom-layout-0QSptXG_.js.map → data-zoom-layout-CkVnm6ej.js.map} +1 -1
- package/dist/{download-config-CzmjOT2T.js → download-config-oJIFZ2WC.js} +9 -8
- package/dist/{download-config-CzmjOT2T.js.map → download-config-oJIFZ2WC.js.map} +1 -1
- package/dist/{png-item-CS4z1iSH.js → png-item-BE9uEqlD.js} +2 -2
- package/dist/png-item-BE9uEqlD.js.map +1 -0
- package/dist/{spread-Y9R1f5dm.js → spread-DYNpzgh_.js} +10 -11
- package/dist/{spread-Y9R1f5dm.js.map → spread-DYNpzgh_.js.map} +1 -1
- package/dist/{table-CQCAnDLb.js → table-C9IMbTr0.js} +50 -53
- package/dist/table-C9IMbTr0.js.map +1 -0
- package/dist/types/chat/bubbles/chat-error-message.d.ts +2 -0
- package/dist/types/chat/bubbles/chat-suggestion-button.d.ts +2 -0
- package/dist/types/chat/bubbles/chat-user-message.d.ts +2 -0
- package/dist/types/chat/bubbles/index.d.ts +4 -0
- package/dist/types/chat/const.d.ts +4 -0
- package/dist/types/chat/containers/chat-content.d.ts +2 -0
- package/dist/types/chat/containers/chat-footer.d.ts +2 -0
- package/dist/types/chat/containers/chat-header.d.ts +2 -0
- package/dist/types/chat/containers/chat-starter.d.ts +2 -0
- package/dist/types/chat/containers/index.d.ts +4 -0
- package/dist/types/chat/containers/styles.d.ts +93 -0
- package/dist/types/chat/feedback/chat-loader.d.ts +2 -0
- package/dist/types/chat/feedback/chat-rating-action.d.ts +2 -0
- package/dist/types/chat/feedback/chat-thinking.d.ts +2 -0
- package/dist/types/chat/feedback/chat-tool-code-area.d.ts +2 -0
- package/dist/types/chat/feedback/chat-tool-full-view-dialog.d.ts +2 -0
- package/dist/types/chat/feedback/chat-tool-group.d.ts +2 -0
- package/dist/types/chat/feedback/chat-tool-trace.d.ts +3 -0
- package/dist/types/chat/feedback/get-tool-label.d.ts +2 -0
- package/dist/types/chat/feedback/index.d.ts +8 -0
- package/dist/types/chat/feedback/styles.d.ts +211 -0
- package/dist/types/chat/index.d.ts +20 -0
- package/dist/types/chat/types.d.ts +184 -0
- package/dist/types/chat/use-typewriter.d.ts +30 -0
- package/dist/types/components/copy-button/copy-button.d.ts +2 -0
- package/dist/types/components/copy-button/types.d.ts +6 -0
- package/dist/types/components/index.d.ts +2 -0
- package/dist/types/widgets/actions/brush-toggle/style.d.ts +1 -1
- package/dist/types/widgets/actions/shared/styles.d.ts +1 -1
- package/dist/types/widgets/actions/zoom-toggle/style.d.ts +1 -1
- package/dist/types/widgets/echart/types.d.ts +1 -1
- package/dist/types/widgets/toolbar-actions/styles.d.ts +1 -1
- package/dist/types/widgets-v2/actions/brush-toggle/style.d.ts +1 -1
- package/dist/types/widgets-v2/actions/change-column/style.d.ts +1 -1
- package/dist/types/widgets-v2/actions/fullscreen/style.d.ts +1 -1
- package/dist/types/widgets-v2/actions/index.d.ts +1 -0
- package/dist/types/widgets-v2/actions/lock-selection/style.d.ts +1 -1
- package/dist/types/widgets-v2/actions/relative-data/style.d.ts +1 -1
- package/dist/types/widgets-v2/actions/searcher/style.d.ts +1 -1
- package/dist/types/widgets-v2/actions/show-all/index.d.ts +2 -0
- package/dist/types/widgets-v2/actions/show-all/labels.d.ts +5 -0
- package/dist/types/widgets-v2/actions/show-all/show-all.d.ts +33 -0
- package/dist/types/widgets-v2/actions/show-all/style.d.ts +8 -0
- package/dist/types/widgets-v2/actions/stack-toggle/style.d.ts +1 -1
- package/dist/types/widgets-v2/actions/zoom-toggle/style.d.ts +1 -1
- package/dist/types/widgets-v2/category/category-ui.d.ts +9 -2
- package/dist/types/widgets-v2/category/category.d.ts +9 -2
- package/dist/types/widgets-v2/category/components/category-row-other.d.ts +19 -6
- package/dist/types/widgets-v2/category/style.d.ts +21 -2
- package/dist/types/widgets-v2/category/types.d.ts +2 -0
- package/dist/types/widgets-v2/index.d.ts +3 -2
- package/dist/types/widgets-v2/selection-summary/labels.d.ts +7 -2
- package/dist/types/widgets-v2/selection-summary/selection-summary.d.ts +13 -6
- package/dist/types/widgets-v2/selection-summary/style.d.ts +15 -0
- package/dist/widgets/actions.js +115 -114
- package/dist/widgets/actions.js.map +1 -1
- package/dist/widgets/bar.js +1 -1
- package/dist/widgets/category.js +9 -8
- package/dist/widgets/category.js.map +1 -1
- package/dist/widgets/formula.js +11 -10
- package/dist/widgets/formula.js.map +1 -1
- package/dist/widgets/histogram.js +7 -6
- package/dist/widgets/histogram.js.map +1 -1
- package/dist/widgets/markdown.js +9 -8
- package/dist/widgets/markdown.js.map +1 -1
- package/dist/widgets/pie.js +1 -1
- package/dist/widgets/scatterplot.js +1 -1
- package/dist/widgets/spread.js +9 -8
- package/dist/widgets/spread.js.map +1 -1
- package/dist/widgets/table.js +17 -16
- package/dist/widgets/table.js.map +1 -1
- package/dist/widgets/timeseries.js +1 -1
- package/dist/widgets/utils.js +1 -1
- package/dist/widgets/wrapper.js +3 -2
- package/dist/widgets/wrapper.js.map +1 -1
- package/dist/widgets-v2/actions.js +41 -37
- package/dist/widgets-v2/bar.js +9 -10
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +25 -26
- package/dist/widgets-v2/category.js.map +1 -1
- package/dist/widgets-v2/formula.js +3 -3
- package/dist/widgets-v2/histogram.js +11 -13
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/markdown.js +26 -27
- package/dist/widgets-v2/markdown.js.map +1 -1
- package/dist/widgets-v2/pie.js +8 -10
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +10 -12
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/spread.js +15 -16
- package/dist/widgets-v2/spread.js.map +1 -1
- package/dist/widgets-v2/table.js +39 -40
- package/dist/widgets-v2/table.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +9 -11
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2/utils.js +1 -1
- package/dist/widgets-v2.js +284 -282
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +5 -1
- package/src/chat/bubbles/chat-agent-message.test.tsx +30 -0
- package/src/chat/bubbles/chat-agent-message.tsx +11 -0
- package/src/chat/bubbles/chat-error-message.test.tsx +40 -0
- package/src/chat/bubbles/chat-error-message.tsx +47 -0
- package/src/chat/bubbles/chat-suggestion-button.test.tsx +24 -0
- package/src/chat/bubbles/chat-suggestion-button.tsx +27 -0
- package/src/chat/bubbles/chat-user-message.test.tsx +27 -0
- package/src/chat/bubbles/chat-user-message.tsx +27 -0
- package/src/chat/bubbles/index.ts +4 -0
- package/src/chat/bubbles/styles.ts +148 -0
- package/src/chat/const.ts +4 -0
- package/src/chat/containers/chat-content.test.tsx +269 -0
- package/src/chat/containers/chat-content.tsx +142 -0
- package/src/chat/containers/chat-footer.test.tsx +34 -0
- package/src/chat/containers/chat-footer.tsx +78 -0
- package/src/chat/containers/chat-header.test.tsx +28 -0
- package/src/chat/containers/chat-header.tsx +29 -0
- package/src/chat/containers/chat-starter.test.tsx +32 -0
- package/src/chat/containers/chat-starter.tsx +75 -0
- package/src/chat/containers/index.ts +4 -0
- package/src/chat/containers/styles.ts +96 -0
- package/src/chat/feedback/chat-actions-container.test.tsx +64 -0
- package/src/chat/feedback/chat-actions-container.tsx +7 -0
- package/src/chat/feedback/chat-loader.test.tsx +10 -0
- package/src/chat/feedback/chat-loader.tsx +31 -0
- package/src/chat/feedback/chat-rating-action.tsx +43 -0
- package/src/chat/feedback/chat-thinking.test.tsx +15 -0
- package/src/chat/feedback/chat-thinking.tsx +23 -0
- package/src/chat/feedback/chat-tool-code-area.test.tsx +23 -0
- package/src/chat/feedback/chat-tool-code-area.tsx +71 -0
- package/src/chat/feedback/chat-tool-full-view-dialog.test.tsx +39 -0
- package/src/chat/feedback/chat-tool-full-view-dialog.tsx +121 -0
- package/src/chat/feedback/chat-tool-group.test.tsx +84 -0
- package/src/chat/feedback/chat-tool-group.tsx +156 -0
- package/src/chat/feedback/chat-tool-trace.test.tsx +81 -0
- package/src/chat/feedback/chat-tool-trace.tsx +192 -0
- package/src/chat/feedback/get-tool-label.test.tsx +91 -0
- package/src/chat/feedback/get-tool-label.ts +13 -0
- package/src/chat/feedback/index.ts +8 -0
- package/src/chat/feedback/styles.ts +229 -0
- package/src/chat/index.ts +59 -0
- package/src/chat/types.ts +215 -0
- package/src/chat/use-typewriter.test.tsx +38 -0
- package/src/chat/use-typewriter.ts +82 -0
- package/src/components/copy-button/copy-button.test.tsx +41 -0
- package/src/components/copy-button/copy-button.tsx +31 -0
- package/src/components/copy-button/types.ts +10 -0
- package/src/components/index.ts +3 -0
- package/src/widgets/echart/types.ts +1 -1
- package/src/widgets-v2/actions/brush-toggle/brush-toggle.tsx +1 -1
- package/src/widgets-v2/actions/change-column/sortable-column-item.tsx +1 -1
- package/src/widgets-v2/actions/download/download.tsx +1 -1
- package/src/widgets-v2/actions/download/icons.tsx +1 -1
- package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +3 -3
- package/src/widgets-v2/actions/index.ts +8 -0
- package/src/widgets-v2/actions/lock-selection/lock-selection.tsx +2 -2
- package/src/widgets-v2/actions/relative-data/relative-data.tsx +1 -1
- package/src/widgets-v2/actions/searcher/searcher-toggle.tsx +1 -1
- package/src/widgets-v2/actions/searcher/searcher.tsx +2 -2
- package/src/widgets-v2/actions/show-all/index.ts +7 -0
- package/src/widgets-v2/actions/show-all/labels.ts +8 -0
- package/src/widgets-v2/actions/show-all/show-all.test.tsx +50 -0
- package/src/widgets-v2/actions/show-all/show-all.tsx +72 -0
- package/src/widgets-v2/actions/show-all/style.ts +8 -0
- package/src/widgets-v2/actions/stack-toggle/stack-toggle.tsx +1 -1
- package/src/widgets-v2/actions/zoom-toggle/zoom-toggle.tsx +1 -1
- package/src/widgets-v2/category/category-ui.test.tsx +26 -10
- package/src/widgets-v2/category/category-ui.tsx +13 -3
- package/src/widgets-v2/category/category.test.tsx +4 -4
- package/src/widgets-v2/category/category.tsx +10 -1
- package/src/widgets-v2/category/components/category-row-other.test.tsx +36 -7
- package/src/widgets-v2/category/components/category-row-other.tsx +64 -13
- package/src/widgets-v2/category/style.ts +35 -4
- package/src/widgets-v2/category/types.ts +2 -0
- package/src/widgets-v2/index.ts +3 -0
- package/src/widgets-v2/selection-summary/labels.ts +8 -4
- package/src/widgets-v2/selection-summary/selection-summary.test.tsx +15 -9
- package/src/widgets-v2/selection-summary/selection-summary.tsx +42 -22
- package/src/widgets-v2/selection-summary/style.ts +15 -0
- package/src/widgets-v2/table/table-ui.tsx +4 -4
- package/src/widgets-v2/toolbox/toolbox.tsx +1 -1
- package/src/widgets-v2/wrapper/widget-wrapper.tsx +1 -1
- package/dist/category-DwaeYjpX.js +0 -656
- package/dist/category-DwaeYjpX.js.map +0 -1
- package/dist/change-column-B4IT0rh6.js +0 -1110
- package/dist/change-column-B4IT0rh6.js.map +0 -1
- package/dist/png-item-CS4z1iSH.js.map +0 -1
- package/dist/table-CQCAnDLb.js.map +0 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { ChatSuggestionButton } from './chat-suggestion-button'
|
|
4
|
+
|
|
5
|
+
describe('ChatSuggestionButton', () => {
|
|
6
|
+
test('renders label text', () => {
|
|
7
|
+
render(<ChatSuggestionButton label='Try this' onClick={vi.fn()} />)
|
|
8
|
+
expect(screen.getByText('Try this')).toBeTruthy()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('calls onClick when clicked', () => {
|
|
12
|
+
const onClick = vi.fn()
|
|
13
|
+
render(<ChatSuggestionButton label='Click me' onClick={onClick} />)
|
|
14
|
+
fireEvent.click(screen.getByText('Click me'))
|
|
15
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('is disabled when disabled prop is true', () => {
|
|
19
|
+
const onClick = vi.fn()
|
|
20
|
+
render(<ChatSuggestionButton label='Disabled' onClick={onClick} disabled />)
|
|
21
|
+
const button = screen.getByRole('button')
|
|
22
|
+
expect(button.hasAttribute('disabled')).toBeTruthy()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ButtonBase, Typography } from '@mui/material'
|
|
2
|
+
import { ArrowUpward } from '@mui/icons-material'
|
|
3
|
+
import type { ChatSuggestionButtonProps } from '../types'
|
|
4
|
+
import { styles } from './styles'
|
|
5
|
+
|
|
6
|
+
export function ChatSuggestionButton({
|
|
7
|
+
label,
|
|
8
|
+
color,
|
|
9
|
+
sx,
|
|
10
|
+
...props
|
|
11
|
+
}: ChatSuggestionButtonProps) {
|
|
12
|
+
return (
|
|
13
|
+
<ButtonBase
|
|
14
|
+
sx={{
|
|
15
|
+
...styles.suggestionButton,
|
|
16
|
+
...(color ? { backgroundColor: color } : undefined),
|
|
17
|
+
...sx,
|
|
18
|
+
}}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
<Typography color='inherit' variant='body2'>
|
|
22
|
+
{label}
|
|
23
|
+
</Typography>
|
|
24
|
+
<ArrowUpward />
|
|
25
|
+
</ButtonBase>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { ChatUserMessage } from './chat-user-message'
|
|
4
|
+
|
|
5
|
+
describe('ChatUserMessage', () => {
|
|
6
|
+
test('renders children content', () => {
|
|
7
|
+
render(<ChatUserMessage>Hello world</ChatUserMessage>)
|
|
8
|
+
expect(screen.getByText('Hello world')).toBeTruthy()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('renders secondary content when provided', () => {
|
|
12
|
+
render(
|
|
13
|
+
<ChatUserMessage topContext={<span>Secondary</span>}>
|
|
14
|
+
Primary
|
|
15
|
+
</ChatUserMessage>,
|
|
16
|
+
)
|
|
17
|
+
expect(screen.getByText('Primary')).toBeTruthy()
|
|
18
|
+
expect(screen.getByText('Secondary')).toBeTruthy()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('does not render secondary content when not provided', () => {
|
|
22
|
+
const { container } = render(
|
|
23
|
+
<ChatUserMessage>Only primary</ChatUserMessage>,
|
|
24
|
+
)
|
|
25
|
+
expect(container.textContent).toBe('Only primary')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Box } from '@mui/material'
|
|
2
|
+
import type { ChatUserMessageProps } from '../types'
|
|
3
|
+
import { ChatMessageOverflow, styles } from './styles'
|
|
4
|
+
|
|
5
|
+
export function ChatUserMessage({
|
|
6
|
+
children,
|
|
7
|
+
muted = false,
|
|
8
|
+
topContext,
|
|
9
|
+
sx,
|
|
10
|
+
}: ChatUserMessageProps) {
|
|
11
|
+
return (
|
|
12
|
+
<Box className='PsChat--user-message' sx={styles.userMessageContainer}>
|
|
13
|
+
{topContext && <Box sx={styles.userMessageTop}>{topContext}</Box>}
|
|
14
|
+
<ChatMessageOverflow
|
|
15
|
+
className='PsChat--user-message-inner'
|
|
16
|
+
variant='body2'
|
|
17
|
+
sx={{
|
|
18
|
+
...styles.userMessage,
|
|
19
|
+
...(muted ? styles.muted : undefined),
|
|
20
|
+
...sx,
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</ChatMessageOverflow>
|
|
25
|
+
</Box>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { styled, Typography, type SxProps, type Theme } from '@mui/material'
|
|
2
|
+
|
|
3
|
+
export const ChatMessageOverflow = styled(Typography)(() => ({
|
|
4
|
+
whiteSpace: 'pre-wrap',
|
|
5
|
+
overflowX: 'clip',
|
|
6
|
+
overflowWrap: 'break-word',
|
|
7
|
+
maxWidth: '100%',
|
|
8
|
+
}))
|
|
9
|
+
ChatMessageOverflow.displayName = 'ChatMessageOverflow'
|
|
10
|
+
|
|
11
|
+
export const styles = {
|
|
12
|
+
agentMessageContainer: (theme: Theme) => ({
|
|
13
|
+
width: '100%',
|
|
14
|
+
display: 'flex',
|
|
15
|
+
flexDirection: 'column',
|
|
16
|
+
alignItems: 'flex-start',
|
|
17
|
+
paddingRight: theme.spacing(4),
|
|
18
|
+
'& + .PsChat--agent-message': {
|
|
19
|
+
marginTop: theme.spacing(1),
|
|
20
|
+
},
|
|
21
|
+
'& + .PsChat--user-message': {
|
|
22
|
+
marginTop: theme.spacing(3),
|
|
23
|
+
},
|
|
24
|
+
'& > p:first-of-type': {
|
|
25
|
+
marginTop: 0,
|
|
26
|
+
},
|
|
27
|
+
'& table': {
|
|
28
|
+
alignSelf: 'stretch',
|
|
29
|
+
display: 'block',
|
|
30
|
+
overflowX: 'auto',
|
|
31
|
+
maxWidth: '100%',
|
|
32
|
+
borderCollapse: 'collapse',
|
|
33
|
+
margin: theme.spacing(1, 0),
|
|
34
|
+
fontSize: '0.875em',
|
|
35
|
+
},
|
|
36
|
+
'& th, & td': {
|
|
37
|
+
border: `1px solid ${theme.palette.divider}`,
|
|
38
|
+
padding: theme.spacing(0.5, 1),
|
|
39
|
+
textAlign: 'left',
|
|
40
|
+
},
|
|
41
|
+
'& th': {
|
|
42
|
+
backgroundColor: theme.palette.action.hover,
|
|
43
|
+
fontWeight: 600,
|
|
44
|
+
},
|
|
45
|
+
'& tr:nth-of-type(even) td': {
|
|
46
|
+
backgroundColor: theme.palette.action.hover,
|
|
47
|
+
},
|
|
48
|
+
'& ul.contains-task-list': {
|
|
49
|
+
listStyle: 'none',
|
|
50
|
+
paddingLeft: 0,
|
|
51
|
+
},
|
|
52
|
+
'& .task-list-item input[type="checkbox"]': {
|
|
53
|
+
marginRight: theme.spacing(0.5),
|
|
54
|
+
},
|
|
55
|
+
'& del': {
|
|
56
|
+
textDecoration: 'line-through',
|
|
57
|
+
opacity: 0.7,
|
|
58
|
+
},
|
|
59
|
+
'& > ul, & > ol': {
|
|
60
|
+
margin: theme.spacing(1, 0),
|
|
61
|
+
paddingLeft: theme.spacing(3),
|
|
62
|
+
'& > li + li': {
|
|
63
|
+
marginTop: theme.spacing(1),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
userMessageContainer: {
|
|
68
|
+
width: '100%',
|
|
69
|
+
display: 'flex',
|
|
70
|
+
flexDirection: 'column',
|
|
71
|
+
alignItems: 'flex-end',
|
|
72
|
+
paddingLeft: ({ spacing }) => spacing(4),
|
|
73
|
+
'&:has(+ .PsChat--user-message) > .PsChat--user-message-inner': {
|
|
74
|
+
borderRadius: ({ spacing }) => spacing(2),
|
|
75
|
+
},
|
|
76
|
+
'& + .PsChat--user-message': {
|
|
77
|
+
marginTop: ({ spacing }) => spacing(0.5),
|
|
78
|
+
},
|
|
79
|
+
'& + .PsChat--agent-message': {
|
|
80
|
+
marginTop: ({ spacing }) => spacing(3),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
userMessage: {
|
|
84
|
+
padding: ({ spacing }) => spacing(0.75, 1.5),
|
|
85
|
+
borderRadius: ({ spacing }) => spacing(2, 2, 0.25, 2),
|
|
86
|
+
border: ({ palette }) => `1px solid ${palette.divider}`,
|
|
87
|
+
backgroundColor: ({ palette }) => palette.background.default,
|
|
88
|
+
color: ({ palette }) => palette.text.primary,
|
|
89
|
+
width: 'fit-content',
|
|
90
|
+
},
|
|
91
|
+
muted: {
|
|
92
|
+
color: ({ palette }) => palette.text.disabled,
|
|
93
|
+
},
|
|
94
|
+
userMessageTop: {
|
|
95
|
+
textAlign: 'right',
|
|
96
|
+
marginBottom: ({ spacing }) => spacing(0.5),
|
|
97
|
+
},
|
|
98
|
+
errorMessage: {
|
|
99
|
+
display: 'flex',
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
flexWrap: 'wrap',
|
|
102
|
+
gap: ({ spacing }) => spacing(1),
|
|
103
|
+
},
|
|
104
|
+
errorActions: {
|
|
105
|
+
display: 'flex',
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
gap: ({ spacing }) => spacing(0.5),
|
|
108
|
+
},
|
|
109
|
+
errorAction: {
|
|
110
|
+
color: ({ palette }) => palette.text.primary,
|
|
111
|
+
},
|
|
112
|
+
errorActionSeparator: {
|
|
113
|
+
mr: 0.5,
|
|
114
|
+
color: ({ palette }) => palette.black[25],
|
|
115
|
+
fontWeight: 600,
|
|
116
|
+
},
|
|
117
|
+
suggestionButton: {
|
|
118
|
+
transition: 'border-color 0.2s',
|
|
119
|
+
width: '100%',
|
|
120
|
+
alignItems: 'flex-start',
|
|
121
|
+
justifyContent: 'space-between',
|
|
122
|
+
textAlign: 'left',
|
|
123
|
+
gap: ({ spacing }) => spacing(1),
|
|
124
|
+
padding: ({ spacing }) => spacing(0.75, 1.5),
|
|
125
|
+
borderRadius: ({ spacing }) => spacing(2),
|
|
126
|
+
border: ({ palette }) => `1px solid ${palette.divider}`,
|
|
127
|
+
'& .MuiSvgIcon-root': {
|
|
128
|
+
transition: 'color 0.2s',
|
|
129
|
+
color: 'inherit',
|
|
130
|
+
fontSize: ({ spacing }) => spacing(1.5),
|
|
131
|
+
width: ({ spacing }) => spacing(1.5),
|
|
132
|
+
minWidth: ({ spacing }) => spacing(1.5),
|
|
133
|
+
height: ({ spacing }) => spacing(2.5),
|
|
134
|
+
},
|
|
135
|
+
'&:not(.Mui-disabled) .MuiSvgIcon-root': {
|
|
136
|
+
color: ({ palette }) => palette.text.secondary,
|
|
137
|
+
},
|
|
138
|
+
'&:hover': {
|
|
139
|
+
borderColor: ({ palette }) => palette.text.hint,
|
|
140
|
+
'& .MuiSvgIcon-root': {
|
|
141
|
+
color: ({ palette }) => palette.text.primary,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
'&.Mui-disabled': {
|
|
145
|
+
color: ({ palette }) => palette.text.disabled,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
} satisfies Record<string, SxProps<Theme>>
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
test,
|
|
4
|
+
expect,
|
|
5
|
+
vi,
|
|
6
|
+
beforeEach,
|
|
7
|
+
afterEach,
|
|
8
|
+
type MockInstance,
|
|
9
|
+
} from 'vitest'
|
|
10
|
+
import { createRef } from 'react'
|
|
11
|
+
import { act, render, screen } from '@testing-library/react'
|
|
12
|
+
import { ChatContent } from './chat-content'
|
|
13
|
+
import type { ChatContentRef } from '../types'
|
|
14
|
+
|
|
15
|
+
type IOCallback = (entries: IntersectionObserverEntry[]) => void
|
|
16
|
+
|
|
17
|
+
interface FakeObserver {
|
|
18
|
+
callback: IOCallback
|
|
19
|
+
observed: Element[]
|
|
20
|
+
disconnected: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface FakeMutationObserverInstance {
|
|
24
|
+
callback: MutationCallback
|
|
25
|
+
target: Node | null
|
|
26
|
+
disconnected: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let observers: FakeObserver[] = []
|
|
30
|
+
let mutationObservers: FakeMutationObserverInstance[] = []
|
|
31
|
+
let scrollToSpy: MockInstance<Element['scrollTo']>
|
|
32
|
+
|
|
33
|
+
// Mutable "DOM measurements" the prototype getters read from.
|
|
34
|
+
let scrollHeightValue = 0
|
|
35
|
+
let scrollTopValue = 0
|
|
36
|
+
let clientHeightValue = 0
|
|
37
|
+
|
|
38
|
+
class FakeIntersectionObserver {
|
|
39
|
+
_obs: FakeObserver
|
|
40
|
+
constructor(cb: IOCallback) {
|
|
41
|
+
this._obs = { callback: cb, observed: [], disconnected: false }
|
|
42
|
+
observers.push(this._obs)
|
|
43
|
+
}
|
|
44
|
+
observe(el: Element) {
|
|
45
|
+
this._obs.observed.push(el)
|
|
46
|
+
}
|
|
47
|
+
unobserve() {
|
|
48
|
+
// NOOP
|
|
49
|
+
}
|
|
50
|
+
disconnect() {
|
|
51
|
+
this._obs.disconnected = true
|
|
52
|
+
}
|
|
53
|
+
takeRecords(): IntersectionObserverEntry[] {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class FakeMutationObserver {
|
|
59
|
+
_inst: FakeMutationObserverInstance
|
|
60
|
+
constructor(cb: MutationCallback) {
|
|
61
|
+
this._inst = { callback: cb, target: null, disconnected: false }
|
|
62
|
+
mutationObservers.push(this._inst)
|
|
63
|
+
}
|
|
64
|
+
observe(target: Node) {
|
|
65
|
+
this._inst.target = target
|
|
66
|
+
}
|
|
67
|
+
disconnect() {
|
|
68
|
+
this._inst.disconnected = true
|
|
69
|
+
}
|
|
70
|
+
takeRecords(): MutationRecord[] {
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The component creates the top observer first, then the bottom one — so
|
|
77
|
+
* `observers[0]` is top and `observers[1]` is bottom. Helper trips one of them.
|
|
78
|
+
*/
|
|
79
|
+
function fireIntersection(index: 0 | 1, isIntersecting: boolean) {
|
|
80
|
+
const obs = observers[index]
|
|
81
|
+
if (!obs) throw new Error(`No observer at index ${index}`)
|
|
82
|
+
act(() => {
|
|
83
|
+
obs.callback([
|
|
84
|
+
{ isIntersecting, target: obs.observed[0]! } as IntersectionObserverEntry,
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Triggers the most recent MutationObserver as if a DOM mutation happened. */
|
|
90
|
+
function fireMutation() {
|
|
91
|
+
const obs = mutationObservers.at(-1)
|
|
92
|
+
if (!obs) throw new Error('No MutationObserver registered')
|
|
93
|
+
act(() => {
|
|
94
|
+
obs.callback([], obs as unknown as MutationObserver)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('ChatContent', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
observers = []
|
|
101
|
+
mutationObservers = []
|
|
102
|
+
scrollHeightValue = 0
|
|
103
|
+
scrollTopValue = 0
|
|
104
|
+
clientHeightValue = 0
|
|
105
|
+
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
|
106
|
+
vi.stubGlobal('MutationObserver', FakeMutationObserver)
|
|
107
|
+
// Run rAF synchronously so MutationObserver-driven scroll checks happen
|
|
108
|
+
// inside `act`.
|
|
109
|
+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
110
|
+
cb(0)
|
|
111
|
+
return 1
|
|
112
|
+
})
|
|
113
|
+
vi.stubGlobal('cancelAnimationFrame', () => {
|
|
114
|
+
// NOOP
|
|
115
|
+
})
|
|
116
|
+
scrollToSpy = vi
|
|
117
|
+
.spyOn(Element.prototype, 'scrollTo')
|
|
118
|
+
.mockImplementation(() => {
|
|
119
|
+
// NOOP
|
|
120
|
+
})
|
|
121
|
+
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(
|
|
122
|
+
() => scrollHeightValue,
|
|
123
|
+
)
|
|
124
|
+
vi.spyOn(HTMLElement.prototype, 'scrollTop', 'get').mockImplementation(
|
|
125
|
+
() => scrollTopValue,
|
|
126
|
+
)
|
|
127
|
+
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(
|
|
128
|
+
() => clientHeightValue,
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
vi.unstubAllGlobals()
|
|
134
|
+
vi.restoreAllMocks()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('renders children', () => {
|
|
138
|
+
render(<ChatContent>Scrollable content</ChatContent>)
|
|
139
|
+
expect(screen.getByText('Scrollable content')).toBeTruthy()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('observes the top and bottom sentinels', () => {
|
|
143
|
+
render(<ChatContent>content</ChatContent>)
|
|
144
|
+
expect(observers).toHaveLength(2)
|
|
145
|
+
expect(observers[0]?.observed).toHaveLength(1)
|
|
146
|
+
expect(observers[1]?.observed).toHaveLength(1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('ref starts with isAtTop and isAtBottom both true', () => {
|
|
150
|
+
const ref = createRef<ChatContentRef>()
|
|
151
|
+
render(<ChatContent ref={ref}>content</ChatContent>)
|
|
152
|
+
expect(ref.current?.isAtTop).toBe(true)
|
|
153
|
+
expect(ref.current?.isAtBottom).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('isAtBottom flips when the bottom sentinel leaves the viewport', () => {
|
|
157
|
+
const ref = createRef<ChatContentRef>()
|
|
158
|
+
render(<ChatContent ref={ref}>content</ChatContent>)
|
|
159
|
+
fireIntersection(1, false)
|
|
160
|
+
expect(ref.current?.isAtBottom).toBe(false)
|
|
161
|
+
fireIntersection(1, true)
|
|
162
|
+
expect(ref.current?.isAtBottom).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('isAtTop flips when the top sentinel leaves the viewport', () => {
|
|
166
|
+
const ref = createRef<ChatContentRef>()
|
|
167
|
+
render(<ChatContent ref={ref}>content</ChatContent>)
|
|
168
|
+
fireIntersection(0, false)
|
|
169
|
+
expect(ref.current?.isAtTop).toBe(false)
|
|
170
|
+
fireIntersection(0, true)
|
|
171
|
+
expect(ref.current?.isAtTop).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('scrollToBottom() scrolls to scrollHeight with smooth behaviour', () => {
|
|
175
|
+
scrollHeightValue = 1000
|
|
176
|
+
const ref = createRef<ChatContentRef>()
|
|
177
|
+
render(<ChatContent ref={ref}>content</ChatContent>)
|
|
178
|
+
act(() => ref.current?.scrollToBottom())
|
|
179
|
+
expect(scrollToSpy).toHaveBeenCalledWith({
|
|
180
|
+
top: 1000,
|
|
181
|
+
behavior: 'smooth',
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('scrollToTop() scrolls the container to top 0', () => {
|
|
186
|
+
const ref = createRef<ChatContentRef>()
|
|
187
|
+
render(<ChatContent ref={ref}>content</ChatContent>)
|
|
188
|
+
act(() => ref.current?.scrollToTop())
|
|
189
|
+
expect(scrollToSpy).toHaveBeenCalledWith({
|
|
190
|
+
top: 0,
|
|
191
|
+
behavior: 'smooth',
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('clicking the jump-to-latest FAB scrolls to the bottom', () => {
|
|
196
|
+
render(<ChatContent>content</ChatContent>)
|
|
197
|
+
fireIntersection(1, false) // not at bottom anymore
|
|
198
|
+
const fab = screen.getByLabelText('Jump to latest')
|
|
199
|
+
act(() => fab.click())
|
|
200
|
+
expect(scrollToSpy).toHaveBeenCalled()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('uses custom jumpToLatest label', () => {
|
|
204
|
+
render(
|
|
205
|
+
<ChatContent labels={{ jumpToLatest: 'Ir al final' }}>x</ChatContent>,
|
|
206
|
+
)
|
|
207
|
+
expect(screen.getByLabelText('Ir al final')).toBeTruthy()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('disconnects observers on unmount', () => {
|
|
211
|
+
const { unmount } = render(<ChatContent>content</ChatContent>)
|
|
212
|
+
unmount()
|
|
213
|
+
expect(observers[0]?.disconnected).toBe(true)
|
|
214
|
+
expect(observers[1]?.disconnected).toBe(true)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('autoScroll', () => {
|
|
218
|
+
test('attaches a MutationObserver by default', () => {
|
|
219
|
+
render(<ChatContent>content</ChatContent>)
|
|
220
|
+
expect(mutationObservers).toHaveLength(1)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('does not attach a MutationObserver when autoScroll={false}', () => {
|
|
224
|
+
render(<ChatContent autoScroll={false}>content</ChatContent>)
|
|
225
|
+
expect(mutationObservers).toHaveLength(0)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('scrolls to the new bottom when content grows and the user is at the bottom', () => {
|
|
229
|
+
// At-bottom: scrollHeight - scrollTop - clientHeight = 0
|
|
230
|
+
scrollHeightValue = 1000
|
|
231
|
+
clientHeightValue = 500
|
|
232
|
+
scrollTopValue = 500
|
|
233
|
+
|
|
234
|
+
render(<ChatContent autoScroll>content</ChatContent>)
|
|
235
|
+
expect(mutationObservers).toHaveLength(1)
|
|
236
|
+
|
|
237
|
+
// Content grows by 200px while the user is still at the bottom.
|
|
238
|
+
scrollHeightValue = 1200
|
|
239
|
+
fireMutation()
|
|
240
|
+
|
|
241
|
+
expect(scrollToSpy).toHaveBeenCalledWith({
|
|
242
|
+
top: 1200,
|
|
243
|
+
behavior: 'smooth',
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('does NOT scroll when the user has scrolled up to read history', () => {
|
|
248
|
+
// 400px from the bottom: 1000 - 100 - 500 = 400 (well past the 32px slack)
|
|
249
|
+
scrollHeightValue = 1000
|
|
250
|
+
clientHeightValue = 500
|
|
251
|
+
scrollTopValue = 100
|
|
252
|
+
|
|
253
|
+
render(<ChatContent autoScroll>content</ChatContent>)
|
|
254
|
+
|
|
255
|
+
// Content grows by 200px — distance-from-bottom is now 600, growth is
|
|
256
|
+
// 200, so 600 > 200 + 32 → leave the user alone.
|
|
257
|
+
scrollHeightValue = 1200
|
|
258
|
+
fireMutation()
|
|
259
|
+
|
|
260
|
+
expect(scrollToSpy).not.toHaveBeenCalled()
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('disconnects the MutationObserver on unmount', () => {
|
|
264
|
+
const { unmount } = render(<ChatContent autoScroll>content</ChatContent>)
|
|
265
|
+
unmount()
|
|
266
|
+
expect(mutationObservers[0]?.disconnected).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useImperativeHandle,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react'
|
|
8
|
+
import { Box, Fab } from '@mui/material'
|
|
9
|
+
import { KeyboardArrowDown } from '@mui/icons-material'
|
|
10
|
+
import type { ChatContentProps, ChatContentRef } from '../types'
|
|
11
|
+
import { styles } from './styles'
|
|
12
|
+
|
|
13
|
+
/** Slack in px for treating the user as "at the bottom" when content grows. */
|
|
14
|
+
const AUTO_SCROLL_THRESHOLD = 32
|
|
15
|
+
|
|
16
|
+
export const ChatContent = forwardRef<ChatContentRef, ChatContentProps>(
|
|
17
|
+
function ChatContent({ children, autoScroll = true, labels = {}, sx }, ref) {
|
|
18
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
19
|
+
const topSentinelRef = useRef<HTMLDivElement>(null)
|
|
20
|
+
const bottomSentinelRef = useRef<HTMLDivElement>(null)
|
|
21
|
+
|
|
22
|
+
const [isAtTop, setIsAtTop] = useState(true)
|
|
23
|
+
const [isAtBottom, setIsAtBottom] = useState(true)
|
|
24
|
+
|
|
25
|
+
const scrollToBottom = () => {
|
|
26
|
+
scrollRef.current?.scrollTo({
|
|
27
|
+
top: scrollRef.current.scrollHeight,
|
|
28
|
+
behavior: 'smooth',
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const scrollToTop = () => {
|
|
33
|
+
scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
useImperativeHandle(
|
|
37
|
+
ref,
|
|
38
|
+
() => ({ scrollToBottom, scrollToTop, isAtBottom, isAtTop }),
|
|
39
|
+
[isAtBottom, isAtTop],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const root = scrollRef.current
|
|
44
|
+
const topEl = topSentinelRef.current
|
|
45
|
+
const bottomEl = bottomSentinelRef.current
|
|
46
|
+
if (!root || !topEl || !bottomEl) return
|
|
47
|
+
|
|
48
|
+
const topObserver = new IntersectionObserver(
|
|
49
|
+
([entry]) => setIsAtTop(entry?.isIntersecting ?? true),
|
|
50
|
+
{ root },
|
|
51
|
+
)
|
|
52
|
+
const bottomObserver = new IntersectionObserver(
|
|
53
|
+
([entry]) => setIsAtBottom(entry?.isIntersecting ?? true),
|
|
54
|
+
{ root },
|
|
55
|
+
)
|
|
56
|
+
topObserver.observe(topEl)
|
|
57
|
+
bottomObserver.observe(bottomEl)
|
|
58
|
+
return () => {
|
|
59
|
+
topObserver.disconnect()
|
|
60
|
+
bottomObserver.disconnect()
|
|
61
|
+
}
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!autoScroll) return
|
|
66
|
+
const root = scrollRef.current
|
|
67
|
+
if (!root) return
|
|
68
|
+
|
|
69
|
+
// We can't watch `children` to detect new content: tool traces and
|
|
70
|
+
// `useTypewriter` live inside child components and update via local
|
|
71
|
+
// state, so they mutate the DOM without re-rendering `ChatContent`.
|
|
72
|
+
// MutationObserver picks up every DOM change regardless of source.
|
|
73
|
+
let prevScrollHeight = root.scrollHeight
|
|
74
|
+
let rafId: number | null = null
|
|
75
|
+
|
|
76
|
+
const check = () => {
|
|
77
|
+
rafId = null
|
|
78
|
+
const newScrollHeight = root.scrollHeight
|
|
79
|
+
if (newScrollHeight > prevScrollHeight) {
|
|
80
|
+
const growth = newScrollHeight - prevScrollHeight
|
|
81
|
+
// If the distance from the bottom doesn't exceed the amount the
|
|
82
|
+
// content just grew (plus a small slack), the user was at the
|
|
83
|
+
// bottom *before* the growth — scroll them to the new bottom.
|
|
84
|
+
// Otherwise they've scrolled up to read history and we leave them
|
|
85
|
+
// alone.
|
|
86
|
+
const distanceFromBottom =
|
|
87
|
+
newScrollHeight - root.scrollTop - root.clientHeight
|
|
88
|
+
if (distanceFromBottom <= growth + AUTO_SCROLL_THRESHOLD) {
|
|
89
|
+
root.scrollTo({ top: newScrollHeight, behavior: 'smooth' })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
prevScrollHeight = newScrollHeight
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const mo = new MutationObserver(() => {
|
|
96
|
+
rafId ??= requestAnimationFrame(check)
|
|
97
|
+
})
|
|
98
|
+
mo.observe(root, {
|
|
99
|
+
childList: true,
|
|
100
|
+
subtree: true,
|
|
101
|
+
characterData: true,
|
|
102
|
+
})
|
|
103
|
+
return () => {
|
|
104
|
+
mo.disconnect()
|
|
105
|
+
if (rafId !== null) cancelAnimationFrame(rafId)
|
|
106
|
+
}
|
|
107
|
+
}, [autoScroll])
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Box
|
|
111
|
+
ref={scrollRef}
|
|
112
|
+
sx={{
|
|
113
|
+
...styles.content,
|
|
114
|
+
borderTopColor: ({ palette }) =>
|
|
115
|
+
isAtTop ? 'transparent' : palette.divider,
|
|
116
|
+
borderBottomColor: ({ palette }) =>
|
|
117
|
+
isAtBottom ? 'transparent' : palette.divider,
|
|
118
|
+
...sx,
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<Box ref={topSentinelRef} sx={styles.sentinel} aria-hidden />
|
|
122
|
+
{children}
|
|
123
|
+
<Box ref={bottomSentinelRef} sx={styles.sentinel} aria-hidden />
|
|
124
|
+
<Box sx={styles.jumpToLatestWrapper}>
|
|
125
|
+
<Fab
|
|
126
|
+
size='small'
|
|
127
|
+
onClick={scrollToBottom}
|
|
128
|
+
aria-label={labels.jumpToLatest ?? 'Jump to latest'}
|
|
129
|
+
sx={{
|
|
130
|
+
...styles.jumpToLatest,
|
|
131
|
+
opacity: isAtBottom ? 0 : 1,
|
|
132
|
+
pointerEvents: isAtBottom ? 'none' : 'auto',
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<KeyboardArrowDown />
|
|
136
|
+
</Fab>
|
|
137
|
+
</Box>
|
|
138
|
+
</Box>
|
|
139
|
+
)
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
ChatContent.displayName = 'ChatContent'
|