Andrew commited on
Commit
e9dc149
·
1 Parent(s): e522062

feat(ui): add conversation tree visualization component

Browse files
src/lib/components/ConversationTreeGraph.svelte ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import CarbonUser from "~icons/carbon/user";
3
+ import CarbonChat from "~icons/carbon/chat";
4
+ import { MessageRole } from "$lib/types/Message";
5
+ import { getPersonaColor } from "$lib/utils/personaColors";
6
+ import type { TreeLayoutNode } from "$lib/utils/tree/layout";
7
+
8
+ interface Props {
9
+ treeData: { nodes: TreeLayoutNode[]; width: number; height: number };
10
+ onNodeClick: (messageId: string) => void;
11
+ }
12
+
13
+ let { treeData, onNodeClick }: Props = $props();
14
+ </script>
15
+
16
+ {#if treeData.nodes.length > 0}
17
+ {@const nodeSize = 24}
18
+ {@const iconSize = 18}
19
+ {@const svgWidth = Math.max(treeData.width, 100)}
20
+ {@const svgHeight = Math.max(treeData.height, 50)}
21
+
22
+ <div class="mt-2 mb-3 flex justify-center conversation-tree">
23
+ <svg
24
+ width={svgWidth}
25
+ height={svgHeight}
26
+ class="overflow-visible"
27
+ >
28
+ <!-- Draw connecting lines -->
29
+ {#each treeData.nodes as node}
30
+ {#if node.parentX !== undefined && node.parentY !== undefined}
31
+ {@const parentNode = treeData.nodes.find(n => n.x === node.parentX && n.y === node.parentY)}
32
+ {@const isMultiPersonaResponse = node.message.from === MessageRole.Assistant && node.message.personaResponses && node.message.personaResponses.length > 1}
33
+ {@const parentIsUser = parentNode?.message.from === MessageRole.User}
34
+
35
+ {#if parentIsUser && isMultiPersonaResponse && node.message.personaResponses}
36
+ {@const personaCount = node.message.personaResponses.length}
37
+ {@const spacing = 8}
38
+ {@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
39
+
40
+ <!-- Use ELK coordinates directly -->
41
+ {@const leftmostX = node.x + iconSize / 2}
42
+ {@const rightmostX = leftmostX + (personaCount - 1) * (iconSize + spacing)}
43
+ {@const childCenterX = node.x + totalWidth / 2}
44
+
45
+ {@const junctionY = node.y - (node.y - node.parentY - nodeSize) * 0.25}
46
+ {@const parentWidth = parentNode?.width || nodeSize}
47
+ {@const parentCenterX = node.parentX + parentWidth / 2}
48
+
49
+ <!-- Curve from Parent to Child Center -->
50
+ <path
51
+ d="M {parentCenterX},{node.parentY + nodeSize}
52
+ C {parentCenterX},{node.parentY + nodeSize + 15}
53
+ {childCenterX},{junctionY - 15}
54
+ {childCenterX},{junctionY}"
55
+ stroke="currentColor"
56
+ stroke-width="1.5"
57
+ fill="none"
58
+ class={node.isOnActivePath ? "text-gray-400 dark:text-gray-500" : "text-gray-300 dark:text-gray-600"}
59
+ />
60
+
61
+ <!-- Horizontal bar connecting all personas -->
62
+ <line
63
+ x1={leftmostX}
64
+ y1={junctionY}
65
+ x2={rightmostX}
66
+ y2={junctionY}
67
+ stroke="currentColor"
68
+ stroke-width="1.5"
69
+ class={node.isOnActivePath ? "text-gray-400 dark:text-gray-500" : "text-gray-300 dark:text-gray-600"}
70
+ />
71
+
72
+ <!-- Vertical drops to each persona -->
73
+ {#each node.message.personaResponses as persona, personaIndex}
74
+ {@const dropX = leftmostX + personaIndex * (iconSize + spacing)}
75
+ <path
76
+ d="M {dropX},{junctionY}
77
+ Q {dropX},{junctionY + 5}
78
+ {dropX},{node.y}"
79
+ stroke="currentColor"
80
+ stroke-width="1.5"
81
+ fill="none"
82
+ class={node.isOnActivePath ? "text-gray-400 dark:text-gray-500" : "text-gray-300 dark:text-gray-600"}
83
+ />
84
+ {/each}
85
+ {:else}
86
+ {@const parentWidth = parentNode?.width || nodeSize}
87
+ {@const parentCenterX = node.parentX + parentWidth / 2}
88
+
89
+ {@const x1 = parentCenterX}
90
+ {@const y1 = node.parentY + nodeSize}
91
+ {@const x2 = node.x + nodeSize / 2}
92
+ {@const y2 = node.y}
93
+ {@const controlOffset = Math.abs(y2 - y1) * 0.3}
94
+
95
+ <path
96
+ d="M {x1},{y1}
97
+ C {x1},{y1 + controlOffset}
98
+ {x2},{y2 - controlOffset}
99
+ {x2},{y2}"
100
+ stroke="currentColor"
101
+ stroke-width="1.5"
102
+ fill="none"
103
+ class={node.isOnActivePath ? "text-gray-400 dark:text-gray-500" : "text-gray-300 dark:text-gray-600"}
104
+ />
105
+ {/if}
106
+ {/if}
107
+ {/each}
108
+
109
+
110
+ <!-- Draw nodes -->
111
+ {#each treeData.nodes as node}
112
+ {@const cx = node.x + nodeSize / 2}
113
+ {@const cy = node.y + nodeSize / 2}
114
+
115
+ {#if node.message.from === MessageRole.User}
116
+ <foreignObject
117
+ x={cx - iconSize / 2}
118
+ y={cy - iconSize / 2}
119
+ width={iconSize}
120
+ height={iconSize}
121
+ role="button"
122
+ tabindex="0"
123
+ class="cursor-pointer hover:opacity-80 transition-opacity"
124
+ onclick={() => onNodeClick(node.message.id)}
125
+ onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
126
+ >
127
+ <div
128
+ class={`flex items-center justify-center w-full h-full rounded-full ${node.isActive ? 'opacity-100 scale-110' : ''} ${node.isOnActivePath ? 'text-gray-400 dark:text-gray-500' : 'text-gray-300 dark:text-gray-600'}`}
129
+ >
130
+ <CarbonUser class="w-full h-full" />
131
+ </div>
132
+ </foreignObject>
133
+ {:else if node.message.personaResponses && node.message.personaResponses.length > 0}
134
+ {@const personaCount = node.message.personaResponses.length}
135
+ {@const spacing = 8}
136
+
137
+ {#if personaCount === 1}
138
+ <!-- Single persona: center it like user icons -->
139
+ <foreignObject
140
+ x={cx - iconSize / 2}
141
+ y={cy - iconSize / 2}
142
+ width={iconSize}
143
+ height={iconSize}
144
+ role="button"
145
+ tabindex="0"
146
+ class="cursor-pointer hover:opacity-80 transition-opacity"
147
+ onclick={() => onNodeClick(node.message.id)}
148
+ onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
149
+ >
150
+ <div
151
+ class="flex items-center justify-center w-full h-full rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
152
+ style="color: {getPersonaColor(node.message.personaResponses[0].personaId)};"
153
+ title={node.message.personaResponses[0].personaName || node.message.personaResponses[0].personaId}
154
+ >
155
+ <CarbonChat class="w-full h-full" />
156
+ </div>
157
+ </foreignObject>
158
+ {:else}
159
+ <!-- Multiple personas: distribute horizontally -->
160
+ {@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
161
+ {@const startX = node.x + iconSize / 2}
162
+
163
+ {#each node.message.personaResponses as response, i}
164
+ {@const iconX = startX + i * (iconSize + spacing)}
165
+ <foreignObject
166
+ x={iconX - iconSize / 2}
167
+ y={cy - iconSize / 2}
168
+ width={iconSize}
169
+ height={iconSize}
170
+ role="button"
171
+ tabindex="0"
172
+ class="cursor-pointer hover:opacity-80 transition-opacity"
173
+ onclick={() => onNodeClick(node.message.id)}
174
+ onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
175
+ >
176
+ <div
177
+ class="flex items-center justify-center w-full h-full rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
178
+ style="color: {getPersonaColor(response.personaId)};"
179
+ title={response.personaName || response.personaId}
180
+ >
181
+ <CarbonChat class="w-full h-full" />
182
+ </div>
183
+ </foreignObject>
184
+ {/each}
185
+ {/if}
186
+ {:else}
187
+ <foreignObject
188
+ x={cx - iconSize / 2}
189
+ y={cy - iconSize / 2}
190
+ width={iconSize}
191
+ height={iconSize}
192
+ role="button"
193
+ tabindex="0"
194
+ class="cursor-pointer hover:opacity-80 transition-opacity"
195
+ onclick={() => onNodeClick(node.message.id)}
196
+ onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
197
+ >
198
+ <div
199
+ class="flex items-center justify-center w-full h-full rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
200
+ style="color: #10b981;"
201
+ >
202
+ <CarbonChat class="w-full h-full" />
203
+ </div>
204
+ </foreignObject>
205
+ {/if}
206
+ {/each}
207
+ </svg>
208
+ </div>
209
+ {/if}
210
+
211
+ <style>
212
+ .conversation-tree {
213
+ animation: fadeIn 0.3s ease-in;
214
+ }
215
+
216
+ .conversation-tree path,
217
+ .conversation-tree line,
218
+ .conversation-tree foreignObject {
219
+ animation: fadeIn 0.4s ease-in;
220
+ }
221
+
222
+ @keyframes fadeIn {
223
+ from {
224
+ opacity: 0;
225
+ }
226
+ to {
227
+ opacity: 1;
228
+ }
229
+ }
230
+ </style>