@stack-spot/ai-chat-widget 2.0.0 → 3.0.0-beta.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/app-metadata.json +7 -7
  3. package/dist/chat-interceptors/send-message.d.ts.map +1 -1
  4. package/dist/chat-interceptors/send-message.js +78 -0
  5. package/dist/chat-interceptors/send-message.js.map +1 -1
  6. package/dist/state/ChatState.d.ts +4 -0
  7. package/dist/state/ChatState.d.ts.map +1 -1
  8. package/dist/state/ChatState.js.map +1 -1
  9. package/dist/utils/date.d.ts +1 -2
  10. package/dist/utils/date.d.ts.map +1 -1
  11. package/dist/utils/date.js +3 -3
  12. package/dist/utils/date.js.map +1 -1
  13. package/dist/utils/error.js +2 -2
  14. package/dist/utils/error.js.map +1 -1
  15. package/dist/utils/planning-tool.d.ts +14 -0
  16. package/dist/utils/planning-tool.d.ts.map +1 -0
  17. package/dist/utils/planning-tool.js +25 -0
  18. package/dist/utils/planning-tool.js.map +1 -0
  19. package/dist/utils/update-tool-step.d.ts +3 -0
  20. package/dist/utils/update-tool-step.d.ts.map +1 -0
  21. package/dist/utils/update-tool-step.js +23 -0
  22. package/dist/utils/update-tool-step.js.map +1 -0
  23. package/dist/views/Chat/ChatMessage.d.ts +1 -1
  24. package/dist/views/Chat/ChatMessage.d.ts.map +1 -1
  25. package/dist/views/Chat/ChatMessage.js +4 -3
  26. package/dist/views/Chat/ChatMessage.js.map +1 -1
  27. package/dist/views/Chat/StepsList.d.ts +6 -1
  28. package/dist/views/Chat/StepsList.d.ts.map +1 -1
  29. package/dist/views/Chat/StepsList.js +118 -13
  30. package/dist/views/Chat/StepsList.js.map +1 -1
  31. package/dist/views/Chat/styled.d.ts.map +1 -1
  32. package/dist/views/Chat/styled.js +13 -6
  33. package/dist/views/Chat/styled.js.map +1 -1
  34. package/dist/views/MessageInput/dictionary.d.ts +1 -1
  35. package/dist/views/Steps/FlowChart/NodeStep.js +1 -1
  36. package/dist/views/Steps/FlowChart/NodeStep.js.map +1 -1
  37. package/dist/views/Steps/FlowChart/layout.d.ts +1 -1
  38. package/dist/views/Steps/FlowChart/layout.d.ts.map +1 -1
  39. package/dist/views/Steps/FlowChart/layout.js +1 -0
  40. package/dist/views/Steps/FlowChart/layout.js.map +1 -1
  41. package/dist/views/Steps/FlowChart/types.d.ts +1 -1
  42. package/dist/views/Steps/FlowChart/types.d.ts.map +1 -1
  43. package/dist/views/Steps/StepModal.js +2 -2
  44. package/dist/views/Steps/StepModal.js.map +1 -1
  45. package/dist/views/Steps/dictionary.d.ts +1 -1
  46. package/dist/views/Steps/utils.d.ts +1 -1
  47. package/dist/views/Steps/utils.d.ts.map +1 -1
  48. package/package.json +6 -6
  49. package/src/app-metadata.json +7 -7
  50. package/src/chat-interceptors/send-message.ts +91 -1
  51. package/src/state/ChatState.ts +4 -0
  52. package/src/utils/date.ts +3 -3
  53. package/src/utils/error.ts +2 -2
  54. package/src/utils/planning-tool.ts +32 -0
  55. package/src/utils/update-tool-step.tsx +27 -0
  56. package/src/views/Chat/ChatMessage.tsx +8 -4
  57. package/src/views/Chat/StepsList.tsx +284 -31
  58. package/src/views/Chat/styled.ts +13 -6
  59. package/src/views/Steps/FlowChart/NodeStep.tsx +1 -1
  60. package/src/views/Steps/FlowChart/layout.ts +1 -0
  61. package/src/views/Steps/FlowChart/types.ts +1 -1
  62. package/src/views/Steps/StepModal.tsx +2 -2
@@ -1,12 +1,17 @@
1
- import { Icon } from '@stack-spot/citric-icons'
2
- import { Button, ProgressCircular, Text } from '@stack-spot/citric-react'
1
+ import { Accordion, Badge, Button, Card, Column, Divider, Icon, IconBox, ImageWithFallback, ProgressCircular, Row, Skeleton, Text } from '@stack-spot/citric-react'
3
2
  import { AnimatedHeight } from '@stack-spot/portal-components/AnimatedHeight'
4
- import { ChatStep, StepChatStep } from '@stack-spot/portal-network'
3
+ import { ChatStep, StepChatStep, ToolChatStep } from '@stack-spot/portal-network'
5
4
  import { theme } from '@stack-spot/portal-theme'
6
5
  import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
7
6
  import { findLastIndex } from 'lodash'
8
- import { useState } from 'react'
9
- import { useWidget } from '../../context/hooks'
7
+ import React, { useMemo } from 'react'
8
+ import styled from 'styled-components'
9
+ import { Markdown } from '../../components/Markdown'
10
+ import { useCurrentChat, useCurrentChatMessages, useWidget } from '../../context/hooks'
11
+ import { ChatEntry } from '../../state/ChatEntry'
12
+ import { planningToolDictionaryHelper } from '../../utils/planning-tool'
13
+ import { updateToolStep } from '../../utils/update-tool-step'
14
+ import { onCopyCode } from './events'
10
15
 
11
16
  interface Props {
12
17
  steps: ChatStep[],
@@ -14,39 +19,243 @@ interface Props {
14
19
  chatId: string,
15
20
  }
16
21
 
22
+ interface StepChatStepWithTarget extends Omit<StepChatStep, 'status' | 'id' | 'type'> {
23
+ status: 'pending' | 'running' | 'success' | 'error' | 'target' | 'awaiting_approval',
24
+ }
25
+
17
26
  interface StepProps {
18
- step: StepChatStep,
27
+ step: StepChatStepWithTarget,
19
28
  index: number,
20
- total: number,
29
+ total?: number,
30
+ totalTools?: number,
31
+ isAllDone?: boolean,
21
32
  onClick?: () => void,
22
33
  }
23
34
 
24
- function getStatusIcon(status: ChatStep['status']) {
35
+ function getStatusIcon(status: StepChatStepWithTarget['status'] | 'target', isDone?: boolean) {
25
36
  const iconProps = { style: { color: theme.color.light[700] }, size: 'xs' } as const
26
37
  switch (status) {
27
38
  case 'error': return <Icon group="fill" icon="TimesCircle" {...iconProps} />
28
39
  case 'success': return <Icon group="fill" icon="CheckCircle" {...iconProps} />
29
- case 'pending': return <Icon icon="Spaces" {...iconProps} />
40
+ case 'pending': return <Icon group="fill" icon="Circle" {...iconProps} style={{ color: theme.color.light[600] }} />
41
+ case 'awaiting_approval': return <Icon group="fill" icon="ExclamationTriangle" {...iconProps} />
42
+ case 'target': return <Icon icon="Target" {...iconProps} style={{ color: isDone ? theme.color.light[700] : theme.color.light[600] }} />
30
43
  case 'running': return <ProgressCircular className="loading" colorScheme="inverse" size="xs" />
31
44
  }
32
45
  }
33
46
 
34
- const Step = ({ step, index, total, onClick }: StepProps) => {
47
+ const StepAccordionHeader = ({ step, index, expand }: Pick<StepProps, 'step' | 'index'> & { expand: React.ReactElement }) => {
35
48
  const t = useTranslate(dictionary)
49
+ return <Row gap="8px">
50
+ {expand}
51
+ {step.status === 'target' ? <Text className="step-title" appearance="body2" color="light.700">{t.planGoal}:</Text> :
52
+ <Badge colorScheme="inverse" appearance="square">
53
+ {t.step} {index}
54
+ </Badge>}
55
+ <Text className="step-title" appearance="body2" >
56
+ {step.input}
57
+ </Text>
58
+ {step.status === 'awaiting_approval' &&
59
+ <Badge appearance="square" style={{ backgroundColor: theme.color.gray[800], color: theme.color.gray[50] }}>
60
+ <Icon icon="Security" />
61
+ {t.pendingReview}
62
+ </Badge>}
63
+ </Row>
64
+ }
65
+
66
+ const StyledCard = styled(Card)`
67
+ &:hover {
68
+ background-color: ${theme.color.light[500]}
69
+ }
70
+ `
71
+
72
+ const Step = ({ step, index, onClick, total, totalTools, isAllDone }: StepProps) => {
73
+ const t = useTranslate(dictionary)
74
+ const status = getStatusIcon(step.status, isAllDone)
75
+
36
76
  return (
37
77
  <li tabIndex={onClick ? 0 : undefined} onClick={onClick} role={onClick ? 'button' : 'listitem'}>
38
- <div className="step-status-icon">{getStatusIcon(step.status)}</div>
39
- <Text className="step-title" appearance="microtext1" color="light.700">
40
- {t.step} {index}/{total}: {step.input}
41
- </Text>
78
+ <Row gap="4px" alignItems="center">
79
+ <div className="step-status-icon">{status}</div>
80
+ <StyledCard p="8px" w="75%">
81
+ <Accordion header={expand => <StepAccordionHeader step={step} index={index} expand={expand} />}>
82
+ <Column pt="12px">
83
+ {total ?
84
+ <Row gap="40px">
85
+ <Row gap="4px">
86
+ <Icon icon="Hashtag" size="sm" color="light.700" />
87
+ <Text color="light.700">{t.totalSteps}</Text>
88
+ {total}
89
+ </Row>
90
+ <Row gap="4px">
91
+ <Icon icon="BorderRadius" size="sm" color="light.700" />
92
+ <Text color="light.700">{t.totalTools}</Text>
93
+ {totalTools}
94
+ </Row>
95
+ </Row>
96
+ : <>
97
+ <Row pb="8px" gap="4px">
98
+ <Icon icon="Target" size="sm" color="light.700" />
99
+ <Text color="light.700">{t.stepGoal}:</Text>
100
+ <Text>
101
+ {step.input}
102
+ </Text>
103
+ </Row>
104
+ <Row gap="4px">
105
+ <Icon icon="BorderRadius" size="sm" color="light.700" />
106
+ <Text color="light.700">{t.toolsToBeExecuted}:</Text>
107
+ </Row>
108
+ <ul className="tools-list">
109
+ {step.attempts?.[0]?.tools?.map((tool) => (<li key={tool.id}>
110
+ <Text>{tool.name}: {tool.goal}</Text>
111
+ </li>),
112
+ )}
113
+ </ul>
114
+ </>
115
+ }
116
+ </Column>
117
+ </Accordion>
118
+ </StyledCard>
119
+ </Row>
42
120
  </li>
43
121
  )
44
122
  }
45
123
 
124
+ export const StepsPlaceholder = () => {
125
+ const t = useTranslate(dictionary)
126
+ return <Card gap="8px">
127
+ <Row gap="8px">
128
+ <ProgressCircular colorScheme="inverse" size="xs" />
129
+ <Text color="light.700">{t.generatingPlan}</Text>
130
+ </Row>
131
+ <Text color="light.700">
132
+ {t.analyzingRequirements}
133
+ </Text>
134
+ <Row gap="12px">
135
+ <Skeleton height="31px" width="148px" bgLevel={600} />
136
+ <Skeleton height="31px" width="148px" bgLevel={600} />
137
+ <Skeleton height="31px" width="148px" bgLevel={600} />
138
+ <Skeleton height="31px" width="148px" bgLevel={600} />
139
+ </Row>
140
+ </Card>
141
+ }
142
+
143
+ const AwaitingApproval = ({ customApproveText }: { customApproveText?: string }) => {
144
+ const t = useTranslate(dictionary)
145
+ const chat = useCurrentChat()
146
+
147
+ const onAnswer = (response: string) => {
148
+ chat.pushMessage(ChatEntry.createUserEntry(response))
149
+ }
150
+
151
+ return <>
152
+ <Row gap="8px">
153
+ <Button colorScheme="light" onClick={() => onAnswer(t.cancel)}>
154
+ <Row gap="8px">
155
+ <Icon icon="Stop" />
156
+ {t.cancel}
157
+ </Row>
158
+ </Button>
159
+
160
+ <Button colorScheme="inverse" onClick={() => onAnswer(customApproveText ?? t.approve)}>
161
+ <Row gap="8px">
162
+ <Icon group="fill" icon="Play" />
163
+ {customApproveText ?? t.approve}
164
+ </Row>
165
+ </Button>
166
+ </Row>
167
+ </>
168
+ }
169
+
170
+ export const ToolStepsList = ({ steps, messageId }: { steps: ToolChatStep, messageId: number }) => {
171
+ const t = useTranslate(dictionary)
172
+ const toolsAwaiting = steps
173
+ const chat = useCurrentChat()
174
+ const messages = useCurrentChatMessages()
175
+ const inputParsed = `\`\`\`json
176
+ ${JSON.stringify(toolsAwaiting?.input, null, 2)}
177
+ \`\`\``
178
+
179
+ const tool = useMemo(() => {
180
+ if (!toolsAwaiting) return undefined
181
+ return toolsAwaiting.attempts?.[0].tools?.[0]
182
+ }, [toolsAwaiting])
183
+
184
+ useMemo(() => {
185
+ if (!toolsAwaiting) return undefined
186
+ const executionId = toolsAwaiting.attempts?.[0].tools?.[0].executionId
187
+ if (!executionId) return
188
+
189
+ updateToolStep(messages, executionId, steps.status)
190
+
191
+ }, [messages, toolsAwaiting, steps.status])
192
+
193
+ return <>
194
+ {toolsAwaiting && tool ? <AnimatedHeight>
195
+ <div className="steps">
196
+ <Badge colorPalette="yellow" appearance="square">
197
+ <Icon icon="StopWatch" />
198
+ <Text>{tool.name} {t.keepWorking}</Text>
199
+ </Badge>
200
+ <Card mt="16px" gap="8px" bgLevel={500}>
201
+ <Row>
202
+ <ImageWithFallback src={tool.image} width="32px" fallback={<IconBox appearance="circle" icon="StackSpot" />} />
203
+ <Text>{tool.name}</Text>
204
+ </Row>
205
+
206
+ <Text>
207
+ {toolsAwaiting.user_question}
208
+ </Text>
209
+
210
+ <Accordion header={expand => <Row gap="8px" mb="4px">
211
+ <Card p="4px" bgLevel={400}>{expand}</Card>
212
+ <Text > {t.viewDetails} </Text>
213
+ </Row>}>
214
+ <Markdown onCopyCode={(code) => onCopyCode(code, `${messageId}`, chat)}>
215
+ {inputParsed}
216
+ </Markdown>
217
+ </Accordion>
218
+
219
+ {toolsAwaiting.status === 'awaiting_approval' && <AwaitingApproval customApproveText={t.approveTool} />}
220
+
221
+ </Card>
222
+ </div>
223
+ </AnimatedHeight> : null}
224
+ </>
225
+ }
226
+
46
227
  export const StepsList = ({ steps, chatId, messageId }: Props) => {
47
228
  const t = useTranslate(dictionary)
48
- const [isExpanded, setExpanded] = useState(false)
49
229
  const actualSteps = steps.filter(s => s.type === 'step')
230
+ const planning = steps.filter(s => s.type === 'planning')
231
+
232
+ useMemo(() => {
233
+ actualSteps.map((item) => {
234
+ const executionId = item.attempts[0]?.tools?.[0].executionId
235
+ if (executionId) {
236
+ planningToolDictionaryHelper.setMessageIdPlanningStepToolExecutionId(`${messageId}`, executionId)
237
+ }
238
+ })
239
+ }, [actualSteps, messageId])
240
+
241
+ const toolsStep = steps.filter(s => s.type === 'tool')
242
+
243
+ useMemo(() => {
244
+ const executionId = toolsStep?.[0]?.attempts?.[0]?.tools?.[0]?.executionId
245
+ if (executionId) {
246
+ planningToolDictionaryHelper.setMessageIdToolStepToolExecutionId(`${messageId}`, executionId)
247
+ }
248
+ }, [toolsStep, messageId])
249
+
250
+ const planningGoal = steps.filter(s => s.type === 'planning')?.[0]?.goal
251
+ const isLastStepDone = actualSteps[actualSteps.length - 1]?.status !== 'running' &&
252
+ actualSteps[actualSteps.length - 1]?.status !== 'pending'
253
+ const totalTools = useMemo(() => actualSteps?.reduce((sum, step) => {
254
+ const firstAttempt = step.attempts && step.attempts[0]
255
+ const toolsCount = firstAttempt.tools?.length ?? 0
256
+ return sum + toolsCount
257
+ }, 0), [steps])
258
+
50
259
  let currentStepIndex = findLastIndex(actualSteps, s => s.status === 'running' || s.status === 'success')
51
260
  if (currentStepIndex === -1) currentStepIndex = 0
52
261
  const widget = useWidget()
@@ -55,30 +264,46 @@ export const StepsList = ({ steps, chatId, messageId }: Props) => {
55
264
  widget.set('currentMessageInPanel', { chatId, messageId })
56
265
  widget.set('panel', 'steps')
57
266
  }
58
-
59
- return (
60
- <AnimatedHeight>
267
+
268
+ return (<>
269
+ {actualSteps.length > 0 ? <AnimatedHeight>
61
270
  <div className="steps">
62
- <ul>
63
- {isExpanded
64
- ? actualSteps.map((s, i) => <Step step={s} key={i} index={i + 1} total={actualSteps.length} />)
65
- : <Step
66
- step={actualSteps[currentStepIndex]}
67
- index={currentStepIndex + 1}
68
- total={actualSteps.length}
69
- onClick={() => setExpanded(true)}
70
- />
71
- }
271
+ <Row gap="14px" mb="8px" ml="5px">
272
+ <Icon icon="Target" size="sm" color="light.600" />
273
+ <Text>{t.executionPlan}</Text>
274
+ </Row>
275
+ <ul className="steps-list">
276
+ <Step
277
+ step={actualSteps[currentStepIndex]}
278
+ index={currentStepIndex + 1}
279
+ />
280
+
281
+ <Step
282
+ step={{ status: 'target', input: planningGoal, attempts: [] }}
283
+ index={currentStepIndex + 1}
284
+ total={actualSteps.length}
285
+ totalTools={totalTools}
286
+ isAllDone={isLastStepDone}
287
+ />
72
288
  </ul>
73
- {isExpanded && <div className="step-actions">
74
- <Button colorScheme="light" size="sm" onClick={() => setExpanded(false)}>{t.hideSteps}</Button>
289
+
290
+ <Column gap="8px" mt="8px">
291
+ <Divider colorScheme="light" />
292
+ <Text color="light.700">{planning?.[0]?.user_question}</Text>
293
+ {planning?.[0]?.status === 'awaiting_approval' && <AwaitingApproval />}
294
+ </Column>
295
+
296
+ {isLastStepDone && <div className="step-actions">
75
297
  <Button colorScheme="light" size="sm" className="icon-button details" onClick={openToolsPanel}>
76
298
  <Icon group="fill" icon="Play" size="xs" />
77
299
  {t.detailed}
78
300
  </Button>
79
301
  </div>}
80
302
  </div>
81
- </AnimatedHeight>
303
+ </AnimatedHeight> : null}
304
+
305
+ {toolsStep?.length > 0 && <ToolStepsList steps={toolsStep[0]} messageId={messageId} />}
306
+ </>
82
307
  )
83
308
  }
84
309
 
@@ -87,10 +312,38 @@ const dictionary = {
87
312
  step: 'Step',
88
313
  hideSteps: 'Hide steps',
89
314
  detailed: 'View detailed mode',
315
+ generatingPlan: 'Generating execution plan...',
316
+ analyzingRequirements: 'Analyzing task requirements',
317
+ executionPlan: 'Execution Plan',
318
+ planGoal: 'Plan Goal',
319
+ toolsToBeExecuted: 'Tools to be executed',
320
+ totalSteps: 'Total Steps',
321
+ totalTools: 'Total Tools',
322
+ stepGoal: 'Step Goal',
323
+ cancel: 'Cancel execution',
324
+ approve: 'Approve & Execute Plan',
325
+ keepWorking: 'will keep working after your answer',
326
+ viewDetails: 'View details',
327
+ approveTool: 'Approve execution',
328
+ pendingReview: 'Pending review',
90
329
  },
91
330
  pt: {
92
331
  step: 'Passo',
93
332
  hideSteps: 'Esconder passos',
94
333
  detailed: 'Ver modo detalhado',
334
+ generatingPlan: 'Gerando plano de execução...',
335
+ analyzingRequirements: 'Analisando os requisitos da task',
336
+ executionPlan: 'Plano de Execução',
337
+ planGoal: 'Finalidade do Plano',
338
+ toolsToBeExecuted: 'Tools a serem executadas',
339
+ totalSteps: 'Total de Passos',
340
+ totalTools: 'Total de Tools',
341
+ stepGoal: 'Objetivo do passo',
342
+ cancel: 'Cancelar execução',
343
+ approve: 'Aprovar & Executar plano',
344
+ keepWorking: 'continuará trabalhando após a sua resposta',
345
+ viewDetails: 'Ver detalhes',
346
+ approveTool: 'Aprovar execução',
347
+ pendingReview: 'Revisão pendente',
95
348
  },
96
349
  } satisfies Dictionary
@@ -243,20 +243,27 @@ export const ChatList: IStyledComponentBase<
243
243
  }
244
244
 
245
245
  .steps {
246
- ul {
246
+ .steps-list {
247
247
  list-style: none;
248
248
  margin: 0;
249
249
  padding: 0;
250
250
  display: flex;
251
251
  flex-direction: column;
252
252
  gap: 6px;
253
+ }
253
254
 
254
- li {
255
- display: flex;
256
- flex-direction: row;
257
- gap: 4px;
258
- align-items: center;
255
+ .tools-list {
256
+ list-style: disc;
257
+ margin: 0;
258
+ padding-left: 24px;
259
+ display: block;
260
+ ::marker {
261
+ color: ${theme.color.light.contrastText};
262
+ }
263
+ }
259
264
 
265
+ ul {
266
+ li {
260
267
  &[role="button"] {
261
268
  cursor: pointer;
262
269
  }
@@ -37,7 +37,7 @@ export const NodeStep = ({ data: { step, index, nextStatus, onClick } }: Props)
37
37
  {!!step.attempts[0].tools?.length && <StackedBadge
38
38
  label={t.tools}
39
39
  images={step.attempts[0].tools?.slice(0, 3).map(
40
- tool => ({ key: tool.id, name: tool.name, url: tool.image, icon: <Icon icon="Cog" /> }),
40
+ tool => ({ key: tool.id, name: tool.name ?? '', url: tool.image, icon: <Icon icon="Cog" /> }),
41
41
  )}
42
42
  />}
43
43
  </div>}
@@ -8,6 +8,7 @@ const nodesSizes = {
8
8
  step: stepNodeSize,
9
9
  planning: planningNodeSize,
10
10
  answer: answerNodeSize,
11
+ tool: stepNodeSize,
11
12
  }
12
13
 
13
14
  export function useLayoutedElements(nodes: NodeWithoutLayout[], edges: Edge[]) {
@@ -9,6 +9,6 @@ export interface NodeData {
9
9
 
10
10
  export interface NodeWithoutLayout {
11
11
  id: string,
12
- type: 'step' | 'planning' | 'answer',
12
+ type: 'step' | 'planning' | 'answer' | 'tool',
13
13
  data?: NodeData,
14
14
  }
@@ -127,7 +127,7 @@ export const StepModal = ({ message, stepId, onClose }: Props) => {
127
127
 
128
128
  const tools = step?.type === 'step' ? step.attempts[attempt]?.tools?.map(tool => (
129
129
  <div className="tool" key={tool.id}>
130
- <ToolBadge name={tool.name} duration={tool.duration} image={tool.image} description={tool.description} />
130
+ <ToolBadge name={tool.name ?? ''} duration={tool.duration} image={tool.image} description={tool.description} />
131
131
  {tool.input && <>
132
132
  <Text appearance="microtext1" color="light.700">{t.input}:</Text>
133
133
  <Code language="json" className="tool-input" showLineNumbers={false} showActionBar>{tool.input}</Code>
@@ -191,7 +191,7 @@ export const StepModal = ({ message, stepId, onClose }: Props) => {
191
191
  {!!s.attempts[0].tools?.length && <ul className="side-by-side-tools">
192
192
  {s.attempts[0].tools.map((tool) => (
193
193
  <li key={tool.id}>
194
- <ToolBadge name={tool.name} image={tool.image} />
194
+ <ToolBadge name={tool.name ?? ''} image={tool.image} />
195
195
  </li>
196
196
  ))}
197
197
  </ul>}