File size: 7,986 Bytes
e9dc149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
<script lang="ts">
	import CarbonUser from "~icons/carbon/user";
	import CarbonChat from "~icons/carbon/chat";
	import { MessageRole } from "$lib/types/Message";
	import { getPersonaColor } from "$lib/utils/personaColors";
	import type { TreeLayoutNode } from "$lib/utils/tree/layout";

	interface Props {
		treeData: { nodes: TreeLayoutNode[]; width: number; height: number };
		onNodeClick: (messageId: string) => void;
	}

	let { treeData, onNodeClick }: Props = $props();
</script>

{#if treeData.nodes.length > 0}
	{@const nodeSize = 24}
	{@const iconSize = 18}
	{@const svgWidth = Math.max(treeData.width, 100)}
	{@const svgHeight = Math.max(treeData.height, 50)}
	
	<div class="mt-2 mb-3 flex justify-center conversation-tree">
		<svg 
			width={svgWidth} 
			height={svgHeight}
			class="overflow-visible"
		>
			<!-- Draw connecting lines -->
			{#each treeData.nodes as node}
				{#if node.parentX !== undefined && node.parentY !== undefined}
					{@const parentNode = treeData.nodes.find(n => n.x === node.parentX && n.y === node.parentY)}
					{@const isMultiPersonaResponse = node.message.from === MessageRole.Assistant && node.message.personaResponses && node.message.personaResponses.length > 1}
					{@const parentIsUser = parentNode?.message.from === MessageRole.User}
					
					{#if parentIsUser && isMultiPersonaResponse && node.message.personaResponses}
						{@const personaCount = node.message.personaResponses.length}
						{@const spacing = 8}
						{@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
						
						<!-- Use ELK coordinates directly -->
						{@const leftmostX = node.x + iconSize / 2}
						{@const rightmostX = leftmostX + (personaCount - 1) * (iconSize + spacing)}
						{@const childCenterX = node.x + totalWidth / 2}

						{@const junctionY = node.y - (node.y - node.parentY - nodeSize) * 0.25}
						{@const parentWidth = parentNode?.width || nodeSize}
						{@const parentCenterX = node.parentX + parentWidth / 2}
						
						<!-- Curve from Parent to Child Center -->
						<path
							d="M {parentCenterX},{node.parentY + nodeSize} 
							   C {parentCenterX},{node.parentY + nodeSize + 15} 
							     {childCenterX},{junctionY - 15}
							     {childCenterX},{junctionY}"
							stroke="currentColor"
							stroke-width="1.5"
							fill="none"
							class={node.isOnActivePath ? "text-gray-400 dark:text-gray-500" : "text-gray-300 dark:text-gray-600"}
						/>
						
						<!-- Horizontal bar connecting all personas -->
						<line
							x1={leftmostX}
							y1={junctionY}
							x2={rightmostX}
							y2={junctionY}
							stroke="currentColor"
							stroke-width="1.5"
							class={node.isOnActivePath ? "text-gray-400 dark:text-gray-500" : "text-gray-300 dark:text-gray-600"}
						/>
						
						<!-- Vertical drops to each persona -->
						{#each node.message.personaResponses as persona, personaIndex}
							{@const dropX = leftmostX + personaIndex * (iconSize + spacing)}
							<path
								d="M {dropX},{junctionY} 
								   Q {dropX},{junctionY + 5} 
								   {dropX},{node.y}"
								stroke="currentColor"
								stroke-width="1.5"
								fill="none"
								class={node.isOnActivePath ? "text-gray-400 dark:text-gray-500" : "text-gray-300 dark:text-gray-600"}
							/>
						{/each}
					{:else}
						{@const parentWidth = parentNode?.width || nodeSize}
						{@const parentCenterX = node.parentX + parentWidth / 2}
						
						{@const x1 = parentCenterX}
						{@const y1 = node.parentY + nodeSize}
						{@const x2 = node.x + nodeSize / 2}
						{@const y2 = node.y}
						{@const controlOffset = Math.abs(y2 - y1) * 0.3}
						
						<path
							d="M {x1},{y1} 
							   C {x1},{y1 + controlOffset} 
							   {x2},{y2 - controlOffset} 
							   {x2},{y2}"
							stroke="currentColor"
							stroke-width="1.5"
							fill="none"
							class={node.isOnActivePath ? "text-gray-400 dark:text-gray-500" : "text-gray-300 dark:text-gray-600"}
						/>
					{/if}
				{/if}
			{/each}


			<!-- Draw nodes -->
			{#each treeData.nodes as node}
				{@const cx = node.x + nodeSize / 2}
				{@const cy = node.y + nodeSize / 2}
				
				{#if node.message.from === MessageRole.User}
					<foreignObject
						x={cx - iconSize / 2}
						y={cy - iconSize / 2}
						width={iconSize}
						height={iconSize}
						role="button"
						tabindex="0"
						class="cursor-pointer hover:opacity-80 transition-opacity"
						onclick={() => onNodeClick(node.message.id)}
						onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
					>
						<div 
							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'}`}
						>
							<CarbonUser class="w-full h-full" />
						</div>
					</foreignObject>
				{:else if node.message.personaResponses && node.message.personaResponses.length > 0}
					{@const personaCount = node.message.personaResponses.length}
					{@const spacing = 8}
					
					{#if personaCount === 1}
						<!-- Single persona: center it like user icons -->
						<foreignObject
							x={cx - iconSize / 2}
							y={cy - iconSize / 2}
							width={iconSize}
							height={iconSize}
							role="button"
							tabindex="0"
							class="cursor-pointer hover:opacity-80 transition-opacity"
							onclick={() => onNodeClick(node.message.id)}
							onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
						>
							<div 
								class="flex items-center justify-center w-full h-full rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
								style="color: {getPersonaColor(node.message.personaResponses[0].personaId)};"
								title={node.message.personaResponses[0].personaName || node.message.personaResponses[0].personaId}
							>
								<CarbonChat class="w-full h-full" />
							</div>
						</foreignObject>
					{:else}
						<!-- Multiple personas: distribute horizontally -->
						{@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
						{@const startX = node.x + iconSize / 2}
						
						{#each node.message.personaResponses as response, i}
							{@const iconX = startX + i * (iconSize + spacing)}
							<foreignObject
								x={iconX - iconSize / 2}
								y={cy - iconSize / 2}
								width={iconSize}
								height={iconSize}
								role="button"
								tabindex="0"
								class="cursor-pointer hover:opacity-80 transition-opacity"
								onclick={() => onNodeClick(node.message.id)}
								onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
							>
								<div 
									class="flex items-center justify-center w-full h-full rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
									style="color: {getPersonaColor(response.personaId)};"
									title={response.personaName || response.personaId}
								>
									<CarbonChat class="w-full h-full" />
								</div>
							</foreignObject>
						{/each}
					{/if}
				{:else}
					<foreignObject
						x={cx - iconSize / 2}
						y={cy - iconSize / 2}
						width={iconSize}
						height={iconSize}
						role="button"
						tabindex="0"
						class="cursor-pointer hover:opacity-80 transition-opacity"
						onclick={() => onNodeClick(node.message.id)}
						onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
					>
						<div 
							class="flex items-center justify-center w-full h-full rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
							style="color: #10b981;"
						>
							<CarbonChat class="w-full h-full" />
						</div>
					</foreignObject>
				{/if}
			{/each}
		</svg>
	</div>
{/if}

<style>
	.conversation-tree {
		animation: fadeIn 0.3s ease-in;
	}

	.conversation-tree path,
	.conversation-tree line,
	.conversation-tree foreignObject {
		animation: fadeIn 0.4s ease-in;
	}

	@keyframes fadeIn {
		from {
			opacity: 0;
		}
		to {
			opacity: 1;
		}
	}
</style>