@stack-spot/ai-chat-widget 2.8.5 → 2.10.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 (72) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/app-metadata.json +3 -3
  3. package/dist/chat-interceptors/quick-commands.d.ts.map +1 -1
  4. package/dist/chat-interceptors/quick-commands.js +14 -5
  5. package/dist/chat-interceptors/quick-commands.js.map +1 -1
  6. package/dist/views/Chat/ButtonExecutionDetail.d.ts +5 -0
  7. package/dist/views/Chat/ButtonExecutionDetail.d.ts.map +1 -0
  8. package/dist/views/Chat/ButtonExecutionDetail.js +34 -0
  9. package/dist/views/Chat/ButtonExecutionDetail.js.map +1 -0
  10. package/dist/views/Chat/ChatMessage.d.ts.map +1 -1
  11. package/dist/views/Chat/ChatMessage.js +9 -7
  12. package/dist/views/Chat/ChatMessage.js.map +1 -1
  13. package/dist/views/Steps/FlowChart/HandleGroup.d.ts +4 -1
  14. package/dist/views/Steps/FlowChart/HandleGroup.d.ts.map +1 -1
  15. package/dist/views/Steps/FlowChart/HandleGroup.js +1 -1
  16. package/dist/views/Steps/FlowChart/HandleGroup.js.map +1 -1
  17. package/dist/views/Steps/FlowChart/NodeDynamic.d.ts +15 -0
  18. package/dist/views/Steps/FlowChart/NodeDynamic.d.ts.map +1 -0
  19. package/dist/views/Steps/FlowChart/NodeDynamic.js +41 -0
  20. package/dist/views/Steps/FlowChart/NodeDynamic.js.map +1 -0
  21. package/dist/views/Steps/FlowChart/NodeStep.d.ts +4 -1
  22. package/dist/views/Steps/FlowChart/NodeStep.d.ts.map +1 -1
  23. package/dist/views/Steps/FlowChart/NodeStep.js +2 -2
  24. package/dist/views/Steps/FlowChart/NodeStep.js.map +1 -1
  25. package/dist/views/Steps/FlowChart/hooks.d.ts +7 -0
  26. package/dist/views/Steps/FlowChart/hooks.d.ts.map +1 -0
  27. package/dist/views/Steps/FlowChart/hooks.js +31 -0
  28. package/dist/views/Steps/FlowChart/hooks.js.map +1 -0
  29. package/dist/views/Steps/FlowChart/index.d.ts +4 -2
  30. package/dist/views/Steps/FlowChart/index.d.ts.map +1 -1
  31. package/dist/views/Steps/FlowChart/index.js +53 -23
  32. package/dist/views/Steps/FlowChart/index.js.map +1 -1
  33. package/dist/views/Steps/FlowChart/layout.d.ts +4 -13
  34. package/dist/views/Steps/FlowChart/layout.d.ts.map +1 -1
  35. package/dist/views/Steps/FlowChart/layout.js +25 -7
  36. package/dist/views/Steps/FlowChart/layout.js.map +1 -1
  37. package/dist/views/Steps/FlowChart/styled.d.ts +0 -1
  38. package/dist/views/Steps/FlowChart/styled.d.ts.map +1 -1
  39. package/dist/views/Steps/FlowChart/styled.js +39 -15
  40. package/dist/views/Steps/FlowChart/styled.js.map +1 -1
  41. package/dist/views/Steps/FlowChart/types.d.ts +14 -2
  42. package/dist/views/Steps/FlowChart/types.d.ts.map +1 -1
  43. package/dist/views/Steps/StepModal.d.ts +2 -1
  44. package/dist/views/Steps/StepModal.d.ts.map +1 -1
  45. package/dist/views/Steps/StepModal.js +24 -7
  46. package/dist/views/Steps/StepModal.js.map +1 -1
  47. package/dist/views/Steps/StepsPanel.d.ts.map +1 -1
  48. package/dist/views/Steps/StepsPanel.js +6 -2
  49. package/dist/views/Steps/StepsPanel.js.map +1 -1
  50. package/dist/views/Steps/dictionary.d.ts +5 -1
  51. package/dist/views/Steps/dictionary.d.ts.map +1 -1
  52. package/dist/views/Steps/dictionary.js +4 -0
  53. package/dist/views/Steps/dictionary.js.map +1 -1
  54. package/dist/views/Steps/utils.js +2 -2
  55. package/dist/views/Steps/utils.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/app-metadata.json +3 -3
  58. package/src/chat-interceptors/quick-commands.ts +14 -5
  59. package/src/views/Chat/ButtonExecutionDetail.tsx +46 -0
  60. package/src/views/Chat/ChatMessage.tsx +10 -6
  61. package/src/views/Steps/FlowChart/HandleGroup.tsx +5 -3
  62. package/src/views/Steps/FlowChart/NodeDynamic.tsx +97 -0
  63. package/src/views/Steps/FlowChart/NodeStep.tsx +6 -4
  64. package/src/views/Steps/FlowChart/hooks.ts +41 -0
  65. package/src/views/Steps/FlowChart/index.tsx +67 -23
  66. package/src/views/Steps/FlowChart/layout.ts +39 -16
  67. package/src/views/Steps/FlowChart/styled.ts +39 -15
  68. package/src/views/Steps/FlowChart/types.ts +16 -2
  69. package/src/views/Steps/StepModal.tsx +36 -13
  70. package/src/views/Steps/StepsPanel.tsx +9 -2
  71. package/src/views/Steps/dictionary.ts +4 -0
  72. package/src/views/Steps/utils.tsx +2 -2
@@ -12,8 +12,8 @@ export function getStatusIcon(status) {
12
12
  }
13
13
  export function getTypeIcon(type) {
14
14
  switch (type) {
15
- case 'planning': return { group: 'outline', icon: 'ListUnordered' };
16
- default: return { group: 'fill', icon: 'Play' };
15
+ case 'planning': return { group: 'fill', icon: 'ChevronRight' };
16
+ default: return { group: 'outline', icon: 'StackSpot' };
17
17
  }
18
18
  }
19
19
  export function getTitle(translation, step, index) {
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../../src/views/Steps/utils.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,EAAY,MAAM,0BAA0B,CAAA;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAA;AAE3D,OAAO,EAAE,KAAK,EAAE,MAAM,0BAA0B,CAAA;AAEhD,MAAM,UAAU,aAAa,CAAC,MAA0B;IACtD,QAAQ,MAAM,EAAE,CAAC;QACf,KAAK,SAAS,CAAC,CAAC,OAAO,KAAC,IAAI,IAAC,KAAK,EAAC,MAAM,EAAC,IAAI,EAAC,aAAa,EAAC,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,GAAI,CAAA;QAC3G,KAAK,OAAO,CAAC,CAAC,OAAO,KAAC,IAAI,IAAC,KAAK,EAAC,MAAM,EAAC,IAAI,EAAC,aAAa,EAAC,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,GAAI,CAAA;QACxG,KAAK,SAAS,CAAC,CAAC,OAAO,KAAC,gBAAgB,IAAC,WAAW,EAAC,SAAS,EAAC,IAAI,EAAC,IAAI,GAAG,CAAA;QAC3E,OAAO,CAAC,CAAC,OAAO,IAAI,CAAA;IACtB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,IAAsB;IAChD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,UAAU,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,eAAe,EAAE,CAAA;QACnE,OAAO,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,CAAA;IACjD,CAAC;AACH,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,WAA2D,EAAE,IAA0B,EAAE,KAAa;IAC7H,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAA;IACpB,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC;QAClB,KAAK,UAAU,CAAC,CAAC,OAAO,WAAW,CAAC,QAAQ,CAAA;QAC5C,KAAK,MAAM,CAAC,CAAC,OAAO,GAAG,WAAW,CAAC,IAAI,IAAI,KAAK,EAAE,CAAA;QAClD,KAAK,QAAQ,CAAC,CAAC,OAAO,WAAW,CAAC,MAAM,CAAA;IAC1C,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,CAAS,EAAE,eAAe,GAAG,CAAC;IACxD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,eAAe,CAAC,CAAA;IAC5C,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,GAAG,MAAM,CAAA;AACxC,CAAC"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../../../src/views/Steps/utils.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,IAAI,EAAY,MAAM,0BAA0B,CAAA;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAA;AAE3D,OAAO,EAAE,KAAK,EAAE,MAAM,0BAA0B,CAAA;AAEhD,MAAM,UAAU,aAAa,CAAC,MAA0B;IACtD,QAAQ,MAAM,EAAE,CAAC;QACf,KAAK,SAAS,CAAC,CAAC,OAAO,KAAC,IAAI,IAAC,KAAK,EAAC,MAAM,EAAC,IAAI,EAAC,aAAa,EAAC,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,GAAI,CAAA;QAC3G,KAAK,OAAO,CAAC,CAAC,OAAO,KAAC,IAAI,IAAC,KAAK,EAAC,MAAM,EAAC,IAAI,EAAC,aAAa,EAAC,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,GAAI,CAAA;QACxG,KAAK,SAAS,CAAC,CAAC,OAAO,KAAC,gBAAgB,IAAC,WAAW,EAAC,SAAS,EAAC,IAAI,EAAC,IAAI,GAAG,CAAA;QAC3E,OAAO,CAAC,CAAC,OAAO,IAAI,CAAA;IACtB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,IAAsB;IAChD,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,UAAU,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,EAAE,CAAA;QAC/D,OAAO,CAAC,CAAC,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE,CAAA;IACzD,CAAC;AACH,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,WAA2D,EAAE,IAA0B,EAAE,KAAa;IAC7H,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAA;IACpB,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC;QAClB,KAAK,UAAU,CAAC,CAAC,OAAO,WAAW,CAAC,QAAQ,CAAA;QAC5C,KAAK,MAAM,CAAC,CAAC,OAAO,GAAG,WAAW,CAAC,IAAI,IAAI,KAAK,EAAE,CAAA;QAClD,KAAK,QAAQ,CAAC,CAAC,OAAO,WAAW,CAAC,MAAM,CAAA;IAC1C,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,CAAS,EAAE,eAAe,GAAG,CAAC;IACxD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,eAAe,CAAC,CAAA;IAC5C,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,GAAG,MAAM,CAAA;AACxC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stack-spot/ai-chat-widget",
3
- "version": "2.8.5",
3
+ "version": "2.10.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.211.2",
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.5",
4
- "date": "Mon Dec 22 2025 20:53:44 GMT+0000 (Coordinated Universal Time)",
3
+ "version": "2.10.0",
4
+ "date": "Tue Jan 06 2026 12:17:53 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.211.2(@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",
@@ -370,7 +370,14 @@ export function createQuickCommandInterceptor(widget: WidgetState, getEditor: ()
370
370
  }
371
371
 
372
372
  if (ctx.qc.use_selected_code && (!code && ctx.context.upload_ids?.length === 0)) {
373
- throw new Error(t.requiresSelection)
373
+ widget.set('panel', 'editor')
374
+ ctx.chat.pushMessage(new ChatEntry({
375
+ agentType: 'bot',
376
+ type: 'text',
377
+ agent: ctx.chat.get('agent'),
378
+ content: t.requiresSelection,
379
+ }))
380
+ return
374
381
  }
375
382
  manageConversationContext(ctx)
376
383
  await computeCustomInputs(ctx)
@@ -458,8 +465,10 @@ export function createQuickCommandInterceptor(widget: WidgetState, getEditor: ()
458
465
  const start = new Date().getTime()
459
466
  try {
460
467
  const result = await runQuickCommand(ctx)
461
- outputResult(ctx, result)
462
- registerAnalyticsEvent(ctx, '200', start)
468
+ if (result) {
469
+ outputResult(ctx, result)
470
+ registerAnalyticsEvent(ctx, '200', start)
471
+ }
463
472
  } catch (error: any) {
464
473
  let message = error.message || `${error}`
465
474
  if (error instanceof AbortedError || error instanceof CancelledError) message = t.aborted
@@ -483,14 +492,14 @@ export function createQuickCommandInterceptor(widget: WidgetState, getEditor: ()
483
492
 
484
493
  const dictionary = {
485
494
  en: {
486
- requiresSelection: 'This quick command requires some code to be selected in the editor. To open the editor click the icon "{/}" in the field below.',
495
+ requiresSelection: 'Select the code in the editor next to it for this quick command to interact with it.',
487
496
  startQuestioning: 'To execute the Quick Command "$0", I\'ll need you to provide some information. Some may be mandatory, and others optional. Let\'s get started.',
488
497
  progress: 'Running step "$0" from Quick Command "$1".',
489
498
  aborted: 'The quick command execution aborted by the user.',
490
499
  notFound: 'There\'s no quick command with the provided name. If you don\'t wish to run a command, prefix the first "/" with a "\\".',
491
500
  },
492
501
  pt: {
493
- requiresSelection: 'Este quick command precisa que algum código esteja selecionado no editor. Para abrir o editor clique no ícone "{/}" no campo abaixo.',
502
+ requiresSelection: 'Selecione o código no editor ao lado para que este quick command possa interagir com ele.',
494
503
  startQuestioning: 'Para executar o Quick Command "$0", vou precisar que você providencie algumas explicações. Algumas são obrigatórias e outras opcionais. Vamos começar.',
495
504
  progress: 'Executando step "$0" do Quick Command "$1".',
496
505
  aborted: 'A execução do quick command foi abortada pelo usuário.',
@@ -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
+