@carto/ps-react-ui 4.4.0-chat-ui.0 → 4.4.0-chat-ui.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/chat.js +1073 -390
  2. package/dist/chat.js.map +1 -1
  3. package/dist/components.js +141 -159
  4. package/dist/components.js.map +1 -1
  5. package/dist/copy-button-DGL1tyli.js +26 -0
  6. package/dist/copy-button-DGL1tyli.js.map +1 -0
  7. package/dist/index-BnyeR7Qx.js +6601 -0
  8. package/dist/index-BnyeR7Qx.js.map +1 -0
  9. package/dist/types/chat/const.d.ts +1 -0
  10. package/dist/types/chat/containers/styles.d.ts +3 -0
  11. package/dist/types/chat/feedback/chat-tool-code-area.d.ts +4 -0
  12. package/dist/types/chat/feedback/chat-tool-full-view-dialog.d.ts +2 -0
  13. package/dist/types/chat/feedback/chat-tool-group.d.ts +2 -0
  14. package/dist/types/chat/feedback/chat-tool-trace.d.ts +3 -0
  15. package/dist/types/chat/feedback/get-tool-label.d.ts +2 -0
  16. package/dist/types/chat/feedback/index.d.ts +4 -1
  17. package/dist/types/chat/feedback/styles.d.ts +149 -3
  18. package/dist/types/chat/index.d.ts +6 -3
  19. package/dist/types/chat/types.d.ts +58 -5
  20. package/dist/widgets/toolbar-actions.js +101 -6693
  21. package/dist/widgets/toolbar-actions.js.map +1 -1
  22. package/package.json +3 -3
  23. package/src/chat/const.ts +1 -0
  24. package/src/chat/containers/styles.ts +3 -0
  25. package/src/chat/feedback/chat-tool-code-area.test.tsx +23 -0
  26. package/src/chat/feedback/chat-tool-code-area.tsx +71 -0
  27. package/src/chat/feedback/chat-tool-full-view-dialog.test.tsx +39 -0
  28. package/src/chat/feedback/chat-tool-full-view-dialog.tsx +121 -0
  29. package/src/chat/feedback/chat-tool-group.test.tsx +84 -0
  30. package/src/chat/feedback/chat-tool-group.tsx +156 -0
  31. package/src/chat/feedback/chat-tool-trace.test.tsx +81 -0
  32. package/src/chat/feedback/chat-tool-trace.tsx +187 -0
  33. package/src/chat/feedback/get-tool-label.test.tsx +91 -0
  34. package/src/chat/feedback/get-tool-label.ts +13 -0
  35. package/src/chat/feedback/index.ts +4 -1
  36. package/src/chat/feedback/styles.ts +153 -4
  37. package/src/chat/index.ts +14 -3
  38. package/src/chat/types.ts +64 -5
  39. package/dist/types/chat/feedback/chat-tools.d.ts +0 -2
  40. package/src/chat/feedback/chat-tools.test.tsx +0 -23
  41. package/src/chat/feedback/chat-tools.tsx +0 -54
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carto/ps-react-ui",
3
- "version": "4.4.0-chat-ui.0",
3
+ "version": "4.4.0-chat-ui.2",
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-utils": "2.0.1",
19
- "@carto/ps-common-types": "1.0.0"
18
+ "@carto/ps-common-types": "1.0.0",
19
+ "@carto/ps-utils": "2.0.1"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@dnd-kit/core": "^6.0.0",
package/src/chat/const.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export const CHAT_MAX_WIDTH = 768
2
2
  export const CHAT_SCROLL_DELAY = 300
3
3
  export const CHAT_DIVIDER_DELAY = 100
4
+ export const CHAT_TOOL_CODE_AREA_MAX_HEIGHT = 126
@@ -85,6 +85,9 @@ export const styles = {
85
85
  animation: `${showFab} linear both`,
86
86
  animationTimeline: 'scroll(nearest)',
87
87
  },
88
+ '@supports not (animation-timeline: scroll())': {
89
+ display: 'none',
90
+ },
88
91
  },
89
92
  starter: {
90
93
  display: 'flex',
@@ -0,0 +1,23 @@
1
+ import { describe, test, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { ChatToolCodeArea } from './chat-tool-code-area'
4
+
5
+ describe('ChatToolCodeArea', () => {
6
+ test('renders code content', () => {
7
+ render(<ChatToolCodeArea content='{"status": 200}' />)
8
+ expect(screen.getByText('{"status": 200}')).toBeTruthy()
9
+ })
10
+
11
+ test('applies error styling via isError prop', () => {
12
+ const { container } = render(
13
+ <ChatToolCodeArea content='Error output' isError />,
14
+ )
15
+ const pre = container.querySelector('pre')
16
+ expect(pre).toBeTruthy()
17
+ })
18
+
19
+ test('does not show Full view button when content fits', () => {
20
+ render(<ChatToolCodeArea content='short' />)
21
+ expect(screen.queryByText('Full view')).toBeNull()
22
+ })
23
+ })
@@ -0,0 +1,71 @@
1
+ import { useRef, useState, useEffect, useCallback } from 'react'
2
+ import { Box, IconButton, Tooltip } from '@mui/material'
3
+ import type { ChatToolCodeAreaProps } from '../types'
4
+ import { CHAT_TOOL_CODE_AREA_MAX_HEIGHT } from '../const'
5
+ import { ChatToolFullViewDialog } from './chat-tool-full-view-dialog'
6
+ import { styles } from './styles'
7
+ import { OpenDiagonallyRight } from '@carto/meridian-ds/custom-icons'
8
+
9
+ export function ChatToolCodeArea({
10
+ content,
11
+ title = '',
12
+ isError = false,
13
+ labels = {},
14
+ sx,
15
+ }: ChatToolCodeAreaProps & { isError?: boolean }) {
16
+ const preRef = useRef<HTMLPreElement>(null)
17
+ const [isOverflowing, setIsOverflowing] = useState(false)
18
+ const [dialogOpen, setDialogOpen] = useState(false)
19
+
20
+ const checkOverflow = useCallback(() => {
21
+ const el = preRef.current
22
+ if (el) {
23
+ setIsOverflowing(el.scrollHeight > el.clientHeight)
24
+ }
25
+ }, [])
26
+
27
+ useEffect(() => {
28
+ checkOverflow()
29
+ const el = preRef.current
30
+ if (!el) return
31
+
32
+ const observer = new ResizeObserver(checkOverflow)
33
+ observer.observe(el)
34
+ return () => observer.disconnect()
35
+ }, [checkOverflow, content])
36
+
37
+ const fullViewLabel = labels.fullView ?? 'Full view'
38
+
39
+ return (
40
+ <Box sx={{ ...styles.codeArea, ...sx }}>
41
+ <Box
42
+ component='pre'
43
+ ref={preRef}
44
+ sx={{
45
+ ...styles.codeAreaPre,
46
+ maxHeight: CHAT_TOOL_CODE_AREA_MAX_HEIGHT,
47
+ ...(isError ? styles.codeAreaPreError : {}),
48
+ }}
49
+ >
50
+ {content}
51
+ </Box>
52
+ {isOverflowing && (
53
+ <Tooltip title={fullViewLabel}>
54
+ <IconButton
55
+ size='small'
56
+ onClick={() => setDialogOpen(true)}
57
+ sx={styles.codeAreaFullViewButton}
58
+ >
59
+ <OpenDiagonallyRight />
60
+ </IconButton>
61
+ </Tooltip>
62
+ )}
63
+ <ChatToolFullViewDialog
64
+ open={dialogOpen}
65
+ onClose={() => setDialogOpen(false)}
66
+ title={title}
67
+ content={content}
68
+ />
69
+ </Box>
70
+ )
71
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { ChatToolFullViewDialog } from './chat-tool-full-view-dialog'
4
+
5
+ describe('ChatToolFullViewDialog', () => {
6
+ const defaultProps = {
7
+ open: true,
8
+ onClose: vi.fn(),
9
+ title: 'Tool executed: Output',
10
+ content: '{"status": 200,\n"data": "test"}',
11
+ }
12
+
13
+ test('renders dialog with title when open', () => {
14
+ render(<ChatToolFullViewDialog {...defaultProps} />)
15
+ expect(screen.getByText('Tool executed: Output')).toBeTruthy()
16
+ })
17
+
18
+ test('renders content with line numbers', () => {
19
+ render(<ChatToolFullViewDialog {...defaultProps} />)
20
+ // NOTE: need to query outside of component to reach dialog portal
21
+ const pre = document.querySelector('pre')
22
+ expect(pre).toBeTruthy()
23
+ // NOTE: json text is tokenized and split into various tokens so we cannot reach it fully with `queryByText`
24
+ expect(pre!.textContent).toContain('{"status": 200,')
25
+ expect(pre!.textContent).toContain('"data": "test"}')
26
+ })
27
+
28
+ test('does not render content when closed', () => {
29
+ render(<ChatToolFullViewDialog {...defaultProps} open={false} />)
30
+ expect(screen.queryByText('Tool executed: Output')).toBeNull()
31
+ })
32
+
33
+ test('calls onClose when close button is clicked', () => {
34
+ const onClose = vi.fn()
35
+ render(<ChatToolFullViewDialog {...defaultProps} onClose={onClose} />)
36
+ fireEvent.click(screen.getByLabelText('close'))
37
+ expect(onClose).toHaveBeenCalledOnce()
38
+ })
39
+ })
@@ -0,0 +1,121 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ Dialog,
4
+ DialogTitle,
5
+ DialogContent,
6
+ IconButton,
7
+ Box,
8
+ } from '@mui/material'
9
+ import { Close } from '@mui/icons-material'
10
+ import { CopyButton } from '../../components/copy-button/copy-button'
11
+ import type { ChatToolFullViewDialogProps } from '../types'
12
+ import { styles } from './styles'
13
+
14
+ interface Token {
15
+ type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'punctuation'
16
+ value: string
17
+ }
18
+
19
+ const NUMBER_START_REGEX = /^-?\d/
20
+
21
+ function tokenizeLine(line: string): (Token | string)[] {
22
+ const tokens: (Token | string)[] = []
23
+ let lastIndex = 0
24
+ let match: RegExpExecArray | null
25
+
26
+ // First, find all key positions by looking for "...": patterns
27
+ const keyPositions = new Set<number>()
28
+ const keyRegex = /("(?:[^"\\]|\\.)*")\s*:/g
29
+ let keyMatch: RegExpExecArray | null
30
+ while ((keyMatch = keyRegex.exec(line)) !== null) {
31
+ keyPositions.add(keyMatch.index)
32
+ }
33
+
34
+ const tokenRegex = // eslint-disable-next-line no-useless-escape
35
+ /"(?:[^"\\]|\\.)*"|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}\[\]:,]/g
36
+
37
+ while ((match = tokenRegex.exec(line)) !== null) {
38
+ if (match.index > lastIndex) {
39
+ tokens.push(line.slice(lastIndex, match.index))
40
+ }
41
+ const value = match[0]
42
+ if (value.startsWith('"')) {
43
+ if (keyPositions.has(match.index)) {
44
+ tokens.push({ type: 'key', value })
45
+ } else {
46
+ tokens.push({ type: 'string', value })
47
+ }
48
+ } else if (NUMBER_START_REGEX.test(value)) {
49
+ tokens.push({ type: 'number', value })
50
+ } else if (value === 'true' || value === 'false') {
51
+ tokens.push({ type: 'boolean', value })
52
+ } else if (value === 'null') {
53
+ tokens.push({ type: 'null', value })
54
+ } else {
55
+ tokens.push({ type: 'punctuation', value })
56
+ }
57
+ lastIndex = match.index + value.length
58
+ }
59
+
60
+ if (lastIndex < line.length) {
61
+ tokens.push(line.slice(lastIndex))
62
+ }
63
+
64
+ return tokens
65
+ }
66
+
67
+ export function ChatToolFullViewDialog({
68
+ open,
69
+ onClose,
70
+ title,
71
+ content,
72
+ }: ChatToolFullViewDialogProps) {
73
+ const tokenizedLines = useMemo(
74
+ () => content.split('\n').map((line) => tokenizeLine(line)),
75
+ [content],
76
+ )
77
+
78
+ return (
79
+ <Dialog
80
+ open={open}
81
+ onClose={onClose}
82
+ sx={styles.fullViewDialog}
83
+ PaperProps={{ sx: styles.fullViewPaper }}
84
+ fullScreen
85
+ >
86
+ <DialogTitle sx={styles.fullViewTitle}>
87
+ {title}
88
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
89
+ <CopyButton copyText={content} aria-label='Copy content' />
90
+ <IconButton onClick={onClose} aria-label='close'>
91
+ <Close />
92
+ </IconButton>
93
+ </Box>
94
+ </DialogTitle>
95
+ <DialogContent sx={styles.fullViewDialogContent}>
96
+ <Box component='pre' sx={styles.fullViewPre}>
97
+ {tokenizedLines.map((tokens, i) => (
98
+ <Box component='span' key={i} sx={styles.fullViewLine}>
99
+ {tokens.map((token, j) =>
100
+ typeof token === 'string' ? (
101
+ token
102
+ ) : (
103
+ <Box
104
+ component='span'
105
+ key={j}
106
+ sx={
107
+ styles[`syntaxToken_${token.type}` as keyof typeof styles]
108
+ }
109
+ >
110
+ {token.value}
111
+ </Box>
112
+ ),
113
+ )}
114
+ {'\n'}
115
+ </Box>
116
+ ))}
117
+ </Box>
118
+ </DialogContent>
119
+ </Dialog>
120
+ )
121
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { ChatToolGroup } from './chat-tool-group'
4
+ import type { ChatToolItem } from '../types'
5
+
6
+ describe('ChatToolGroup', () => {
7
+ const tools: ChatToolItem[] = [
8
+ {
9
+ id: '1',
10
+ name: 'Tool #1',
11
+ status: 'complete',
12
+ reference: 'tool_1',
13
+ duration: 1.2,
14
+ },
15
+ {
16
+ id: '2',
17
+ name: 'Tool #2',
18
+ status: 'complete',
19
+ reference: 'tool_2',
20
+ duration: 0.8,
21
+ },
22
+ {
23
+ id: '3',
24
+ name: 'Tool #3',
25
+ status: 'error',
26
+ reference: 'tool_3',
27
+ duration: 2.1,
28
+ },
29
+ ]
30
+
31
+ test('renders "N tools used" label when collapsed', () => {
32
+ render(<ChatToolGroup tools={tools} />)
33
+ expect(screen.getByText('3 tools used')).toBeTruthy()
34
+ })
35
+
36
+ test('renders individual tool names when expanded', () => {
37
+ render(<ChatToolGroup tools={tools} expanded />)
38
+ expect(screen.getByText(/Tool #1/)).toBeTruthy()
39
+ expect(screen.getByText(/Tool #2/)).toBeTruthy()
40
+ expect(screen.getByText(/Tool #3/)).toBeTruthy()
41
+ })
42
+
43
+ test('shows "Error" label on failed tools in the expanded list', () => {
44
+ render(<ChatToolGroup tools={tools} expanded />)
45
+ expect(screen.getByText('Error')).toBeTruthy()
46
+ })
47
+
48
+ test('shows error count badge at group level when any tool has error', () => {
49
+ render(<ChatToolGroup tools={tools} />)
50
+ // New UI shows error count ("1") + error icon instead of "Error" text
51
+ expect(screen.getByTestId('ErrorOutlineIcon')).toBeTruthy()
52
+ expect(screen.getByText('1')).toBeTruthy()
53
+ })
54
+
55
+ test('calls onExpandedChange when group header is clicked', () => {
56
+ const onChange = vi.fn()
57
+ render(<ChatToolGroup tools={tools} onExpandedChange={onChange} />)
58
+ const summary = screen.getByText('3 tools used').closest('button')
59
+ if (summary) fireEvent.click(summary)
60
+ expect(onChange).toHaveBeenCalled()
61
+ })
62
+
63
+ test('preserves individual tool expansion states via expandedTools prop', () => {
64
+ render(
65
+ <ChatToolGroup
66
+ tools={tools}
67
+ expanded
68
+ expandedTools={{ '1': true, '2': false, '3': false }}
69
+ />,
70
+ )
71
+ // Tool #1 should be expanded (shows its reference chip)
72
+ expect(screen.getByText(/tool_1/)).toBeTruthy()
73
+ })
74
+
75
+ test('uses custom toolsUsed label', () => {
76
+ render(
77
+ <ChatToolGroup
78
+ tools={tools}
79
+ labels={{ toolsUsed: 'herramientas usadas' }}
80
+ />,
81
+ )
82
+ expect(screen.getByText('3 herramientas usadas')).toBeTruthy()
83
+ })
84
+ })
@@ -0,0 +1,156 @@
1
+ import { Box, Button, Collapse, Typography } from '@mui/material'
2
+ import { ArrowRight, ChevronRight, ErrorOutline } from '@mui/icons-material'
3
+ import type { ChatToolGroupProps } from '../types'
4
+ import { ChatToolTrace, ChatToolTraceDetails } from './chat-tool-trace'
5
+ import { styles } from './styles'
6
+ import { getToolLabel } from './get-tool-label'
7
+ import { useState } from 'react'
8
+
9
+ function useController<T>(value: T, setter?: (value: T) => void) {
10
+ const [_value, _setter] = useState<T>(value)
11
+ if (setter) {
12
+ return [value, setter] as const
13
+ } else {
14
+ return [_value, _setter] as const
15
+ }
16
+ }
17
+
18
+ export function ChatToolGroup({
19
+ tools,
20
+ expanded: _expanded = false,
21
+ onExpandedChange: _setExpanded,
22
+ expandedTools: _expandedTools = {},
23
+ onToolExpandedChange: _setExpandedTools,
24
+ labels = {},
25
+ sx,
26
+ }: ChatToolGroupProps) {
27
+ const [expanded, setExpanded] = useController(_expanded, _setExpanded)
28
+ const [expandedTools, setExpandedTools] = useController(
29
+ _expandedTools,
30
+ _setExpandedTools,
31
+ )
32
+
33
+ const numErrors = tools.filter((t) => t.status === 'error').length
34
+ const runningTools = tools.filter((t) => t.status === 'running')
35
+ const nonRunningTools = tools.filter((t) => t.status !== 'running')
36
+ const toolsUsedLabel = labels.toolsUsed ?? 'tools used'
37
+ const errorLabel = labels.error ?? 'Error'
38
+
39
+ if (tools.length === 1) {
40
+ return (
41
+ <ChatToolTrace
42
+ tool={tools[0]!}
43
+ expanded={expanded}
44
+ onExpandedChange={setExpanded}
45
+ labels={labels}
46
+ sx={sx}
47
+ />
48
+ )
49
+ }
50
+
51
+ return (
52
+ <Box width='100%' sx={sx} className='PsChat--tool-group'>
53
+ <Button
54
+ size='small'
55
+ variant='text'
56
+ onClick={() => setExpanded(!expanded)}
57
+ aria-expanded={expanded}
58
+ sx={{
59
+ ...styles.traceHeader,
60
+ pr: numErrors > 0 ? 0.5 : 0,
61
+ }}
62
+ >
63
+ <Typography variant='caption' fontWeight={600} color='text.secondary'>
64
+ {tools.length} {toolsUsedLabel}
65
+ </Typography>
66
+ <ArrowRight
67
+ sx={{
68
+ ...styles.traceChevron,
69
+ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
70
+ }}
71
+ />
72
+ {numErrors > 0 && (
73
+ <Typography variant='code3' sx={styles.errorBadge}>
74
+ {numErrors}
75
+ <ErrorOutline sx={{ fontSize: 12 }} color='error' />
76
+ </Typography>
77
+ )}
78
+ </Button>
79
+ <Collapse in={expanded} unmountOnExit>
80
+ <Box
81
+ component='ul'
82
+ sx={{
83
+ ...styles.traceDetailsWrapper,
84
+ p: 0,
85
+ listStyle: 'none',
86
+ }}
87
+ >
88
+ {nonRunningTools.map((tool) => {
89
+ const isExpanded = expandedTools[tool.id] ?? false
90
+ return (
91
+ <Box
92
+ key={tool.id}
93
+ component='li'
94
+ sx={styles.groupListItem}
95
+ aria-expanded={isExpanded}
96
+ >
97
+ <Button
98
+ variant='text'
99
+ color='inherit'
100
+ fullWidth
101
+ onClick={() =>
102
+ setExpandedTools({
103
+ ...expandedTools,
104
+ [tool.id]: !isExpanded,
105
+ })
106
+ }
107
+ aria-expanded={isExpanded}
108
+ sx={styles.groupHeader}
109
+ >
110
+ <Typography
111
+ flexGrow={1}
112
+ flexShrink={0}
113
+ variant='caption'
114
+ color='text.secondary'
115
+ >
116
+ {getToolLabel(tool)}
117
+ </Typography>
118
+ {tool.status === 'error' && (
119
+ <Typography variant='code3' color='error.main'>
120
+ {errorLabel}
121
+ </Typography>
122
+ )}
123
+ <ChevronRight
124
+ sx={{
125
+ ...styles.traceChevron,
126
+ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
127
+ }}
128
+ />
129
+ </Button>
130
+ <Collapse in={isExpanded} unmountOnExit>
131
+ <Box px={1.5} py={1}>
132
+ <ChatToolTraceDetails tool={tool} labels={labels} />
133
+ </Box>
134
+ </Collapse>
135
+ </Box>
136
+ )
137
+ })}
138
+ </Box>
139
+ </Collapse>
140
+ {runningTools.map((tool) => (
141
+ <ChatToolTrace
142
+ key={tool.id}
143
+ tool={tool}
144
+ labels={labels}
145
+ expanded={expandedTools[tool.id]}
146
+ onExpandedChange={() =>
147
+ setExpandedTools({
148
+ ...expandedTools,
149
+ [tool.id]: !expandedTools[tool.id],
150
+ })
151
+ }
152
+ />
153
+ ))}
154
+ </Box>
155
+ )
156
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, test, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { ChatToolTrace } from './chat-tool-trace'
4
+ import type { ChatToolItem } from '../types'
5
+
6
+ describe('ChatToolTrace', () => {
7
+ const completeTool: ChatToolItem = {
8
+ id: '1',
9
+ name: 'add_marker',
10
+ status: 'complete',
11
+ reference: 'add_marker',
12
+ duration: 1.8,
13
+ inputArguments:
14
+ '{"latitude": 39.610861,\n"label": "Calle Toledo 1, Ciudad Real",\n"longitude": -4.2393630}',
15
+ output: '{ "success": true, "data": {} }',
16
+ }
17
+
18
+ const runningTool: ChatToolItem = {
19
+ id: '2',
20
+ name: 'execute_sql',
21
+ status: 'running',
22
+ }
23
+
24
+ const errorTool: ChatToolItem = {
25
+ id: '3',
26
+ name: 'execute_sql',
27
+ status: 'error',
28
+ reference: 'execute_sql',
29
+ duration: 1.8,
30
+ inputArguments: '{"datasetId": "abc123"}',
31
+ output: 'HTTP 400: Bad Request\n{"status":400,"error":"Syntax error"}',
32
+ }
33
+
34
+ test('renders "Tool executed" label for completed tools', () => {
35
+ render(<ChatToolTrace tool={completeTool} />)
36
+ expect(screen.getByText('Tool executed')).toBeTruthy()
37
+ })
38
+
39
+ test('renders reference name when provided', () => {
40
+ render(<ChatToolTrace tool={completeTool} expanded />)
41
+ expect(screen.getByText('add_marker')).toBeTruthy()
42
+ })
43
+
44
+ test('renders duration when provided', () => {
45
+ render(<ChatToolTrace tool={completeTool} expanded />)
46
+ expect(screen.getByText('1.8s')).toBeTruthy()
47
+ })
48
+
49
+ test('renders Success status for completed tools', () => {
50
+ render(<ChatToolTrace tool={completeTool} expanded />)
51
+ expect(screen.getByText('Success')).toBeTruthy()
52
+ })
53
+
54
+ test('renders Error status for failed tools', () => {
55
+ render(<ChatToolTrace tool={errorTool} expanded />)
56
+ expect(screen.getByText('Error')).toBeTruthy()
57
+ })
58
+
59
+ test('is NOT expandable when status is running', () => {
60
+ const { container } = render(<ChatToolTrace tool={runningTool} />)
61
+ expect(container.querySelector('button')).toBeNull()
62
+ })
63
+
64
+ test('renders input arguments code area', () => {
65
+ render(<ChatToolTrace tool={completeTool} expanded />)
66
+ expect(screen.getByText(/latitude/)).toBeTruthy()
67
+ })
68
+
69
+ test('renders output code area', () => {
70
+ render(<ChatToolTrace tool={completeTool} expanded />)
71
+ expect(screen.getByText(/success/)).toBeTruthy()
72
+ })
73
+
74
+ test('calls onExpandedChange callback', () => {
75
+ const onChange = vi.fn()
76
+ render(<ChatToolTrace tool={completeTool} onExpandedChange={onChange} />)
77
+ const summary = screen.getByText('Tool executed').closest('button')
78
+ if (summary) fireEvent.click(summary)
79
+ expect(onChange).toHaveBeenCalled()
80
+ })
81
+ })