From a14a53edbacb24051a31e73e2d111307c2f0354e Mon Sep 17 00:00:00 2001
From: blackhao <13851610112@163.com>
Date: Mon, 8 Dec 2025 19:54:58 -0600
Subject: before file functions

---
 frontend/src/components/Sidebar.tsx | 261 +++++++++++++++++++++++-------------
 1 file changed, 170 insertions(+), 91 deletions(-)

(limited to 'frontend/src/components/Sidebar.tsx')

diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 28a40f6..5516629 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -1,8 +1,9 @@
 import React, { useState, useEffect, useRef } from 'react';
+import { useReactFlow } from 'reactflow';
 import useFlowStore from '../store/flowStore';
 import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../store/flowStore';
 import ReactMarkdown from 'react-markdown';
-import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2 } from 'lucide-react';
+import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation } from 'lucide-react';
 
 interface SidebarProps {
   isOpen: boolean;
@@ -16,6 +17,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     isTraceComplete, createQuickChatNode, theme,
     createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages
   } = useFlowStore();
+  const { setCenter } = useReactFlow();
   const isDark = theme === 'dark';
   const [activeTab, setActiveTab] = useState<'interact' | 'settings' | 'debug'>('interact');
   const [streamBuffer, setStreamBuffer] = useState('');
@@ -34,6 +36,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
   // Quick Chat states
   const [quickChatOpen, setQuickChatOpen] = useState(false);
   const [quickChatTrace, setQuickChatTrace] = useState<Trace | null>(null);
+  const [quickChatLastNodeId, setQuickChatLastNodeId] = useState<string | null>(null); // Track the last node in the chat chain
   const [quickChatMessages, setQuickChatMessages] = useState<Message[]>([]);
   const [quickChatInput, setQuickChatInput] = useState('');
   const [quickChatModel, setQuickChatModel] = useState('gpt-5.1');
@@ -431,6 +434,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
       });
       setQuickChatMessages(initialMessages);
       setQuickChatNeedsDuplicate(false);
+      setQuickChatLastNodeId(selectedNode.id);
     } else {
       // Use existing trace context
       const hasDownstream = traceHasDownstream(trace);
@@ -452,6 +456,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
         messages: fullMessages
       });
       setQuickChatMessages(fullMessages);
+      
+      // Set last node ID: if current node has response, start from here.
+      // Otherwise start from trace source (which is the last completed node)
+      setQuickChatLastNodeId(hasResponse ? selectedNode.id : trace.sourceNodeId);
     }
     
     setQuickChatOpen(true);
@@ -536,58 +544,95 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     return true;
   };
 
+  // Helper: Check if specific trace path upstream has complete nodes
+  const checkTracePathComplete = (nodeId: string, traceId: string, visited: Set<string> = new Set()): boolean => {
+    if (visited.has(nodeId)) return true;
+    visited.add(nodeId);
+    
+    // Find the incoming edge that carries this trace
+    const incomingEdge = edges.find(e => 
+      e.target === nodeId && 
+      e.sourceHandle === `trace-${traceId}`
+    );
+    
+    if (!incomingEdge) return true; // Reached head of trace segment
+    
+    const sourceNode = nodes.find(n => n.id === incomingEdge.source);
+    if (!sourceNode || sourceNode.data.disabled) return true;
+    
+    // Check if source node is complete
+    if (!sourceNode.data.userPrompt || !sourceNode.data.response) {
+      return false; // Found incomplete node
+    }
+    
+    // Continue upstream
+    return checkTracePathComplete(sourceNode.id, traceId, visited);
+  };
+
+  // Helper: Find the first empty node on a specific trace path
+  const findEmptyNodeOnTrace = (nodeId: string, traceId: string, visited: Set<string> = new Set()): string | null => {
+    if (visited.has(nodeId)) return null;
+    visited.add(nodeId);
+    
+    const incomingEdge = edges.find(e => 
+      e.target === nodeId && 
+      e.sourceHandle === `trace-${traceId}`
+    );
+    
+    if (!incomingEdge) return null;
+    
+    const sourceNode = nodes.find(n => n.id === incomingEdge.source);
+    if (!sourceNode || sourceNode.data.disabled) return null;
+    
+    // Recursively check upstream first (find the furthest empty node)
+    const upstreamEmpty = findEmptyNodeOnTrace(sourceNode.id, traceId, visited);
+    if (upstreamEmpty) return upstreamEmpty;
+    
+    // If no further upstream empty, check this node
+    if (!sourceNode.data.userPrompt || !sourceNode.data.response) {
+      return sourceNode.id;
+    }
+    
+    return null;
+  };
+
   // Check if all active traces are complete (for main Run Node button)
   const checkActiveTracesComplete = (): { complete: boolean; incompleteTraceId?: string } => {
     if (!selectedNode) return { complete: true };
     
-    // FIRST: Always check if all upstream nodes (via edges) have complete Q&A
-    // This has highest priority - even if no trace is selected
-    if (!checkUpstreamNodesComplete(selectedNode.id)) {
-      return { complete: false, incompleteTraceId: 'upstream' };
-    }
-    
     const activeTraceIds = selectedNode.data.activeTraceIds || [];
     if (activeTraceIds.length === 0) return { complete: true };
     
-    // Check incoming traces - these represent upstream context
-    const incomingTraces = selectedNode.data.traces || [];
+    // Check upstream nodes ONLY for active traces
     for (const traceId of activeTraceIds) {
-      const trace = incomingTraces.find((t: Trace) => t.id === traceId);
-      if (trace && !isTraceComplete(trace)) {
-        return { complete: false, incompleteTraceId: traceId };
+      // Check if it's a merged trace
+      const merged = selectedNode.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId);
+      
+      if (merged) {
+        // For merged trace, check all source traces
+        for (const sourceId of merged.sourceTraceIds) {
+          if (!checkTracePathComplete(selectedNode.id, sourceId)) {
+            return { complete: false, incompleteTraceId: 'upstream' };
+          }
+        }
+      } else {
+        // For regular trace
+        if (!checkTracePathComplete(selectedNode.id, traceId)) {
+          return { complete: false, incompleteTraceId: 'upstream' };
+        }
       }
     }
     
-    // Check outgoing traces (for originated traces)
-    // But for traces that THIS node originated (self trace, forked traces), 
-    // we only need to check if there are incomplete UPSTREAM messages
-    // (not the current node's own messages)
-    const outgoingTraces = selectedNode.data.outgoingTraces || [];
+    // Check incoming traces content (message integrity)
+    const incomingTraces = selectedNode.data.traces || [];
     for (const traceId of activeTraceIds) {
-      const trace = outgoingTraces.find((t: Trace) => t.id === traceId);
-      if (trace) {
-        // Filter out current node's own messages
-        const upstreamMessages = trace.messages.filter(m => !m.id?.startsWith(`${selectedNode.id}-`));
-        
-        // Only check completeness if there are upstream messages
-        // Empty upstream means this is a head node - that's fine
-        if (upstreamMessages.length > 0) {
-          let userCount = 0;
-          let assistantCount = 0;
-          for (const msg of upstreamMessages) {
-            if (msg.role === 'user') userCount++;
-            if (msg.role === 'assistant') assistantCount++;
-          }
-          // Incomplete if unbalanced upstream messages
-          if (userCount !== assistantCount) {
-            return { complete: false, incompleteTraceId: traceId };
-          }
-        }
-        // If no upstream messages, this is a head node - always complete
+      const trace = incomingTraces.find((t: Trace) => t.id === traceId);
+      if (trace && !isTraceComplete(trace)) {
+        return { complete: false, incompleteTraceId: traceId };
       }
     }
     
-    // Check merged traces (all source traces must be complete)
+    // Check merged traces content
     const mergedTraces = selectedNode.data.mergedTraces || [];
     for (const traceId of activeTraceIds) {
       const merged = mergedTraces.find((m: MergedTrace) => m.id === traceId);
@@ -603,6 +648,35 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     
     return { complete: true };
   };
+
+  // Navigate to an empty upstream node on the active traces
+  const navigateToEmptyNode = () => {
+    if (!selectedNode) return;
+    const activeTraceIds = selectedNode.data.activeTraceIds || [];
+    
+    for (const traceId of activeTraceIds) {
+      let emptyNodeId: string | null = null;
+      
+      const merged = selectedNode.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId);
+      if (merged) {
+        for (const sourceId of merged.sourceTraceIds) {
+          emptyNodeId = findEmptyNodeOnTrace(selectedNode.id, sourceId);
+          if (emptyNodeId) break;
+        }
+      } else {
+        emptyNodeId = findEmptyNodeOnTrace(selectedNode.id, traceId);
+      }
+      
+      if (emptyNodeId) {
+        const emptyNode = nodes.find(n => n.id === emptyNodeId);
+        if (emptyNode) {
+          setCenter(emptyNode.position.x + 100, emptyNode.position.y + 50, { zoom: 1.2, duration: 500 });
+          setSelectedNode(emptyNodeId);
+          return; // Found one, navigate and stop
+        }
+      }
+    }
+  };
   
   const activeTracesCheck = selectedNode ? checkActiveTracesComplete() : { complete: true };
 
@@ -681,9 +755,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
       }
 
       // Determine whether to overwrite current node or create new one
-      // Use quickChatTrace.sourceNodeId as the "current" node in the chat flow
-      // This allows continuous chaining: A -> B -> C
-      const fromNodeId = quickChatTrace.sourceNodeId;
+      // Use quickChatLastNodeId as the "current" node in the chat flow to ensure continuity
+      // If not set, fallback to quickChatTrace.sourceNodeId (initial state)
+      const fromNodeId = quickChatLastNodeId || quickChatTrace.sourceNodeId;
       const fromNode = nodes.find(n => n.id === fromNodeId);
       const fromNodeHasResponse = fromNode?.data.response && fromNode.data.response.trim() !== '';
       
@@ -707,6 +781,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
           messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }]
         } : null);
         
+        // Update last node ID
+        setQuickChatLastNodeId(fromNodeId);
+        
         // Generate title
         generateTitle(fromNodeId, userInput, fullResponse);
       } else {
@@ -820,30 +897,27 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                 }
               }
             } else {
-              // This is a continuation - find the evolved trace that ends at fromNodeId
+              // This is a continuation - find the trace ID (should be preserved now)
               // Look for a trace that was created from the original node's self trace
               const matchingTrace = sourceNodeData?.data.outgoingTraces?.find(t => {
-                // The trace should end with fromNodeId and contain the original node
-                return t.id.endsWith(`_${fromNodeId}`) && t.id.includes(originalStartNodeId);
+                return t.id.includes(originalStartNodeId);
               });
               
               if (matchingTrace) {
                 sourceHandle = `trace-${matchingTrace.id}`;
               } else {
                 // Fallback 1: Check INCOMING traces (Connect to Continue Handle)
-                // This is crucial because pass-through traces are not in outgoingTraces until connected
                 const incoming = sourceNodeData?.data.traces?.find(t => 
                   t.id.includes(originalStartNodeId)
                 );
                 
                 if (incoming) {
-                  // Construct evolved ID for continue handle
-                  const evolvedId = `${incoming.id}_${fromNodeId}`;
-                  sourceHandle = `trace-${evolvedId}`;
+                  // ID is preserved, so handle ID is just trace-{id}
+                  sourceHandle = `trace-${incoming.id}`;
                 } else {
-                  // Fallback 2: find any trace that ends with fromNodeId
+                  // Fallback 2: find any trace that ends with fromNodeId (unlikely if ID preserved)
                   const anyMatch = sourceNodeData?.data.outgoingTraces?.find(
-                    t => t.id === `trace-${fromNodeId}` || t.id.endsWith(`_${fromNodeId}`)
+                    t => t.id === `trace-${fromNodeId}`
                   );
                   if (anyMatch) {
                     sourceHandle = `trace-${anyMatch.id}`;
@@ -852,31 +926,27 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
               }
             }
           } else {
-            // For existing trace: find the evolved version of the original trace
+            // For existing trace: ID is preserved
             const baseTraceId = currentTraceId.replace(/^trace-/, '');
             
             // 1. Try OUTGOING traces first (if already connected downstream)
             const matchingOutgoing = sourceNodeData?.data.outgoingTraces?.find(t => {
               const traceBase = t.id.replace(/^trace-/, '');
-              return traceBase.startsWith(baseTraceId) || traceBase === baseTraceId;
+              return traceBase === baseTraceId; // Exact match now
             });
             
             if (matchingOutgoing) {
               sourceHandle = `trace-${matchingOutgoing.id}`;
             } else {
               // 2. Try INCOMING traces (Connect to Continue Handle)
-              // If we are at Node B, and currentTraceId is "trace-A", 
-              // we look for incoming "trace-A" and use its continue handle "trace-trace-A_B"
               const matchingIncoming = sourceNodeData?.data.traces?.find(t => {
                  const tId = t.id.replace(/^trace-/, '');
-                 return tId === baseTraceId || baseTraceId.startsWith(tId);
+                 return tId === baseTraceId; // Exact match now
               });
               
               if (matchingIncoming) {
-                 // Construct the evolved ID: {traceId}_{nodeId}
-                 // Handle ID format in LLMNode is `trace-${evolvedTraceId}`
-                 const evolvedId = `${matchingIncoming.id}_${fromNodeId}`;
-                 sourceHandle = `trace-${evolvedId}`;
+                 // ID is preserved
+                 sourceHandle = `trace-${matchingIncoming.id}`;
               }
             }
           }
@@ -907,46 +977,28 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
           
           if (newNode && newNode.data.outgoingTraces) {
             // Find the trace that continues the current conversation
-            // It should end with the new node ID
+            // Now trace IDs don't evolve, so it should be simpler
             
             if (isMerged) {
+               // Merged traces might still need evolution or logic check
+               // For now assuming linear extension keeps same ID if we changed flowStore
+               // But merged trace logic in flowStore might still append ID?
+               // Let's check if evolved version exists
                const evolved = newNode.data.outgoingTraces.find(t => 
                  t.id === `${currentId}_${newNodeId}`
                );
                if (evolved) nextTraceId = evolved.id;
+               else nextTraceId = currentId; // Try keeping same ID
             } else if (isCurrentNewTrace) {
-               // For new trace, we look for the trace that originated from the start node
-               // and now passes through the new node
+               // For new trace, check if we have an outgoing trace with the start node ID
                const startNodeId = currentId.replace('new-trace-', '');
                const match = newNode.data.outgoingTraces.find(t => 
-                 t.id.includes(startNodeId) && t.id.endsWith(`_${newNodeId}`)
+                 t.id.includes(startNodeId)
                );
                if (match) nextTraceId = match.id;
-               // If first step (A->B), might be a direct fork ID
-               else {
-                 const directFork = newNode.data.outgoingTraces.find(t => 
-                   t.id.includes(startNodeId) && t.sourceNodeId === startNodeId
-                 );
-                 if (directFork) nextTraceId = directFork.id;
-               }
             } else {
-               // Regular trace: look for evolved version
-               const baseId = currentId.replace(/^trace-/, '');
-               
-               // 1. Try outgoing traces
-               const match = newNode.data.outgoingTraces.find(t => 
-                 t.id.includes(baseId) && t.id.endsWith(`_${newNodeId}`)
-               );
-               if (match) {
-                 nextTraceId = match.id;
-               } else {
-                 // 2. If not in outgoing (no downstream yet), construct evolved ID manually
-                 // Check if it's an incoming trace that evolved
-                 const incoming = newNode.data.traces?.find(t => t.id.includes(baseId));
-                 if (incoming) {
-                   nextTraceId = `${incoming.id}_${newNodeId}`;
-                 }
-               }
+               // Regular trace: ID should be preserved
+               nextTraceId = currentId;
             }
           }
           
@@ -957,6 +1009,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
             messages: [...messagesBeforeSend, userMessage, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }]
           } : null);
           
+          // Update last node ID to the new node
+          setQuickChatLastNodeId(newNodeId);
+          
           // Generate title
           generateTitle(newNodeId, userInput, fullResponse);
         }, 100);
@@ -1237,6 +1292,20 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   {selectedNode.data.mergedTraces.map((merged: MergedTrace) => {
                     const isActive = selectedNode.data.activeTraceIds?.includes(merged.id);
                     
+                    // Check if merged trace is complete
+                    const isComplete = merged.sourceTraceIds.every(sourceId => {
+                      // Check trace path completeness (upstream empty nodes)
+                      const pathComplete = checkTracePathComplete(selectedNode.id, sourceId);
+                      if (!pathComplete) return false;
+                      
+                      // Check message integrity
+                      const incomingTraces = selectedNode.data.traces || [];
+                      const sourceTrace = incomingTraces.find(t => t.id === sourceId);
+                      if (sourceTrace && !isTraceComplete(sourceTrace)) return false;
+                      
+                      return true;
+                    });
+                    
                     return (
                       <div 
                         key={merged.id}
@@ -1273,8 +1342,11 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                         </div>
                         
                         <div className="flex-1 min-w-0">
-                          <div className={`font-mono truncate ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
-                            Merged #{merged.id.slice(-6)}
+                          <div className={`flex items-center gap-1 ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
+                            <span className="font-mono truncate">Merged #{merged.id.slice(-6)}</span>
+                            {!isComplete && (
+                              <span className="text-[9px] text-orange-500 font-sans">(incomplete)</span>
+                            )}
                           </div>
                           <div className={`truncate ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
                             {merged.strategy} • {merged.messages.length} msgs
@@ -1338,8 +1410,15 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
               <div className={`mb-2 p-2 rounded-md text-xs flex items-center gap-2 ${
                 isDark ? 'bg-yellow-900/50 text-yellow-300 border border-yellow-700' : 'bg-yellow-50 text-yellow-700 border border-yellow-200'
               }`}>
-                <AlertCircle size={14} />
-                <span>Upstream node is empty. Complete the context chain before running.</span>
+                <AlertCircle size={14} className="flex-shrink-0" />
+                <span className="flex-1">Upstream node is empty. Complete the context chain before running.</span>
+                <button
+                  onClick={navigateToEmptyNode}
+                  className={`flex-shrink-0 p-1 rounded hover:bg-yellow-600/30 transition-colors`}
+                  title="Go to empty node"
+                >
+                  <Navigation size={14} />
+                </button>
               </div>
             )}
 
-- 
cgit v1.2.3

