File size: 11,559 Bytes
e9dc149
 
 
 
 
 
cb5990d
e9dc149
 
 
cb5990d
e9dc149
 
 
cb5990d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e9dc149
 
 
cb5990d
 
 
 
e9dc149
cb5990d
 
 
 
 
e9dc149
 
 
 
 
 
 
 
 
 
 
 
 
 
cb5990d
e9dc149
 
 
cb5990d
 
e9dc149
cb5990d
e9dc149
cb5990d
 
 
 
e9dc149
 
 
 
 
cb5990d
 
 
e9dc149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb5990d
e9dc149
 
 
 
 
 
 
 
 
 
cb5990d
 
 
 
 
 
 
 
 
 
 
 
 
 
e9dc149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb5990d
e9dc149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb5990d
e9dc149
 
 
 
 
 
 
 
cb5990d
e9dc149
 
 
cb5990d
 
e9dc149
 
cb5990d
 
e9dc149
 
 
 
 
 
 
 
cb5990d
 
e9dc149
 
 
 
 
 
 
 
cb5990d
e9dc149
 
 
cb5990d
 
 
 
 
 
 
e9dc149
cb5990d
 
 
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
<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";
	import { TREE_CONFIG } from "$lib/constants/treeConfig";

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

	let { treeData, onNodeClick }: Props = $props();
	
	let containerElement: HTMLDivElement | undefined = $state();
	let previousNodeCount = $state(0);
	let previousActiveNodeId = $state<string | undefined>(undefined);
	let userHasScrolled = $state(false);
	let scrollResetTimeout: ReturnType<typeof setTimeout> | undefined;
	
	// Auto-scroll to keep active or latest nodes in view
	$effect(() => {
		if (!containerElement || treeData.nodes.length === 0) return;
		
		const activeNode = treeData.nodes.find(n => n.isActive);
		const activeNodeId = activeNode?.id;
		
		// Determine if we should auto-scroll
		const shouldAutoScroll = 
			// New nodes were added
			(treeData.nodes.length > previousNodeCount) ||
			// Active node changed and user hasn't manually scrolled recently
			(activeNodeId !== previousActiveNodeId && !userHasScrolled);
		
		if (shouldAutoScroll) {
			// Find the target node (active node or the last node)
			const targetNode = activeNode || treeData.nodes[treeData.nodes.length - 1];
			
			if (targetNode) {
				// Small delay to ensure DOM is updated
				requestAnimationFrame(() => {
					if (!containerElement) return;
					
					// Calculate the center Y position of the target node
					const nodeY = targetNode.y + (targetNode.height / 2);
					const containerHeight = containerElement.clientHeight;
					
					// Scroll to center the node vertically in the view
					const scrollTop = nodeY - (containerHeight / 2);
					
					// Use smooth scrolling
					containerElement.scrollTo({
						top: Math.max(0, scrollTop),
						behavior: 'smooth'
					});
				});
			}
			
			// Reset user scroll flag after auto-scroll
			userHasScrolled = false;
		}
		
		previousNodeCount = treeData.nodes.length;
		previousActiveNodeId = activeNodeId;
	});
	
	// Track manual scrolling by user
	function handleScroll() {
		userHasScrolled = true;
		
		// Reset the flag after a delay so auto-scroll can resume
		clearTimeout(scrollResetTimeout);
		scrollResetTimeout = setTimeout(() => {
			userHasScrolled = false;
		}, 2000); // 2 second delay
	}
</script>

{#if treeData.nodes.length > 0}
	{@const nodeSize = TREE_CONFIG.nodeSize}
	{@const iconSize = TREE_CONFIG.iconSize}
	{@const svgWidth = Math.max(treeData.width, TREE_CONFIG.minWidth)}
	{@const svgHeight = Math.max(treeData.height, TREE_CONFIG.minHeight)}
	
	<div 
		bind:this={containerElement}
		onscroll={handleScroll}
		class="mt-2 mb-3 flex justify-center conversation-tree max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent hover:scrollbar-thumb-gray-400 dark:hover:scrollbar-thumb-gray-500"
	>
		<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 = TREE_CONFIG.spacing}
						{@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
						
						<!-- Use ELK coordinates directly -->
						{@const startOffset = (node.width - totalWidth) / 2}
						{@const leftmostX = node.x + startOffset + iconSize / 2}
						{@const rightmostX = leftmostX + (personaCount - 1) * (iconSize + spacing)}
						{@const childCenterX = node.x + node.width / 2}

						{@const parentBottom = node.parentY + nodeSize}
						{@const gap = node.y - parentBottom}
						{@const junctionY = parentBottom + gap * 0.5}
						
						{@const parentWidth = parentNode?.width || nodeSize}
						{@const parentCenterX = node.parentX + parentWidth / 2}
						
						<!-- Curve from Parent to Child Center -->
						<path
							d="M {parentCenterX},{parentBottom} 
							   C {parentCenterX},{parentBottom + gap * 0.25} 
							     {childCenterX},{junctionY - gap * 0.25}
							     {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} 
								   L {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 spacing = TREE_CONFIG.spacing}
						{@const parentIsMultiPersona = parentNode?.message.from === MessageRole.Assistant && parentNode?.message.personaResponses && parentNode.message.personaResponses.length > 1}
						{@const branchedFromPersonaId = node.message.branchedFrom?.personaId}
						{@const targetPersonaIndex = (parentIsMultiPersona && branchedFromPersonaId && parentNode?.message.personaResponses) 
							? parentNode.message.personaResponses.findIndex(p => p.personaId === branchedFromPersonaId)
							: -1}

						{@const parentPersonaCount = parentNode?.message.personaResponses?.length || 0}
						{@const parentTotalWidth = (parentPersonaCount > 0) ? parentPersonaCount * iconSize + (parentPersonaCount - 1) * spacing : 0}
						{@const parentStartOffset = (parentWidth - parentTotalWidth) / 2}

						{@const x1 = (targetPersonaIndex !== -1)
							? (node.parentX + parentStartOffset + iconSize / 2) + targetPersonaIndex * (iconSize + spacing)
							: 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 + node.width / 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 = TREE_CONFIG.spacing}
					
					{#if personaCount === 1}
						<!-- Single persona: center it like user icons -->
						<foreignObject
							x={cx - iconSize / 2}
							y={cy - iconSize / 2}
							width={iconSize}
							height={iconSize}
							style="overflow: visible"
							role="button"
							tabindex="0"
							class="cursor-pointer hover:opacity-80 transition-opacity"
							onclick={() => onNodeClick(node.message.id, node.message.personaResponses![0].personaId)}
							onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id, node.message.personaResponses![0].personaId)}
						>
							<div 
								class="flex items-center justify-center rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
								style="width: {iconSize}px; height: {iconSize}px; 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 startOffset = (node.width - totalWidth) / 2}
						{@const startX = node.x + startOffset + 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}
								style="overflow: visible"
								role="button"
								tabindex="0"
								class="cursor-pointer hover:opacity-80 transition-opacity"
								onclick={() => onNodeClick(node.message.id, response.personaId)}
							onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id, response.personaId)}
						>
							<div 
								class="flex items-center justify-center rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
								style="width: {iconSize}px; height: {iconSize}px; 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}