@carto/ps-react-ui 4.4.0-chat-ui.3 → 4.4.0-chat-ui.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carto/ps-react-ui",
3
- "version": "4.4.0-chat-ui.3",
3
+ "version": "4.4.0-chat-ui.5",
4
4
  "description": "CARTO's Professional Service React Material library",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -15,8 +15,8 @@
15
15
  "html2canvas": "1.4.1",
16
16
  "react-markdown": "10.1.0",
17
17
  "zustand": "5.0.11",
18
- "@carto/ps-common-types": "1.0.0",
19
- "@carto/ps-utils": "2.0.1"
18
+ "@carto/ps-utils": "2.0.1",
19
+ "@carto/ps-common-types": "1.0.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@dnd-kit/core": "^6.0.0",
@@ -45,7 +45,7 @@ export const styles = {
45
45
  footer: {
46
46
  maxWidth: CHAT_MAX_WIDTH,
47
47
  margin: '0 auto',
48
- '& textarea': {
48
+ '& .MuiInputBase-multiline.MuiInputBase-sizeSmall textarea': {
49
49
  resize: 'none',
50
50
  maxHeight: '10em',
51
51
  overflowY: 'auto !important',
package/src/chat/index.ts CHANGED
@@ -30,6 +30,9 @@ export {
30
30
  CHAT_TOOL_CODE_AREA_MAX_HEIGHT,
31
31
  } from './const'
32
32
 
33
+ // Hooks
34
+ export { useTypewriter } from './use-typewriter'
35
+
33
36
  // Messages
34
37
  export { ChatUserMessage } from './bubbles/chat-user-message'
35
38
  export { ChatAgentMessage } from './bubbles/chat-agent-message'
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { renderHook } from '@testing-library/react'
3
+ import { useTypewriter } from './use-typewriter'
4
+
5
+ describe('useTypewriter', () => {
6
+ test('starts with empty text and isTyping=true when fullText is non-empty', () => {
7
+ const { result } = renderHook(() => useTypewriter('hello'))
8
+ expect(result.current.displayedText).toBe('')
9
+ expect(result.current.isTyping).toBe(true)
10
+ })
11
+
12
+ test('returns the full text immediately when skipAnimation is true', () => {
13
+ const { result } = renderHook(() =>
14
+ useTypewriter('hello', { skipAnimation: true }),
15
+ )
16
+ expect(result.current.displayedText).toBe('hello')
17
+ expect(result.current.isTyping).toBe(false)
18
+ })
19
+
20
+ test('isTyping=false for empty fullText', () => {
21
+ const { result } = renderHook(() => useTypewriter(''))
22
+ expect(result.current.displayedText).toBe('')
23
+ expect(result.current.isTyping).toBe(false)
24
+ })
25
+
26
+ test('skipAnimation captured at mount — toggling later does not retrigger reveal', () => {
27
+ const { result, rerender } = renderHook(
28
+ ({ skip }: { skip: boolean }) =>
29
+ useTypewriter('hello', { skipAnimation: skip }),
30
+ { initialProps: { skip: true } },
31
+ )
32
+ expect(result.current.displayedText).toBe('hello')
33
+
34
+ rerender({ skip: false })
35
+ expect(result.current.displayedText).toBe('hello')
36
+ expect(result.current.isTyping).toBe(false)
37
+ })
38
+ })
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ interface UseTypewriterOptions {
4
+ /** Characters revealed per second (default: `500`). */
5
+ speed?: number
6
+ /** When true on mount, skip the animation and reveal the full text immediately. */
7
+ skipAnimation?: boolean
8
+ }
9
+
10
+ interface UseTypewriterResult {
11
+ /** The portion of `fullText` revealed so far. */
12
+ displayedText: string
13
+ /** `true` while characters are still being revealed. */
14
+ isTyping: boolean
15
+ }
16
+
17
+ /**
18
+ * Reveals a string character-by-character at a steady rate via
19
+ * `requestAnimationFrame`. Useful for smoothing out bursty WebSocket-streamed
20
+ * agent message text — pair it with `ChatAgentMessage`.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * const { displayedText, isTyping } = useTypewriter(message)
25
+ * return (
26
+ * <ChatAgentMessage>
27
+ * <Markdown>{displayedText}</Markdown>
28
+ * {isTyping ? <Cursor /> : null}
29
+ * </ChatAgentMessage>
30
+ * )
31
+ * ```
32
+ */
33
+ export function useTypewriter(
34
+ fullText: string,
35
+ options: UseTypewriterOptions = {},
36
+ ): UseTypewriterResult {
37
+ const { speed = 500, skipAnimation = false } = options
38
+
39
+ const skipRef = useRef(skipAnimation)
40
+
41
+ const [charIndex, setCharIndex] = useState(() =>
42
+ skipRef.current ? fullText.length : 0,
43
+ )
44
+
45
+ useEffect(() => {
46
+ if (skipRef.current) {
47
+ setCharIndex(fullText.length)
48
+ return
49
+ }
50
+
51
+ if (charIndex >= fullText.length) {
52
+ return
53
+ }
54
+
55
+ const msPerChar = 1000 / speed
56
+ let rafId: number
57
+ let lastTime: number | null = null
58
+
59
+ function tick(timestamp: number) {
60
+ lastTime ??= timestamp
61
+
62
+ const elapsed = timestamp - lastTime
63
+ const charsToAdd = Math.floor(elapsed / msPerChar)
64
+
65
+ if (charsToAdd > 0) {
66
+ lastTime = timestamp
67
+ setCharIndex((prev) => Math.min(prev + charsToAdd, fullText.length))
68
+ }
69
+
70
+ rafId = requestAnimationFrame(tick)
71
+ }
72
+
73
+ rafId = requestAnimationFrame(tick)
74
+
75
+ return () => cancelAnimationFrame(rafId)
76
+ }, [fullText, charIndex, speed])
77
+
78
+ return {
79
+ displayedText: fullText.slice(0, charIndex),
80
+ isTyping: charIndex < fullText.length,
81
+ }
82
+ }