@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.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 (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * A aresta do editor, com o botão de desligar em cima dela.
5
+ *
6
+ * Existe porque desligar um fio não tinha caminho nenhum no canvas: as arestas são derivadas do
7
+ * grafo a cada render, então clicar e apertar Delete não tem onde guardar a seleção. O botão no
8
+ * hover resolve sem estado de seleção — e é o único lugar em que a pessoa já está olhando quando
9
+ * quer trocar o destino.
10
+ */
11
+
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from '@xyflow/react'
14
+ import { X } from 'lucide-react'
15
+
16
+ /** Folga para o ponteiro sair do traço e alcançar o botão sem que ele desapareça no caminho. */
17
+ const HIDE_DELAY_MS = 320
18
+
19
+ export type FlowConnectionEdgeData = {
20
+ /** Ausente em aresta que não se desliga daqui (salto entre fluxos via portal). */
21
+ readonly onDisconnect?: (() => void) | undefined
22
+ readonly disconnectLabel: string
23
+ }
24
+
25
+ export function FlowConnectionEdge({
26
+ id,
27
+ sourceX,
28
+ sourceY,
29
+ targetX,
30
+ targetY,
31
+ sourcePosition,
32
+ targetPosition,
33
+ markerEnd,
34
+ style,
35
+ data,
36
+ interactionWidth,
37
+ }: EdgeProps) {
38
+ const [hovered, setHovered] = useState(false)
39
+ const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
40
+ const { onDisconnect, disconnectLabel } = (data ?? {}) as FlowConnectionEdgeData
41
+
42
+ // Sair do traço não esconde o botão na hora: entre o fio e o botão há um vão de alguns pixels, e
43
+ // esconder no `mouseleave` seco fazia o alvo fugir do mouse a caminho dele.
44
+ function show() {
45
+ if (hideTimer.current) clearTimeout(hideTimer.current)
46
+ setHovered(true)
47
+ }
48
+
49
+ function scheduleHide() {
50
+ if (hideTimer.current) clearTimeout(hideTimer.current)
51
+ hideTimer.current = setTimeout(() => setHovered(false), HIDE_DELAY_MS)
52
+ }
53
+
54
+ useEffect(() => () => clearTimeout(hideTimer.current), [])
55
+
56
+ const [path, labelX, labelY] = getBezierPath({
57
+ sourceX,
58
+ sourceY,
59
+ sourcePosition,
60
+ targetX,
61
+ targetY,
62
+ targetPosition,
63
+ })
64
+
65
+ return (
66
+ <>
67
+ <BaseEdge id={id} path={path} markerEnd={markerEnd} style={style} interactionWidth={interactionWidth ?? 20} />
68
+
69
+ {/* Faixa invisível larga por cima do traço: o traço tem ~1,5px e mirar nele com o mouse é
70
+ tarefa de precisão que ninguém deveria ter. */}
71
+ <path
72
+ d={path}
73
+ fill="none"
74
+ strokeWidth={22}
75
+ stroke="transparent"
76
+ className="react-flow__edge-interaction"
77
+ onMouseEnter={show}
78
+ onMouseLeave={scheduleHide}
79
+ />
80
+
81
+ {onDisconnect && hovered && (
82
+ <EdgeLabelRenderer>
83
+ <button
84
+ type="button"
85
+ data-cv-tooltip={disconnectLabel}
86
+ aria-label={disconnectLabel}
87
+ onMouseEnter={show}
88
+ onMouseLeave={scheduleHide}
89
+ onClick={(event) => {
90
+ event.stopPropagation()
91
+ onDisconnect()
92
+ }}
93
+ style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)` }}
94
+ className="nodrag nopan pointer-events-auto absolute flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 bg-white text-gray-500 shadow-sm hover:border-red-400 hover:text-red-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"
95
+ >
96
+ <X size={12} strokeWidth={2.5} />
97
+ </button>
98
+ </EdgeLabelRenderer>
99
+ )}
100
+ </>
101
+ )
102
+ }
103
+
104
+ export const flowEdgeTypes = { flowConnection: FlowConnectionEdge }
@@ -18,10 +18,20 @@ export function FlowGroupHeader({ data }: NodeProps) {
18
18
  return (
19
19
  <div className="flex items-center gap-2 rounded-full border border-cyan-300 dark:border-cyan-700 bg-cyan-50 dark:bg-cyan-950/50 px-3 py-1 text-xs font-medium text-cyan-800 dark:text-cyan-200 shadow-sm whitespace-nowrap">
20
20
  <span>{label}</span>
21
- <button type="button" onClick={onFocus} title={labels.flowGroup.focus} className="hover:text-blue-600 dark:hover:text-blue-400">
21
+ <button
22
+ type="button"
23
+ onClick={onFocus}
24
+ data-cv-tooltip={labels.flowGroup.focus} aria-label={labels.flowGroup.focus}
25
+ className="hover:text-blue-600 dark:hover:text-blue-400"
26
+ >
22
27
  <Maximize2 size={12} />
23
28
  </button>
24
- <button type="button" onClick={onClose} title={labels.flowGroup.close} className="hover:text-red-600 dark:hover:text-red-400">
29
+ <button
30
+ type="button"
31
+ onClick={onClose}
32
+ data-cv-tooltip={labels.flowGroup.close} aria-label={labels.flowGroup.close}
33
+ className="hover:text-red-600 dark:hover:text-red-400"
34
+ >
25
35
  <X size={12} />
26
36
  </button>
27
37
  </div>
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Legenda do canvas.
5
+ *
6
+ * O editor distingue os tipos de ligação só por cor e traço — opção escolhida, fallback, salto entre
7
+ * fluxos, conversa viva — e nada na tela dizia o que era o quê. Quem não desenhou o fluxo lia um
8
+ * emaranhado colorido; a cor sem chave não informa, decora.
9
+ *
10
+ * Os contornos e marcas do card entram na mesma chave: card solto, início do fluxo e o ícone de
11
+ * repetição são vocabulário do editor tanto quanto as cores dos fios.
12
+ *
13
+ * Recolhida por padrão: em canvas cheio, uma caixa fixa cobre card. Quem já conhece o vocabulário
14
+ * não paga o espaço, e quem não conhece está a um clique.
15
+ */
16
+
17
+ import { useState } from 'react'
18
+ import { ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'
19
+
20
+ import type { FlowEditorLabels } from './labels'
21
+ import type { FlowNodeType } from './flowGraph'
22
+
23
+ export type FlowLegendEdgeSample = {
24
+ readonly color: string
25
+ readonly dash?: string | undefined
26
+ readonly label: string
27
+ }
28
+
29
+ export type FlowLegendProps = {
30
+ readonly labels: FlowEditorLabels
31
+ readonly edgeSamples: readonly FlowLegendEdgeSample[]
32
+ /** Classe de cor por tipo de card — a mesma que o `FlowNodeCard` usa, para a chave bater. */
33
+ readonly nodeSwatches: readonly { readonly type: FlowNodeType; readonly className: string }[]
34
+ }
35
+
36
+ function EdgeSample({ sample }: { sample: FlowLegendEdgeSample }) {
37
+ return (
38
+ <li className="flex items-center gap-2">
39
+ <svg width="28" height="12" viewBox="0 0 28 12" aria-hidden="true" className="shrink-0">
40
+ <line
41
+ x1="2"
42
+ y1="6"
43
+ x2="26"
44
+ y2="6"
45
+ stroke={sample.color}
46
+ strokeWidth="1.75"
47
+ {...(sample.dash ? { strokeDasharray: sample.dash } : {})}
48
+ />
49
+ </svg>
50
+ <span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">{sample.label}</span>
51
+ </li>
52
+ )
53
+ }
54
+
55
+ export function FlowLegend({ labels, edgeSamples, nodeSwatches }: FlowLegendProps) {
56
+ const [open, setOpen] = useState(false)
57
+
58
+ return (
59
+ <div className="rounded-lg border border-gray-200 bg-white/95 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/95">
60
+ <button
61
+ type="button"
62
+ onClick={() => setOpen((current) => !current)}
63
+ data-cv-tooltip={labels.legendPanel.title}
64
+ aria-label={labels.legendPanel.title}
65
+ aria-expanded={open}
66
+ className="flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
67
+ >
68
+ {labels.legendPanel.title}
69
+ {open ? <ChevronDown size={13} /> : <ChevronUp size={13} />}
70
+ </button>
71
+
72
+ {open && (
73
+ <div className="max-h-72 w-64 overflow-y-auto border-t border-gray-100 px-2.5 py-2 dark:border-gray-700">
74
+ <p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
75
+ {labels.legendPanel.connections}
76
+ </p>
77
+ <ul className="space-y-1.5">
78
+ {edgeSamples.map((sample) => (
79
+ <EdgeSample key={sample.label} sample={sample} />
80
+ ))}
81
+ </ul>
82
+
83
+ <p className="mb-1 mt-3 text-[10px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
84
+ {labels.legendPanel.nodes}
85
+ </p>
86
+ <ul className="space-y-1.5">
87
+ {nodeSwatches.map((swatch) => (
88
+ <li key={swatch.type} className="flex items-center gap-2">
89
+ <span className={`h-3 w-5 shrink-0 rounded border-2 ${swatch.className}`} aria-hidden="true" />
90
+ <span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">
91
+ {labels.legend[swatch.type]}
92
+ </span>
93
+ </li>
94
+ ))}
95
+ <li className="flex items-center gap-2">
96
+ <span
97
+ className="h-3 w-5 shrink-0 rounded border-2 border-dashed border-amber-400"
98
+ aria-hidden="true"
99
+ />
100
+ <span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">
101
+ {labels.legendPanel.detached}
102
+ </span>
103
+ </li>
104
+ <li className="flex items-center gap-2">
105
+ <span className="flex h-3 w-5 shrink-0 items-center justify-center" aria-hidden="true">
106
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
107
+ </span>
108
+ <span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">
109
+ {labels.legendPanel.startNode}
110
+ </span>
111
+ </li>
112
+ <li className="flex items-center gap-2">
113
+ <span className="flex h-3 w-5 shrink-0 items-center justify-center text-gray-400" aria-hidden="true">
114
+ <RotateCcw size={12} strokeWidth={2.5} />
115
+ </span>
116
+ <span className="text-[11px] leading-tight text-gray-600 dark:text-gray-300">
117
+ {labels.legendPanel.selfLoop}
118
+ </span>
119
+ </li>
120
+ </ul>
121
+ </div>
122
+ )}
123
+ </div>
124
+ )
125
+ }
@@ -27,18 +27,21 @@ export function FlowMapCanvas({ graphs, rootKey, onOpenFlow, labels: labelsOverr
27
27
  const positions = useMemo(() => computeFlowMapLayout(graphs, rootKey), [graphs, rootKey])
28
28
 
29
29
  const nodes = useMemo<Node[]>(
30
- () => Object.values(graphs).map((g): Node => ({
31
- id: g.key,
32
- type: 'flowMapNode',
33
- position: positions[g.key] ?? { x: 0, y: 0 },
34
- data: {
35
- label: g.label,
36
- nodeCount: Object.keys(g.nodes).length,
37
- isRoot: g.key === rootKey,
38
- labels,
39
- onOpen: () => onOpenFlow(g.key),
40
- } satisfies FlowMapNodeData,
41
- })),
30
+ () =>
31
+ Object.values(graphs).map(
32
+ (g): Node => ({
33
+ id: g.key,
34
+ type: 'flowMapNode',
35
+ position: positions[g.key] ?? { x: 0, y: 0 },
36
+ data: {
37
+ label: g.label,
38
+ nodeCount: Object.keys(g.nodes).length,
39
+ isRoot: g.key === rootKey,
40
+ labels,
41
+ onOpen: () => onOpenFlow(g.key),
42
+ } satisfies FlowMapNodeData,
43
+ }),
44
+ ),
42
45
  [graphs, positions, rootKey, onOpenFlow, labels],
43
46
  )
44
47
 
@@ -21,10 +21,13 @@ export function FlowMapNode({ data }: NodeProps) {
21
21
  <div className="flex items-center gap-1.5 text-emerald-700 dark:text-emerald-300">
22
22
  <GitBranch size={14} />
23
23
  <span className="text-sm font-semibold truncate">{label}</span>
24
- {isRoot && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" title={labels.startNodeTooltip} />}
24
+ {isRoot && (
25
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500 shrink-0" data-cv-tooltip={labels.startNodeTooltip} />
26
+ )}
25
27
  </div>
26
28
  <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{labels.flowMap.nodeCount(nodeCount)}</p>
27
29
  <button
30
+ data-cv-tooltip={labels.flowMap.openFlow} aria-label={labels.flowMap.openFlow}
28
31
  type="button"
29
32
  onClick={onOpen}
30
33
  className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
@@ -1,9 +1,32 @@
1
+ import { useState } from 'react'
1
2
  import { Handle, Position, type NodeProps } from '@xyflow/react'
2
- import { MessageCircleQuestion, GitBranch, Zap, ListTree, Diamond, AlertTriangle, AlertCircle, Headset, Clock3, ShoppingBag, type LucideIcon } from 'lucide-react'
3
+ import {
4
+ MessageCircleQuestion,
5
+ Plus,
6
+ GitBranch,
7
+ Zap,
8
+ ListTree,
9
+ Diamond,
10
+ AlertTriangle,
11
+ AlertCircle,
12
+ Headset,
13
+ Clock3,
14
+ ShoppingBag,
15
+ RotateCcw,
16
+ type LucideIcon,
17
+ } from 'lucide-react'
3
18
  import type { FlowEditorLabels } from './labels'
4
- import type { FlowNodeData, GraphIssue } from './flowGraph'
19
+ import { PASS_THROUGH_ACTION_KINDS, type FlowNodeData, type GraphIssue } from './flowGraph'
5
20
 
6
- const NODE_TYPE_COLOR: Record<FlowNodeData['type'], string> = {
21
+ /**
22
+ * Diâmetro dos pontos de ligação.
23
+ *
24
+ * O padrão do react-flow tem ~6px, e mirar nele com o mouse é tarefa de precisão — some com a
25
+ * borda do card e não se lê como "puxe daqui". Vale para o alvo (topo) e para cada saída.
26
+ */
27
+ const HANDLE_SIZE_PX = 14
28
+
29
+ export const NODE_TYPE_COLOR: Record<FlowNodeData['type'], string> = {
7
30
  question: 'border-blue-300 bg-blue-50 dark:bg-blue-950/40 dark:border-blue-800',
8
31
  entrada_choice: 'border-purple-300 bg-purple-50 dark:bg-purple-950/40 dark:border-purple-800',
9
32
  action: 'border-orange-300 bg-orange-50 dark:bg-orange-950/40 dark:border-orange-800',
@@ -31,6 +54,9 @@ const DEFAULT_ACTION_KIND_ICON: Record<string, LucideIcon> = {
31
54
  // que só faz sentido para quem escreveu o interpretador do fluxo, não para quem está editando.
32
55
  export function nodeLabel(node: FlowNodeData | undefined, labels: FlowEditorLabels): string {
33
56
  if (!node) return '—'
57
+ // Apelido dado no editor manda sobre qualquer texto derivado: dois nós de mesma ação ficam
58
+ // idênticos no card sem ele, e é exatamente para desempatá-los que o campo existe.
59
+ if (node.label) return node.label
34
60
  if (node.type === 'action') {
35
61
  const actionKindLabel = node.actionKind ? labels.actionKindLabels[node.actionKind] : undefined
36
62
  // Sempre retorna string: um actionKind futuro sem label mapeado cai no próprio valor bruto
@@ -50,82 +76,230 @@ export type FlowNodeCardData = {
50
76
  liveCount: number
51
77
  isStart: boolean
52
78
  isSelected: boolean
79
+ /**
80
+ * Nó sem nenhuma ligação de entrada — o bot nunca chega nele. Contorno tracejado e pulso: sem
81
+ * isso, desconectar um fio ou criar um card solto passa despercebido até o fluxo quebrar em
82
+ * produção.
83
+ */
84
+ isDetached?: boolean
53
85
  issues: GraphIssue[]
54
86
  labels: FlowEditorLabels
55
87
  actionKindIcons?: Record<string, LucideIcon>
56
88
  onSelect: (id: string) => void
89
+ /**
90
+ * Criar o próximo nó já ligado nesta saída. Capacidade por ausência: sem o callback, o "+" não
91
+ * aparece — puxar o fio à mão continua funcionando igual.
92
+ */
93
+ onQuickAdd?: ((params: { nodeId: string; handle: string; anchor: { x: number; y: number } }) => void) | undefined
57
94
  }
58
95
 
59
96
  // Uma linha por saída: nós de escolha (question+choice ou menu) ganham UMA linha por opção mais
60
97
  // uma linha "caso contrário" — cada uma com seu próprio handle, arrastável pra uma conexão
61
98
  // condicional distinta. Nós lineares (pergunta simples) têm uma única linha "Próximo". Ações são
62
99
  // terminais (o motor do host nunca continua a partir de 'next' de uma ação) — sem linha.
63
- function sourceRows(node: FlowNodeData, labels: FlowEditorLabels): { id: string; label: string; isDefault: boolean }[] {
64
- if (node.type === 'action') return []
100
+ type SourceRowSpec = { id: string; label: string; isDefault: boolean; isSelfLoop: boolean }
101
+
102
+ /**
103
+ * Quais saídas deste card voltam para ele mesmo.
104
+ *
105
+ * Sai do próprio nó porque é o único lugar que sabe: uma aresta de A para A não tem trajeto para
106
+ * desenhar, então quem mostra o comportamento é a linha de saída, com um ícone de repetição.
107
+ */
108
+ function selfLoopHandles(node: FlowNodeData): Set<string> {
109
+ const loops = new Set<string>()
110
+ if (typeof node.next === 'string') {
111
+ if (node.next === node.id) loops.add('next')
112
+ return loops
113
+ }
114
+ if (!node.next) return loops
115
+
116
+ for (const [optionId, target] of Object.entries(node.next.byAnswer ?? {})) {
117
+ if (target === node.id) loops.add(optionId)
118
+ }
119
+ if (node.next.default === node.id) loops.add('__default')
120
+
121
+ return loops
122
+ }
123
+
124
+ function sourceRows(node: FlowNodeData, labels: FlowEditorLabels): SourceRowSpec[] {
125
+ // Ação de passagem tem saída; ação terminal, não — ver `PASS_THROUGH_ACTION_KINDS`.
126
+ if (node.type === 'action') {
127
+ return node.actionKind && PASS_THROUGH_ACTION_KINDS.includes(node.actionKind)
128
+ ? [{ id: 'next', label: labels.nodePanel.nextRowLabel, isDefault: false, isSelfLoop: false }]
129
+ : []
130
+ }
131
+
132
+ const loops = selfLoopHandles(node)
65
133
  if (node.type === 'condition') {
66
134
  return [
67
- { id: 'true', label: labels.nodePanel.conditionTrue, isDefault: false },
68
- { id: 'false', label: labels.nodePanel.conditionFalse, isDefault: false },
135
+ { id: 'true', label: labels.nodePanel.conditionTrue, isDefault: false, isSelfLoop: loops.has('true') },
136
+ { id: 'false', label: labels.nodePanel.conditionFalse, isDefault: false, isSelfLoop: loops.has('false') },
69
137
  ]
70
138
  }
71
139
  const isChoice = node.type === 'menu' || node.questionType === 'choice'
72
- if (!isChoice) return [{ id: 'next', label: labels.nodePanel.nextRowLabel, isDefault: false }]
140
+ if (!isChoice)
141
+ return [{ id: 'next', label: labels.nodePanel.nextRowLabel, isDefault: false, isSelfLoop: loops.has('next') }]
73
142
  const options = node.options ?? []
74
143
  return [
75
- ...options.map(([id, label]) => ({ id, label, isDefault: false })),
76
- { id: '__default', label: labels.edgeFallbackLabel, isDefault: true },
144
+ ...options.map(([id, label]) => ({ id, label, isDefault: false, isSelfLoop: loops.has(id) })),
145
+ { id: '__default', label: labels.edgeFallbackLabel, isDefault: true, isSelfLoop: loops.has('__default') },
77
146
  ]
78
147
  }
79
148
 
80
149
  // Uma linha de saída, com seu próprio handle ancorado na borda direita da própria linha (não
81
150
  // mais distribuído na borda inferior do card) — assim dá pra ler "opção → destino" sem seguir
82
151
  // o fio até o label da ligação, que é justamente o que confundia num fluxo com muitos ramos.
83
- function SourceRow({ label, isDefault, handleId }: { label: string; isDefault: boolean; handleId: string }) {
152
+ /**
153
+ * Uma saída do card: o rótulo, o ponto de onde se puxa o fio e o "+" que cria o próximo nó já
154
+ * ligado nele.
155
+ *
156
+ * Duas decisões que vieram de ver alguém usar:
157
+ *
158
+ * O "+" está **sempre visível**, esmaecido, e não aparece no hover. Aparecer no hover criava uma
159
+ * corrida contra o mouse: o botão fica à direita da linha, então o ponteiro atravessava o vão
160
+ * entre os dois, o hover caía e o botão sumia antes de ser alcançado.
161
+ *
162
+ * E a área que reage ao mouse **engloba o botão** (o `pr-9` do container), em vez de terminar na
163
+ * borda da linha — sem isso o vão continuaria existindo para o realce.
164
+ */
165
+ function SourceRow({
166
+ label,
167
+ isDefault,
168
+ handleId,
169
+ addLabel,
170
+ isSelfLoop,
171
+ selfLoopLabel,
172
+ onQuickAdd,
173
+ }: {
174
+ label: string
175
+ isDefault: boolean
176
+ handleId: string
177
+ addLabel: string
178
+ isSelfLoop: boolean
179
+ selfLoopLabel: string
180
+ onQuickAdd?: ((params: { handle: string; anchor: { x: number; y: number } }) => void) | undefined
181
+ }) {
182
+ const [hovered, setHovered] = useState(false)
183
+
84
184
  return (
85
- <div className="relative flex items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-600 bg-white/70 dark:bg-gray-900/40 px-2 py-1 pr-3">
86
- <span className={`text-xs truncate flex-1 ${isDefault ? 'italic text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}>
87
- {label}
88
- </span>
89
- <Handle
90
- type="source"
91
- position={Position.Right}
92
- id={handleId}
93
- style={{ position: 'absolute', right: -7, top: '50%', transform: 'translateY(-50%)' }}
94
- className={isDefault ? '!bg-gray-400 dark:!bg-gray-500' : '!bg-purple-500'}
95
- />
185
+ <div
186
+ className={`relative ${onQuickAdd ? 'pr-9' : ''}`}
187
+ onMouseEnter={() => setHovered(true)}
188
+ onMouseLeave={() => setHovered(false)}
189
+ >
190
+ <div className="relative flex items-center gap-1.5 rounded-md border border-gray-200 dark:border-gray-600 bg-white/70 dark:bg-gray-900/40 px-2 py-1 pr-3">
191
+ <span
192
+ className={`text-xs truncate flex-1 ${isDefault ? 'italic text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-200'}`}
193
+ >
194
+ {label}
195
+ </span>
196
+ {isSelfLoop && (
197
+ <RotateCcw
198
+ size={12}
199
+ strokeWidth={2.5}
200
+ data-cv-tooltip={selfLoopLabel}
201
+ aria-label={selfLoopLabel}
202
+ className="shrink-0 text-gray-400 dark:text-gray-500"
203
+ />
204
+ )}
205
+ <Handle
206
+ type="source"
207
+ position={Position.Right}
208
+ id={handleId}
209
+ style={{
210
+ position: 'absolute',
211
+ right: -(HANDLE_SIZE_PX / 2 + 1),
212
+ top: '50%',
213
+ transform: 'translateY(-50%)',
214
+ width: HANDLE_SIZE_PX,
215
+ height: HANDLE_SIZE_PX,
216
+ }}
217
+ className={`!border-2 !border-white dark:!border-gray-800 ${isDefault ? '!bg-gray-400 dark:!bg-gray-500' : '!bg-purple-500'}`}
218
+ />
219
+ </div>
220
+ {onQuickAdd && (
221
+ <button
222
+ type="button"
223
+ data-cv-tooltip={addLabel}
224
+ aria-label={addLabel}
225
+ onClick={(event) => {
226
+ event.stopPropagation()
227
+ const rect = event.currentTarget.getBoundingClientRect()
228
+ onQuickAdd({ handle: handleId, anchor: { x: rect.right, y: rect.top } })
229
+ }}
230
+ // Opacidade inline, e não classe utilitária: o realce é estado de interação deste
231
+ // botão, e prende o valor ao componente em vez de depender do CSS gerado no build.
232
+ style={{ opacity: hovered ? 1 : 0.35 }}
233
+ className="nodrag absolute right-0 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border border-blue-300 bg-white text-blue-600 shadow-sm transition-opacity hover:bg-blue-50 dark:border-blue-700 dark:bg-gray-800 dark:text-blue-400 dark:hover:bg-gray-700"
234
+ >
235
+ <Plus size={12} strokeWidth={3} />
236
+ </button>
237
+ )}
96
238
  </div>
97
239
  )
98
240
  }
99
241
 
100
242
  export function FlowNodeCard({ data }: NodeProps) {
101
- const { node, liveCount, isStart, isSelected, issues, labels, actionKindIcons, onSelect } = data as unknown as FlowNodeCardData
243
+ const { node, liveCount, isStart, isSelected, isDetached, issues, labels, actionKindIcons, onSelect, onQuickAdd } =
244
+ data as unknown as FlowNodeCardData
102
245
  const label = nodeLabel(node, labels)
103
246
  const iconMap = { ...DEFAULT_ACTION_KIND_ICON, ...actionKindIcons }
104
- const Icon = node.type === 'action' && node.actionKind ? (iconMap[node.actionKind] ?? NODE_TYPE_ICON[node.type]) : NODE_TYPE_ICON[node.type]
247
+ const Icon =
248
+ node.type === 'action' && node.actionKind
249
+ ? (iconMap[node.actionKind] ?? NODE_TYPE_ICON[node.type])
250
+ : NODE_TYPE_ICON[node.type]
105
251
  const rows = sourceRows(node, labels)
106
- const hasError = issues.some((i) => i.severity === 'error')
107
- const hasWarning = !hasError && issues.some((i) => i.severity === 'warning')
252
+ // Filtrado pelo nó, e não pelo fluxo: sem isto TODO card do fluxo acendia o alerta quando UM
253
+ // deles tinha problema, e o ícone deixava de apontar qualquer coisa. Aviso sem alvo é ruído.
254
+ const nodeIssues = issues.filter((issue) => issue.nodeId === node.id)
255
+ const errors = nodeIssues.filter((issue) => issue.severity === 'error')
256
+ const warnings = nodeIssues.filter((issue) => issue.severity === 'warning')
257
+ const hasError = errors.length > 0
258
+ const hasWarning = !hasError && warnings.length > 0
259
+ // Separador em vez de quebra de linha: o balão não preserva `\n`. O ícone dizia que havia algo
260
+ // errado sem dizer o quê, e corrigir passava por abrir o painel de cada card procurando.
261
+ const issueTooltip = (hasError ? errors : warnings).map((issue) => issue.message).join(' · ')
108
262
 
109
263
  return (
110
264
  <div
111
- title={label}
112
- className={`relative rounded-lg border-2 px-3 py-2 w-60 cursor-pointer shadow-sm hover:shadow-md transition-shadow ${NODE_TYPE_COLOR[node.type]} ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 dark:ring-offset-gray-900' : ''}`}
265
+ data-cv-tooltip={isDetached ? labels.detachedNodeTooltip : label}
266
+ className={`relative rounded-lg border-2 px-3 py-2 w-60 cursor-pointer shadow-sm hover:shadow-md transition-shadow ${NODE_TYPE_COLOR[node.type]} ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 dark:ring-offset-gray-900' : ''} ${isDetached ? 'border-dashed !border-amber-400 animate-pulse' : ''}`}
113
267
  onClick={() => onSelect(node.id)}
114
268
  >
115
- <Handle type="target" position={Position.Top} id="target" className="!bg-gray-400 dark:!bg-gray-500" />
269
+ <Handle
270
+ type="target"
271
+ position={Position.Top}
272
+ id="target"
273
+ style={{ width: HANDLE_SIZE_PX, height: HANDLE_SIZE_PX }}
274
+ className="!bg-gray-400 !border-2 !border-white dark:!bg-gray-500 dark:!border-gray-800"
275
+ />
116
276
 
117
277
  <div className="flex items-center justify-between gap-2">
118
278
  <span className="flex items-center gap-1.5 uppercase tracking-wide font-semibold text-gray-500 dark:text-gray-400 text-xs">
119
279
  <Icon size={12} strokeWidth={2.5} />
120
- {isStart && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" title={labels.startNodeTooltip} />}
280
+ {isStart && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" data-cv-tooltip={labels.startNodeTooltip} />}
121
281
  {labels.legend[node.type]}
122
282
  </span>
123
283
  <div className="flex items-center gap-1">
124
- {hasError && <AlertCircle size={13} className="text-red-600 dark:text-red-400" />}
125
- {hasWarning && <AlertTriangle size={13} className="text-amber-500 dark:text-amber-400" />}
284
+ {hasError && (
285
+ <AlertCircle
286
+ size={13}
287
+ data-cv-tooltip={issueTooltip}
288
+ aria-label={issueTooltip}
289
+ className="text-red-600 dark:text-red-400"
290
+ />
291
+ )}
292
+ {hasWarning && (
293
+ <AlertTriangle
294
+ size={13}
295
+ data-cv-tooltip={issueTooltip}
296
+ aria-label={issueTooltip}
297
+ className="text-amber-500 dark:text-amber-400"
298
+ />
299
+ )}
126
300
  {liveCount > 0 && (
127
301
  <span
128
- title={labels.liveCountTooltip(liveCount)}
302
+ data-cv-tooltip={labels.liveCountTooltip(liveCount)}
129
303
  className="font-bold bg-blue-600 text-white rounded-full px-1.5 py-0.5 text-xs"
130
304
  >
131
305
  {liveCount}
@@ -139,7 +313,18 @@ export function FlowNodeCard({ data }: NodeProps) {
139
313
  {rows.length > 0 && (
140
314
  <div className="mt-2 space-y-1">
141
315
  {rows.map((row) => (
142
- <SourceRow key={row.id} label={row.label} isDefault={row.isDefault} handleId={row.id} />
316
+ <SourceRow
317
+ key={row.id}
318
+ label={row.label}
319
+ isDefault={row.isDefault}
320
+ handleId={row.id}
321
+ addLabel={labels.quickAdd.fromHandle}
322
+ isSelfLoop={row.isSelfLoop}
323
+ selfLoopLabel={labels.legendPanel.selfLoop}
324
+ {...(onQuickAdd
325
+ ? { onQuickAdd: (params) => onQuickAdd({ nodeId: node.id, ...params }) }
326
+ : {})}
327
+ />
143
328
  ))}
144
329
  </div>
145
330
  )}