From b6f21c210ee804782eba2e7c30c2ccdcbd95bffb Mon Sep 17 00:00:00 2001
From: YurenHao0426 <blackhao0426@gmail.com>
Date: Fri, 13 Feb 2026 05:07:46 +0000
Subject: Add unfold merged trace: convert to sequential node chain
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Unfold takes a merged trace's messages, extracts the node order,
and creates real edges chaining those nodes sequentially (A→B→C→D→E).
The merged trace is deleted and replaced by a regular pass-through trace.

- Add unfoldMergedTrace() to flowStore (creates edges, rewires downstream)
- Add Unfold button (Layers icon) to Sidebar merged traces UI
- Fix isMerged edge detection to use explicit flag instead of ID prefix
- Fix LLMNode useUpdateNodeInternals deps for dynamic handle updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 frontend/src/components/nodes/LLMNode.tsx | 77 +++++++++++++++++++++----------
 1 file changed, 53 insertions(+), 24 deletions(-)

(limited to 'frontend/src/components/nodes/LLMNode.tsx')

diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx
index 8cbf0e9..7542860 100644
--- a/frontend/src/components/nodes/LLMNode.tsx
+++ b/frontend/src/components/nodes/LLMNode.tsx
@@ -10,10 +10,10 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
   const updateNodeInternals = useUpdateNodeInternals();
   const edges = useEdges();
 
-  // Force update handles when traces change
+  // Force update handles when traces or edges change
   useEffect(() => {
     updateNodeInternals(id);
-  }, [id, data.outgoingTraces, data.mergedTraces, data.inputs, updateNodeInternals]);
+  }, [id, data.traces, data.outgoingTraces, data.mergedTraces, data.forkedTraces, data.inputs, edges.length, updateNodeInternals]);
 
   // Determine how many input handles to show
   // We want to ensure there is always at least one empty handle at the bottom
@@ -53,6 +53,35 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
   const isDisabled = data.disabled;
   const isDark = theme === 'dark';
 
+  // Calculate handle counts to determine minimum node height
+  // Each handle slot is 16px (h-3=12px + my-0.5*2=4px)
+  const HANDLE_SLOT = 16;
+  const NODE_PADDING = 16; // py-2 top + bottom
+
+  // Left side: regular inputs + prepend handles
+  const prependCount = (data.outgoingTraces || []).filter(trace => {
+    const isSelfTrace = trace.id === `trace-${id}`;
+    const isForkTrace = trace.id.startsWith('fork-') && trace.sourceNodeId === id;
+    if (!isSelfTrace && !isForkTrace) return false;
+    return edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`);
+  }).length;
+  const leftHandleCount = inputsToShow + prependCount;
+
+  // Right side: continue + outgoing + merged + new branch
+  const continueCount = (data.traces || []).filter((trace: any) => {
+    return !edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`);
+  }).length;
+  const outgoingCount = (data.outgoingTraces || []).filter(trace => {
+    const isLocallyMerged = data.mergedTraces?.some(m => m.id === trace.id);
+    if (isLocallyMerged) return false;
+    return edges.some(e => e.source === id && e.sourceHandle === `trace-${trace.id}`);
+  }).length;
+  const mergedCount = (data.mergedTraces || []).length;
+  const rightHandleCount = continueCount + outgoingCount + mergedCount + 1; // +1 for "new branch"
+
+  const maxHandles = Math.max(leftHandleCount, rightHandleCount);
+  const minHandleHeight = maxHandles * HANDLE_SLOT + NODE_PADDING;
+
   // Truncate preview content
   const previewContent = data.response 
     ? data.response.slice(0, 200) + (data.response.length > 200 ? '...' : '')
@@ -61,13 +90,13 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
       : null;
 
   return (
-    <div 
+    <div
       className={`px-4 py-2 shadow-md rounded-md border-2 min-w-[200px] transition-all relative ${
-        isDisabled 
-          ? isDark 
-            ? 'bg-gray-800 border-gray-600 opacity-50 cursor-not-allowed' 
+        isDisabled
+          ? isDark
+            ? 'bg-gray-800 border-gray-600 opacity-50 cursor-not-allowed'
             : 'bg-gray-100 border-gray-300 opacity-50 cursor-not-allowed'
-          : selected 
+          : selected
             ? isDark
               ? 'bg-gray-800 border-blue-400'
               : 'bg-white border-blue-500'
@@ -75,7 +104,7 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
               ? 'bg-gray-800 border-gray-600'
               : 'bg-white border-gray-200'
       }`}
-      style={{ pointerEvents: isDisabled ? 'none' : 'auto' }}
+      style={{ pointerEvents: isDisabled ? 'none' : 'auto', minHeight: minHandleHeight }}
       onMouseEnter={() => setShowPreview(true)}
       onMouseLeave={() => setShowPreview(false)}
     >
@@ -145,16 +174,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
              const gradientStops = mergedColors.map((color, idx) => 
                `${color} ${(idx / mergedColors.length) * 100}%, ${color} ${((idx + 1) / mergedColors.length) * 100}%`
              ).join(', ');
-             handleBackground = `linear-gradient(45deg, ${gradientStops})`;
+             handleBackground = `conic-gradient(from 0deg, ${gradientStops})`;
            }
            
            return (
-             <div key={i} className="relative h-4 w-4 my-1">
+             <div key={i} className="relative h-3 w-3 my-0.5">
                <Handle 
                   type="target" 
                   position={Position.Left} 
                   id={`input-${i}`}
-                  className="!w-3 !h-3 !left-[-6px] !border-0"
+                  className="!w-2.5 !h-2.5 !left-[-6px] !border-0"
                   style={{ 
                     top: '50%', 
                     transform: 'translateY(-50%)',
@@ -196,12 +225,12 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
              );
              
              return (
-               <div key={`prepend-${trace.id}`} className="relative h-4 w-4 my-1" title={`Prepend context to: ${trace.id}`}>
+               <div key={`prepend-${trace.id}`} className="relative h-3 w-3 my-0.5" title={`Prepend context to: ${trace.id}`}>
                  <Handle 
                     type="target" 
                     position={Position.Left} 
                     id={`prepend-${trace.id}`}
-                    className="!w-3 !h-3 !left-[-6px] !border-2"
+                    className="!w-2.5 !h-2.5 !left-[-6px] !border-2"
                     style={{ 
                       top: '50%', 
                       transform: 'translateY(-50%)',
@@ -244,16 +273,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
                 const gradientStops = colors.map((color: string, idx: number) => 
                   `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
                 ).join(', ');
-                backgroundStyle = `linear-gradient(45deg, ${gradientStops})`;
+                backgroundStyle = `conic-gradient(from 0deg, ${gradientStops})`;
               }
 
               return (
-                <div key={`continue-${trace.id}`} className="relative h-4 w-4 my-1" title={`Continue trace: ${trace.id}`}>
+                <div key={`continue-${trace.id}`} className="relative h-3 w-3 my-0.5" title={`Continue trace: ${trace.id}`}>
                   <Handle 
                      type="source" 
                      position={Position.Right} 
                      id={`trace-${trace.id}`}
-                     className="!w-3 !h-3 !right-[-6px]"
+                     className="!w-2.5 !h-2.5 !right-[-6px]"
                      style={{ 
                        background: backgroundStyle,
                        top: '50%', 
@@ -287,16 +316,16 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
                const gradientStops = colors.map((color, idx) => 
                  `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
                ).join(', ');
-               backgroundStyle = `linear-gradient(45deg, ${gradientStops})`;
+               backgroundStyle = `conic-gradient(from 0deg, ${gradientStops})`;
              }
 
              return (
-             <div key={trace.id} className="relative h-4 w-4 my-1" title={`Trace: ${trace.id}`}>
+             <div key={trace.id} className="relative h-3 w-3 my-0.5" title={`Trace: ${trace.id}`}>
                 <Handle 
                    type="source" 
                    position={Position.Right} 
                    id={`trace-${trace.id}`}
-                   className="!w-3 !h-3 !right-[-6px]"
+                   className="!w-2.5 !h-2.5 !right-[-6px]"
                    style={{ 
                      background: backgroundStyle,
                      top: '50%', 
@@ -319,15 +348,15 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
             const gradientStops = colors.map((color, idx) => 
               `${color} ${(idx / colors.length) * 100}%, ${color} ${((idx + 1) / colors.length) * 100}%`
             ).join(', ');
-            const stripeGradient = `linear-gradient(45deg, ${gradientStops})`;
+            const stripeGradient = `conic-gradient(from 0deg, ${gradientStops})`;
             
             return (
-              <div key={merged.id} className="relative h-4 w-4 my-1" title={`Merged: ${merged.strategy} (${merged.sourceTraceIds.length} traces)`}>
+              <div key={merged.id} className="relative h-3 w-3 my-0.5" title={`Merged: ${merged.strategy} (${merged.sourceTraceIds.length} traces)`}>
                 <Handle 
                    type="source" 
                    position={Position.Right} 
                    id={`trace-${merged.id}`}
-                   className="!w-3 !h-3 !right-[-6px]"
+                   className="!w-2.5 !h-2.5 !right-[-6px]"
                    style={{ 
                      background: stripeGradient,
                      top: '50%', 
@@ -340,12 +369,12 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
           })}
           
           {/* 3. New Branch Generator Handle (Always visible) */}
-          <div className="relative h-4 w-4 my-1" title="Create New Branch">
+          <div className="relative h-3 w-3 my-0.5" title="Create New Branch">
              <Handle 
                 type="source" 
                 position={Position.Right} 
                 id="new-trace"
-                className="!w-3 !h-3 !bg-gray-400 !right-[-6px]"
+                className="!w-2.5 !h-2.5 !bg-gray-400 !right-[-6px]"
                 style={{ top: '50%', transform: 'translateY(-50%)' }}
              />
              <span className={`absolute right-4 top-[-2px] text-[9px] pointer-events-none w-max ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
-- 
cgit v1.2.3

