@stack-spot/ai-chat-widget 2.8.4 → 2.9.0

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 (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/app-metadata.json +3 -3
  3. package/dist/chat-interceptors/quick-commands.js +2 -2
  4. package/dist/chat-interceptors/quick-commands.js.map +1 -1
  5. package/dist/views/Chat/ButtonExecutionDetail.d.ts +5 -0
  6. package/dist/views/Chat/ButtonExecutionDetail.d.ts.map +1 -0
  7. package/dist/views/Chat/ButtonExecutionDetail.js +34 -0
  8. package/dist/views/Chat/ButtonExecutionDetail.js.map +1 -0
  9. package/dist/views/Chat/ChatMessage.d.ts.map +1 -1
  10. package/dist/views/Chat/ChatMessage.js +9 -7
  11. package/dist/views/Chat/ChatMessage.js.map +1 -1
  12. package/dist/views/Steps/FlowChart/HandleGroup.d.ts +4 -1
  13. package/dist/views/Steps/FlowChart/HandleGroup.d.ts.map +1 -1
  14. package/dist/views/Steps/FlowChart/HandleGroup.js +1 -1
  15. package/dist/views/Steps/FlowChart/HandleGroup.js.map +1 -1
  16. package/dist/views/Steps/FlowChart/NodeDynamic.d.ts +15 -0
  17. package/dist/views/Steps/FlowChart/NodeDynamic.d.ts.map +1 -0
  18. package/dist/views/Steps/FlowChart/NodeDynamic.js +41 -0
  19. package/dist/views/Steps/FlowChart/NodeDynamic.js.map +1 -0
  20. package/dist/views/Steps/FlowChart/NodeStep.d.ts +4 -1
  21. package/dist/views/Steps/FlowChart/NodeStep.d.ts.map +1 -1
  22. package/dist/views/Steps/FlowChart/NodeStep.js +2 -2
  23. package/dist/views/Steps/FlowChart/NodeStep.js.map +1 -1
  24. package/dist/views/Steps/FlowChart/hooks.d.ts +7 -0
  25. package/dist/views/Steps/FlowChart/hooks.d.ts.map +1 -0
  26. package/dist/views/Steps/FlowChart/hooks.js +31 -0
  27. package/dist/views/Steps/FlowChart/hooks.js.map +1 -0
  28. package/dist/views/Steps/FlowChart/index.d.ts +4 -2
  29. package/dist/views/Steps/FlowChart/index.d.ts.map +1 -1
  30. package/dist/views/Steps/FlowChart/index.js +53 -23
  31. package/dist/views/Steps/FlowChart/index.js.map +1 -1
  32. package/dist/views/Steps/FlowChart/layout.d.ts +4 -13
  33. package/dist/views/Steps/FlowChart/layout.d.ts.map +1 -1
  34. package/dist/views/Steps/FlowChart/layout.js +25 -7
  35. package/dist/views/Steps/FlowChart/layout.js.map +1 -1
  36. package/dist/views/Steps/FlowChart/styled.d.ts +0 -1
  37. package/dist/views/Steps/FlowChart/styled.d.ts.map +1 -1
  38. package/dist/views/Steps/FlowChart/styled.js +39 -15
  39. package/dist/views/Steps/FlowChart/styled.js.map +1 -1
  40. package/dist/views/Steps/FlowChart/types.d.ts +14 -2
  41. package/dist/views/Steps/FlowChart/types.d.ts.map +1 -1
  42. package/dist/views/Steps/StepModal.d.ts +2 -1
  43. package/dist/views/Steps/StepModal.d.ts.map +1 -1
  44. package/dist/views/Steps/StepModal.js +24 -7
  45. package/dist/views/Steps/StepModal.js.map +1 -1
  46. package/dist/views/Steps/StepsPanel.d.ts.map +1 -1
  47. package/dist/views/Steps/StepsPanel.js +6 -2
  48. package/dist/views/Steps/StepsPanel.js.map +1 -1
  49. package/dist/views/Steps/dictionary.d.ts +5 -1
  50. package/dist/views/Steps/dictionary.d.ts.map +1 -1
  51. package/dist/views/Steps/dictionary.js +4 -0
  52. package/dist/views/Steps/dictionary.js.map +1 -1
  53. package/dist/views/Steps/utils.js +2 -2
  54. package/dist/views/Steps/utils.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/app-metadata.json +3 -3
  57. package/src/chat-interceptors/quick-commands.ts +2 -2
  58. package/src/views/Chat/ButtonExecutionDetail.tsx +46 -0
  59. package/src/views/Chat/ChatMessage.tsx +10 -6
  60. package/src/views/Steps/FlowChart/HandleGroup.tsx +5 -3
  61. package/src/views/Steps/FlowChart/NodeDynamic.tsx +97 -0
  62. package/src/views/Steps/FlowChart/NodeStep.tsx +6 -4
  63. package/src/views/Steps/FlowChart/hooks.ts +41 -0
  64. package/src/views/Steps/FlowChart/index.tsx +67 -23
  65. package/src/views/Steps/FlowChart/layout.ts +39 -16
  66. package/src/views/Steps/FlowChart/styled.ts +39 -15
  67. package/src/views/Steps/FlowChart/types.ts +16 -2
  68. package/src/views/Steps/StepModal.tsx +36 -13
  69. package/src/views/Steps/StepsPanel.tsx +9 -2
  70. package/src/views/Steps/dictionary.ts +4 -0
  71. package/src/views/Steps/utils.tsx +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stack-spot/ai-chat-widget",
3
- "version": "2.8.4",
3
+ "version": "2.9.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -16,7 +16,7 @@
16
16
  "@citric/core": "^6.4.0",
17
17
  "@stack-spot/portal-components": "^2.27.3",
18
18
  "@citric/icons": "^5.13.0",
19
- "@stack-spot/portal-network": "0.210.0",
19
+ "@stack-spot/portal-network": "0.212.0",
20
20
  "@citric/ui": "^6.10.2",
21
21
  "@stack-spot/portal-translate": "^2.1.0",
22
22
  "lodash": "^4.17.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stack-spot/ai-chat-widget",
3
- "version": "2.8.4",
4
- "date": "Mon Dec 22 2025 13:07:28 GMT+0000 (Coordinated Universal Time)",
3
+ "version": "2.9.0",
4
+ "date": "Mon Jan 05 2026 17:22:54 GMT+0000 (Coordinated Universal Time)",
5
5
  "dependencies": [
6
6
  {
7
7
  "name": "@stack-spot/app-metadata",
@@ -121,7 +121,7 @@
121
121
  },
122
122
  {
123
123
  "name": "@stack-spot/portal-network",
124
- "version": "0.210.0(@stack-spot/auth@6.1.0)(@stack-spot/opa@2.5.0(@stack-spot/auth@6.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@stack-spot/portal-translate@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@tanstack/react-query@5.59.16(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)"
124
+ "version": "0.212.0(@stack-spot/auth@6.1.0)(@stack-spot/opa@2.5.0(@stack-spot/auth@6.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@stack-spot/portal-translate@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@tanstack/react-query@5.59.16(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)"
125
125
  },
126
126
  {
127
127
  "name": "@stack-spot/portal-theme",
@@ -271,8 +271,8 @@ export function createQuickCommandInterceptor(widget: WidgetState, getEditor: ()
271
271
  stepSlug: step.slug,
272
272
  slug: slug,
273
273
  quickCommandStartScriptRequest: {
274
- input_data: customInputs,
275
- selected_code: code,
274
+ input_data: code,
275
+ custom_inputs: customInputs,
276
276
  context: stepContext,
277
277
  slugs_executions: resultMap,
278
278
  },
@@ -0,0 +1,46 @@
1
+ import { LoadingCircular } from '@citric/ui'
2
+ import { Button, Icon, Row } from '@stack-spot/citric-react'
3
+ import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
4
+ import { useEffect, useMemo } from 'react'
5
+ import { useChatMessages, useWidget } from '../../context/hooks'
6
+
7
+ export const ButtonExecutionDetail = ({ chatId, messageId }: { chatId: string, messageId: number }) => {
8
+ const t = useTranslate(dictionary)
9
+ const messages = useChatMessages(chatId)
10
+ const widget = useWidget()
11
+
12
+ const isRunning = useMemo(() => {
13
+ const messageEntry = messages?.find((message) => message.id === messageId)
14
+ return !messageEntry?.getValue().steps?.find((step) => step.type === 'answer' && step.status !== 'running')
15
+ }, [messageId, messages])
16
+
17
+ function openToolsPanel() {
18
+ if (messageId) {
19
+ widget.set('currentMessageInPanel', { chatId, messageId })
20
+ widget.set('panel', 'steps')
21
+ }
22
+ }
23
+
24
+ useEffect(() => {
25
+ isRunning && openToolsPanel()
26
+ }, [isRunning])
27
+
28
+ return <>
29
+ <Row className="step-actions">
30
+ {isRunning && <LoadingCircular colorScheme="inverse" size="xs" />}
31
+ <Button colorScheme="light" size="sm" appearance="none" className="icon-button details" onClick={openToolsPanel}>
32
+ <Icon group="outline" icon="Expand" size="xs" />
33
+ {t.detailed}
34
+ </Button>
35
+ </Row>
36
+ </>
37
+ }
38
+
39
+ const dictionary = {
40
+ en: {
41
+ detailed: 'View execution details',
42
+ },
43
+ pt: {
44
+ detailed: 'Ver detalhes da execução',
45
+ },
46
+ } satisfies Dictionary
@@ -15,6 +15,7 @@ import { ChatEntry, SerializableAction, TextChatEntry } from '../../state/ChatEn
15
15
  import { useDateFormatter } from '../../utils/date'
16
16
  import { toolById } from '../../utils/tools'
17
17
  import { AgentInfo } from './AgentInfo'
18
+ import { ButtonExecutionDetail } from './ButtonExecutionDetail'
18
19
  import { useChatScrollToBottomEffect } from './chat-scroll'
19
20
  import { onCopyAll, onCopyCode, onLikeOrDislike } from './events'
20
21
  import { StepsList, StepsPlaceholder, ViewToolsDetails } from './StepsList'
@@ -226,10 +227,10 @@ export const ChatMessage = ({ message, isLast, beforeMessage, afterMessage, cust
226
227
  const [showUserButtonCopy, setShowUserButtonCopy] = useState(false)
227
228
  const isPlanning = useCurrentChatState('isPlaning') ?? false
228
229
 
229
- // Dynamic tool steps are identified by the "dynamic" id.
230
- // We're temporarily hiding the toolbox for these dynamic tools while we finalize their UI.
231
- const shouldHideToolbox = entry?.steps?.some((step) => step?.id === 'dynamic')
232
- const showToolBox = (!!agentsTools?.length || !!entry.tools?.length) && !shouldHideToolbox
230
+ // Dynamic steps are identified by the "dynamic" id.
231
+ const isDynamicSteps = !!entry?.steps?.some((s) => s.id?.toLowerCase() === 'dynamic')
232
+ const hasTools = ((agentsTools?.length ?? 0) > 0) || ((entry.tools?.length ?? 0) > 0)
233
+ const showToolBox = hasTools && !isDynamicSteps
233
234
 
234
235
  useChatScrollToBottomEffect(ref, [entry])
235
236
  useMidnightUpdateView()
@@ -355,7 +356,8 @@ export const ChatMessage = ({ message, isLast, beforeMessage, afterMessage, cust
355
356
  widget.set('panel', 'resources')
356
357
  }
357
358
 
358
- const shouldShowToolsOnlyMessage = (entry.done !== false || entry.hasPlanning) && !!entry.steps?.length
359
+ const buttonExecutionDetail = useMemo(() => <ButtonExecutionDetail chatId={chat.id} messageId={message.id} />, [chat.id, message.id])
360
+ const shouldShowToolsOnlyMessage = (entry.done !== false || entry.hasPlanning || isDynamicSteps) && !!entry.steps?.length
359
361
  const shouldRender = entry.content || entry.error || shouldShowToolsOnlyMessage || !!entry.upload?.length
360
362
 
361
363
  return shouldRender && (
@@ -372,7 +374,7 @@ export const ChatMessage = ({ message, isLast, beforeMessage, afterMessage, cust
372
374
  {entry.badges.map((b, index) => <Badge key={index} colorPalette={b.color ?? 'cyan'} appearance="square">{b.label}</Badge>)}
373
375
  </div>}
374
376
 
375
- {!!entry.steps?.length && <StepsList steps={entry.steps} chatId={chat.id} messageId={message.id} />}
377
+ {!!entry.steps?.length && !isDynamicSteps && <StepsList steps={entry.steps} chatId={chat.id} messageId={message.id} />}
376
378
 
377
379
  {renderContent()}
378
380
 
@@ -428,6 +430,8 @@ export const ChatMessage = ({ message, isLast, beforeMessage, afterMessage, cust
428
430
  </div>
429
431
  }
430
432
 
433
+ {!!entry.steps?.length && isDynamicSteps && buttonExecutionDetail}
434
+
431
435
  {shouldShowFooter && <div className="message-footer">
432
436
  {entry.agentType === 'bot' && !entry.error && <div className="message-actions">
433
437
  {entry.type === 'md' && (
@@ -3,10 +3,12 @@ import { Handle, Position } from '@xyflow/react'
3
3
  interface Props {
4
4
  renderSource?: boolean,
5
5
  renderTarget?: boolean,
6
+ targetPosition?: Position,
7
+ sourcePosition?: Position,
6
8
  }
7
- export const HandleGroup = ({ renderSource = true, renderTarget = true }: Props) => (
9
+ export const HandleGroup = ({ renderSource = true, renderTarget = true, targetPosition, sourcePosition }: Props) => (
8
10
  <>
9
- {renderTarget && <Handle type="target" position={Position.Left} isConnectable className="target-handle" />}
10
- {renderSource && <Handle type="source" position={Position.Right} isConnectable className="source-handle" />}
11
+ {renderTarget && <Handle type="target" position={targetPosition || Position.Left} isConnectable className="target-handle" />}
12
+ {renderSource && <Handle type="source" position={sourcePosition || Position.Right} isConnectable className="source-handle" />}
11
13
  </>
12
14
  )
@@ -0,0 +1,97 @@
1
+ import { Icon, WithIcon } from '@stack-spot/citric-icons'
2
+ import { Avatar, Column, IconBox, Row, Skeleton, Text } from '@stack-spot/citric-react'
3
+ import { AnimatedHeight } from '@stack-spot/portal-components/AnimatedHeight'
4
+ import { ChatAgentTool } from '@stack-spot/portal-network'
5
+ import { listToClass } from '@stack-spot/portal-theme'
6
+ import { Position } from '@xyflow/react'
7
+ import { useChatEntry } from '../../../context/hooks'
8
+ import { useStepsDictionary } from '../dictionary'
9
+ import { getTitle, getTypeIcon } from '../utils'
10
+ import { HandleGroup } from './HandleGroup'
11
+ import { useResizeObserver } from './hooks'
12
+ import { NodeData } from './types'
13
+
14
+ const ToolItem = ({ tool, index, onClick }: { tool: ChatAgentTool, index: number, onClick?: (index: number) => void }) => {
15
+ const { name, image, input, status } = tool
16
+
17
+ if (status === 'running') {
18
+ return <Skeleton height="48px" bgLevel={600} />
19
+ }
20
+
21
+ return (
22
+ <div className="tool" onClick={() => onClick?.(index)}>
23
+ <Row gap="4px" alignItems="center" mb={2}>
24
+ {image
25
+ ? <Avatar size="xxs" image={image} name={name} />
26
+ : <Icon icon="Cog" group="outline" />
27
+ }
28
+ <Text weight="500" className="title">{name}</Text>
29
+ </Row>
30
+ <Text nowrapEllipsis color="light.700">{input}</Text>
31
+ </div>
32
+ )
33
+ }
34
+
35
+ export const ListTools = ({ tools, onClick }: { tools: ChatAgentTool[], onClick?: (toolIndex?: number) => void }) => (
36
+ <AnimatedHeight>
37
+ <Column gap="8px">
38
+ {tools.map((tool, index) => (
39
+ <Column key={`${tool.id}-${index}`} className="wrapper-tool">
40
+ <ToolItem tool={tool} index={index} onClick={onClick} />
41
+ </Column>
42
+ ))}
43
+ </Column>
44
+ </AnimatedHeight>
45
+ )
46
+
47
+ const NodeBox = ({ title, icon, content, onClick }: { title: string, icon: WithIcon, content?: string, onClick?: () => void }) => (
48
+ <Column onClick={onClick} style={{ cursor: onClick ? 'pointer' : 'default' }}>
49
+ <Row gap="4px" alignItems="center">
50
+ <IconBox {...icon} appearance="none" colorScheme="light" size="sm" />
51
+ <Text className="step-index" weight="500">{title}</Text>
52
+ </Row>
53
+ <Text nowrapEllipsis color="light.700">
54
+ {content}
55
+ </Text>
56
+ </Column>
57
+ )
58
+
59
+ interface Props {
60
+ data: NodeData,
61
+ targetPosition?: Position,
62
+ sourcePosition?: Position,
63
+ }
64
+
65
+ export const NodeDynamic = ({ data, ...props }: Props) => {
66
+ const { step, index, onClick, onResize, message } = data
67
+ const content = useChatEntry(message)?.content
68
+ const t = useStepsDictionary()
69
+ const ref = useResizeObserver(onResize)
70
+
71
+ const tools = step.type === 'step' ? step?.attempts?.[0]?.tools : undefined
72
+
73
+ const renderContent = () => {
74
+ if (step.type === 'planning') {
75
+ return <NodeBox title={t.userPrompt} content={step.user_question} icon={{ ...getTypeIcon(step.type) }} />
76
+ }
77
+
78
+ if (step.type === 'step') {
79
+ return tools?.length ? <ListTools onClick={onClick} tools={tools} /> : null
80
+ }
81
+
82
+ if (step.type === 'answer') {
83
+ return <Column p={3} bg="light.400" onClick={() => onClick?.(index)}>
84
+ <NodeBox onClick={onClick} title={t.finalAnswer} content={content} icon={{ ...getTypeIcon(step.type) }} />
85
+ </Column>
86
+ }
87
+ }
88
+
89
+ return (
90
+ <div ref={ref} aria-label={getTitle(t, step, index)}>
91
+ <div className={listToClass(['chart-node', null, step.status])} >
92
+ {renderContent()}
93
+ <HandleGroup {...props} renderSource={step.type !== 'answer'} renderTarget={step.type !== 'planning'} />
94
+ </div>
95
+ </div>
96
+ )
97
+ }
@@ -1,6 +1,7 @@
1
1
  import { Icon } from '@stack-spot/citric-icons'
2
2
  import { Text } from '@stack-spot/citric-react'
3
3
  import { listToClass } from '@stack-spot/portal-theme'
4
+ import { Position } from '@xyflow/react'
4
5
  import { StackedBadge } from '../../../components/StackedBadge'
5
6
  import { useStepsDictionary } from '../dictionary'
6
7
  import { getStatusIcon, getTitle, getTypeIcon } from '../utils'
@@ -9,15 +10,17 @@ import { NodeData } from './types'
9
10
 
10
11
  interface Props {
11
12
  data: NodeData,
13
+ targetPosition?: Position,
14
+ sourcePosition?: Position,
12
15
  }
13
16
 
14
- export const NodeStep = ({ data: { step, index, nextStatus, onClick } }: Props) => {
17
+ export const NodeStep = ({ data: { step, index, nextStatus, onClick }, ...props }: Props) => {
15
18
  const t = useStepsDictionary()
16
19
 
17
20
  return (
18
21
  <div
19
22
  className={listToClass(['chart-node', step.type, nextStatus])}
20
- onClick={onClick}
23
+ onClick={() => onClick?.()}
21
24
  onKeyDown={e => e.key === 'Enter' && onClick?.()}
22
25
  tabIndex={0}
23
26
  role="button"
@@ -41,7 +44,6 @@ export const NodeStep = ({ data: { step, index, nextStatus, onClick } }: Props)
41
44
  )}
42
45
  />}
43
46
  </div>}
44
- <HandleGroup renderSource={step.type !== 'answer'} renderTarget={step.type !== 'planning'} />
45
- </div>
47
+ <HandleGroup {...props} renderSource={step.type !== 'answer'} renderTarget={step.type !== 'planning'} /> </div>
46
48
  )
47
49
  }
@@ -0,0 +1,41 @@
1
+ import { debounce } from 'lodash'
2
+ import { useEffect, useRef } from 'react'
3
+
4
+ interface Size {
5
+ width: number,
6
+ height: number,
7
+ }
8
+
9
+ export const useResizeObserver = (callback?: (size: Size) => void, delay = 50) => {
10
+ const ref = useRef<HTMLDivElement | null>(null)
11
+ const previousSizeRef = useRef<Size | null>(null)
12
+
13
+ useEffect(() => {
14
+ if (!ref.current || !callback) return
15
+
16
+ const debouncedCallback = debounce((newSize: Size) => {
17
+ const prevSize = previousSizeRef.current
18
+ const hasChanged = !prevSize || prevSize.width !== newSize.width || prevSize.height !== newSize.height
19
+
20
+ if (hasChanged) {
21
+ previousSizeRef.current = newSize
22
+ callback(newSize)
23
+ }
24
+ }, delay)
25
+
26
+ const resizeObserver = new ResizeObserver((entries) => {
27
+ if (!entries.length) return
28
+ const { width, height } = entries[0].contentRect
29
+ debouncedCallback({ width, height })
30
+ })
31
+
32
+ resizeObserver.observe(ref.current)
33
+
34
+ return () => {
35
+ resizeObserver.disconnect()
36
+ debouncedCallback.cancel()
37
+ }
38
+ }, [callback])
39
+
40
+ return ref
41
+ }
@@ -1,34 +1,63 @@
1
1
  import { ChatStep } from '@stack-spot/portal-network'
2
2
  import { listToClass, theme } from '@stack-spot/portal-theme'
3
- import { Background, Controls, Edge, MarkerType, ReactFlow } from '@xyflow/react'
3
+ import { Background, Controls, Edge, MarkerType, ReactFlow, ReactFlowProvider, useReactFlow } from '@xyflow/react'
4
4
  import '@xyflow/react/dist/style.css'
5
- import { useMemo } from 'react'
5
+ import { last } from 'lodash'
6
+ import { useCallback, useEffect, useMemo, useState } from 'react'
6
7
  import { useChatEntry } from '../../../context/hooks'
7
8
  import { ChatEntry } from '../../../state/ChatEntry'
8
- import { useLayoutedElements } from './layout'
9
+ import { LayoutDirection, useLayoutedElements } from './layout'
10
+ import { NodeDynamic } from './NodeDynamic'
9
11
  import { NodeStep } from './NodeStep'
10
- import { FlowChartBox, runningColor } from './styled'
12
+ import { FlowChartBox } from './styled'
13
+ import { NodeFullProps } from './types'
11
14
 
12
15
  interface Props {
13
16
  message: ChatEntry,
14
- onClick: (step: ChatStep, index: number) => void,
17
+ onClick: (step: ChatStep, toolIndex?: number) => void,
18
+ direction?: LayoutDirection,
15
19
  }
16
20
 
17
- const nodeTypes = {
18
- planning: NodeStep,
19
- step: NodeStep,
20
- answer: NodeStep,
21
+ const NODE_TYPES = {
22
+ step: { planning: NodeStep, step: NodeStep, answer: NodeStep },
23
+ stepDynamic: { planning: NodeDynamic, step: NodeDynamic, answer: NodeDynamic },
21
24
  }
22
25
 
23
- export const FlowChart = ({ message, onClick }: Props) => {
26
+ const Flow = ({ message, onClick, direction = 'LR' }: Props) => {
24
27
  const steps = useChatEntry(message).steps
25
- const { nodes, edges } = useMemo(() => {
26
- const nodes = steps?.map((s, i) => ({
27
- id: s.id,
28
- type: s.type,
29
- focusable: false,
30
- data: { step: s, index: i, nextStatus: steps[i + 1]?.status, onClick: () => onClick(s, i) },
31
- })) ?? []
28
+ const [nodeSizes, setNodeSizes] = useState<Record<string, { width: number, height: number }>>({})
29
+ const reactFlowInstance = useReactFlow()
30
+
31
+ const isDynamic = useMemo(() => steps?.some((step) => step.id === 'dynamic'), [steps])
32
+ const nodeTypes = useMemo(() => NODE_TYPES[isDynamic ? 'stepDynamic' : 'step'], [isDynamic])
33
+
34
+ const handleNodeSizeChange = useCallback((id: string, size: { width: number, height: number }) => {
35
+ setNodeSizes((prev) => {
36
+ const current = prev[id]
37
+ if (current?.width === size.width && current?.height === size.height) return prev
38
+ return { ...prev, [id]: size }
39
+ })
40
+ }, [])
41
+
42
+ const baseElements = useMemo(() => {
43
+ const nodes = steps?.map((s, index) => {
44
+ const nodeSize = nodeSizes[s.id]
45
+ return {
46
+ id: s.id,
47
+ type: s.type,
48
+ focusable: false,
49
+ ...(nodeSize && { width: nodeSize.width, height: nodeSize.height }),
50
+ origin: [1, 0],
51
+ data: {
52
+ message,
53
+ step: s,
54
+ index,
55
+ nextStatus: steps[index + 1]?.status,
56
+ onClick: (toolIndex) => onClick(s, toolIndex),
57
+ onResize: (size) => handleNodeSizeChange(s.id, size),
58
+ },
59
+ }
60
+ }) as NodeFullProps[] ?? []
32
61
  const edges: Edge[] = []
33
62
  for (let i = 0; i < nodes.length - 1; i++) {
34
63
  edges.push({
@@ -40,24 +69,33 @@ export const FlowChart = ({ message, onClick }: Props) => {
40
69
  markerEnd: {
41
70
  type: MarkerType.Arrow,
42
71
  strokeWidth: 2,
43
- color: nodes[i]?.data?.nextStatus === 'running' ? runningColor : theme.color.light[700],
72
+ color: theme.color.light[700],
44
73
  },
45
74
  })
46
75
  }
47
76
  return { nodes, edges }
48
- }, [steps])
77
+ }, [steps, nodeSizes, message, onClick, handleNodeSizeChange])
78
+
79
+ const { nodes, edges } = useLayoutedElements(baseElements.nodes, baseElements.edges, direction)
49
80
 
50
- const layouted = useLayoutedElements(nodes, edges)
81
+ useEffect(() => {
82
+ const lastNode = last(nodes)
83
+ const isRunning = lastNode?.data?.step?.status === 'running'
84
+ const shouldFitView = nodes.length > 2 && isRunning || (lastNode?.height ?? 0 > 180) && isRunning
85
+ if (shouldFitView) {
86
+ reactFlowInstance.fitView({ duration: 1000, padding: { bottom: '120px', top: '20px' } })
87
+ }
88
+ }, [nodes, edges, reactFlowInstance])
51
89
 
52
90
  return (
53
91
  <FlowChartBox>
54
92
  <ReactFlow
55
- // @ts-ignore wrong type in the lib
56
- nodes={layouted.nodes}
57
- edges={layouted.edges}
93
+ nodes={nodes}
94
+ edges={edges}
58
95
  nodeTypes={nodeTypes}
59
96
  snapToGrid={true}
60
97
  fitView
98
+ maxZoom={1}
61
99
  >
62
100
  <Controls orientation="horizontal" className="controls" showInteractive={false} />
63
101
  <Background />
@@ -65,3 +103,9 @@ export const FlowChart = ({ message, onClick }: Props) => {
65
103
  </FlowChartBox>
66
104
  )
67
105
  }
106
+
107
+ export const FlowChart = (props: Props) => (
108
+ <ReactFlowProvider>
109
+ <Flow {...props} />
110
+ </ReactFlowProvider>
111
+ )
@@ -1,8 +1,8 @@
1
1
  import dagre from '@dagrejs/dagre'
2
- import { Edge } from '@xyflow/react'
2
+ import { Edge, Position } from '@xyflow/react'
3
3
  import { useMemo } from 'react'
4
4
  import { answerNodeSize, planningNodeSize, stepNodeSize } from './styled'
5
- import { NodeWithoutLayout } from './types'
5
+ import { NodeFullProps } from './types'
6
6
 
7
7
  const nodesSizes = {
8
8
  step: stepNodeSize,
@@ -11,40 +11,63 @@ const nodesSizes = {
11
11
  tool: stepNodeSize,
12
12
  }
13
13
 
14
- export function useLayoutedElements(nodes: NodeWithoutLayout[], edges: Edge[]) {
14
+ export type LayoutDirection = 'TB' | 'BT' | 'LR' | 'RL'
15
+
16
+ const getHandlePositions = (direction: LayoutDirection) => {
17
+ switch (direction) {
18
+ case 'TB':
19
+ return { target: Position.Top, source: Position.Bottom }
20
+ case 'BT':
21
+ return { target: Position.Bottom, source: Position.Top }
22
+ case 'LR':
23
+ return { target: Position.Left, source: Position.Right }
24
+ case 'RL':
25
+ return { target: Position.Right, source: Position.Left }
26
+ }
27
+ }
28
+
29
+ export function useLayoutedElements(nodes: NodeFullProps[], edges: Edge[], direction: LayoutDirection = 'LR') {
15
30
  const dagreGraph = useMemo(() => new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})), [])
31
+
16
32
  return useMemo(() => {
17
- dagreGraph.setGraph({ rankdir: 'LR' })
18
-
33
+ dagreGraph.setGraph({ rankdir: direction })
34
+
19
35
  nodes.forEach((node) => {
20
36
  const { width, height } = nodesSizes[node.type]
21
- dagreGraph.setNode(node.id, { width, height })
37
+ const nodeWidth = node.width ?? width
38
+ const nodeHeight = node.height ?? height
39
+ dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight })
22
40
  })
23
-
41
+
24
42
  edges.forEach((edge) => {
25
43
  dagreGraph.setEdge(edge.source, edge.target)
26
44
  })
27
-
45
+
28
46
  dagre.layout(dagreGraph)
29
-
30
- const newNodes = nodes.map((node) => {
47
+
48
+ const { target, source } = getHandlePositions(direction)
49
+
50
+ const newNodes: NodeFullProps[] = nodes.map((node) => {
31
51
  const { width, height } = nodesSizes[node.type]
52
+ const nodeWidth = node?.width ?? width
53
+ const nodeHeight = node.height ?? height
32
54
  const nodeWithPosition = dagreGraph.node(node.id)
33
55
  const newNode = {
34
56
  ...node,
35
- targetPosition: 'left',
36
- sourcePosition: 'right',
57
+ targetPosition: target,
58
+ sourcePosition: source,
37
59
  // We are shifting the dagre node position (anchor=center center) to the top left
38
60
  // so it matches the React Flow node anchor point (top left).
39
61
  position: {
40
- x: nodeWithPosition.x - width / 2,
41
- y: nodeWithPosition.y - height / 2,
62
+ x: nodeWithPosition.x - nodeWidth / 2,
63
+ y: nodeWithPosition.y - nodeHeight / 2,
42
64
  },
43
65
  }
44
-
66
+
45
67
  return newNode
46
68
  })
47
-
69
+
48
70
  return { nodes: newNodes, edges }
49
71
  }, [nodes, edges])
50
72
  }
73
+
@@ -1,10 +1,9 @@
1
1
  import { theme } from '@stack-spot/portal-theme'
2
2
  import { styled } from 'styled-components'
3
3
 
4
- export const stepNodeSize = { width: 160, height: 167 }
5
- export const planningNodeSize = { width: 160, height: 61 }
6
- export const answerNodeSize = { width: 160, height: 40 }
7
- export const runningColor = '#0097FA'
4
+ export const stepNodeSize = { width: 400, height: 167 }
5
+ export const planningNodeSize = { width: 400, height: 61 }
6
+ export const answerNodeSize = { width: 400, height: 40 }
8
7
 
9
8
  export const FlowChartBox = styled.div`
10
9
  width: 100%;
@@ -19,31 +18,35 @@ export const FlowChartBox = styled.div`
19
18
  border: 1px solid ${theme.color.light[600]};
20
19
  background-color: ${theme.color.light[500]};
21
20
  box-sizing: border-box;
22
- justify-content: center;
23
-
24
- &.running .source-handle {
25
- background-color: ${runningColor};
26
- }
21
+ justify-content: center;
22
+ width: 400px;
27
23
 
28
24
  &.pending .source-handle {
29
25
  opacity: 0.3;
30
26
  }
31
27
 
28
+ .source-handle {
29
+ transition: opacity 1s ease; opacity: 1;
30
+ }
31
+
32
+ &.running .source-handle {
33
+ opacity: 0;
34
+ }
35
+
32
36
  &.step {
33
37
  width: ${stepNodeSize.width}px;
34
- height: ${stepNodeSize.height}px;
38
+ min-height: ${stepNodeSize.height}px;
35
39
  }
36
40
 
37
41
  &.planning {
38
42
  width: ${planningNodeSize.width}px;
39
- height: ${planningNodeSize.height}px;
43
+ min-height: ${planningNodeSize.height}px;
40
44
  }
41
45
 
42
46
  &.answer {
43
47
  width: ${answerNodeSize.width}px;
44
- height: ${answerNodeSize.height}px;
48
+ min-height: ${answerNodeSize.height}px;
45
49
  }
46
-
47
50
  header {
48
51
  display: flex;
49
52
  gap: 4px;
@@ -71,7 +74,7 @@ export const FlowChartBox = styled.div`
71
74
  flex-direction: column;
72
75
  align-items: start;
73
76
  justify-content: space-between;
74
- gap: 10px;
77
+ gap: 8px;
75
78
  overflow: hidden;
76
79
 
77
80
  .step-description {
@@ -89,6 +92,28 @@ export const FlowChartBox = styled.div`
89
92
  }
90
93
  }
91
94
  }
95
+
96
+ .wrapper-tool {
97
+ &:not(:last-child) {
98
+ border-bottom: 1px solid ${theme.color.light[600]};
99
+ padding-bottom: 8px;
100
+ }
101
+
102
+ .tool {
103
+ padding: 8px;
104
+ border-radius: 4px;
105
+ min-height: 48px;
106
+ border: 1px solid ${theme.color.light[500]};
107
+ background-color: ${theme.color.light[400]};
108
+ border-bottom: 1px solid ${theme.color.light[300]};
109
+ cursor: pointer;
110
+
111
+ &:hover .title {
112
+ text-decoration: underline;
113
+ }
114
+ }
115
+ }
116
+
92
117
  }
93
118
 
94
119
  .source-handle {
@@ -121,7 +146,6 @@ export const FlowChartBox = styled.div`
121
146
  opacity: 0.3;
122
147
  }
123
148
  &.running path {
124
- stroke: ${runningColor};
125
149
  stroke-dasharray: 5, 5;
126
150
  }
127
151
  }