summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/components/Sidebar.tsx261
-rw-r--r--frontend/src/components/nodes/LLMNode.tsx17
-rw-r--r--frontend/src/store/flowStore.ts48
3 files changed, 209 insertions, 117 deletions
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>
)}
diff --git a/frontend/src/components/nodes/LLMNode.tsx b/frontend/src/components/nodes/LLMNode.tsx
index dce1f2e..8105070 100644
--- a/frontend/src/components/nodes/LLMNode.tsx
+++ b/frontend/src/components/nodes/LLMNode.tsx
@@ -263,22 +263,21 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
{data.traces && data.traces
.filter((trace: Trace) => {
// Only show continue handle if NOT already connected downstream
- const evolvedTraceId = `${trace.id}_${id}`;
+ // Now that trace IDs don't evolve, we check the base ID
const hasOutgoingEdge = edges.some(e =>
e.source === id &&
- (e.sourceHandle === `trace-${trace.id}` || e.sourceHandle === `trace-${evolvedTraceId}`)
+ e.sourceHandle === `trace-${trace.id}`
);
// Only show dashed handle if not yet connected
return !hasOutgoingEdge;
})
.map((trace: Trace) => {
- const evolvedTraceId = `${trace.id}_${id}`;
return (
<div key={`continue-${trace.id}`} className="relative h-4 w-4 my-1" title={`Continue trace: ${trace.id}`}>
<Handle
type="source"
position={Position.Right}
- id={`trace-${evolvedTraceId}`}
+ id={`trace-${trace.id}`}
className="!w-3 !h-3 !right-[-6px]"
style={{
backgroundColor: trace.color,
@@ -322,6 +321,11 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
{/* 2. Merged Trace Handles (with alternating color stripes) */}
{data.mergedTraces && data.mergedTraces.map((merged: MergedTrace) => {
+ // Check if this merged trace has any outgoing edges
+ const hasOutgoingEdge = edges.some(e =>
+ e.source === id && e.sourceHandle === `trace-${merged.id}`
+ );
+
// Create a gradient background from the source trace colors
const colors = merged.colors.length > 0 ? merged.colors : ['#888'];
const gradientStops = colors.map((color, idx) =>
@@ -335,11 +339,12 @@ const LLMNode = ({ id, data, selected }: NodeProps<NodeData>) => {
type="source"
position={Position.Right}
id={`trace-${merged.id}`}
- className="!w-3 !h-3 !right-[-6px] !border-0"
+ className="!w-3 !h-3 !right-[-6px]"
style={{
background: stripeGradient,
top: '50%',
- transform: 'translateY(-50%)'
+ transform: 'translateY(-50%)',
+ border: hasOutgoingEdge ? 'none' : `2px dashed ${isDark ? '#374151' : '#fff'}`
}}
/>
</div>
diff --git a/frontend/src/store/flowStore.ts b/frontend/src/store/flowStore.ts
index 636113d..49a8ece 100644
--- a/frontend/src/store/flowStore.ts
+++ b/frontend/src/store/flowStore.ts
@@ -188,19 +188,24 @@ const useFlowStore = create<FlowState>((set, get) => ({
findNonOverlappingPosition: (baseX: number, baseY: number) => {
const { nodes } = get();
- const nodeWidth = 220;
- const nodeHeight = 80;
- const padding = 10;
+ // Estimate larger dimensions to be safe, considering dynamic handles
+ const nodeWidth = 300;
+ const nodeHeight = 200;
+ const padding = 20;
let x = baseX;
let y = baseY;
let attempts = 0;
- const maxAttempts = 30;
+ const maxAttempts = 100; // Increase attempts
const isOverlapping = (testX: number, testY: number) => {
return nodes.some(node => {
+ // Use the same estimated dimensions for existing nodes too
+ // Ideally we would know their actual dimensions, but this is a safe approximation
const nodeX = node.position.x;
const nodeY = node.position.y;
+
+ // Check for overlap
return !(testX + nodeWidth + padding < nodeX ||
testX > nodeX + nodeWidth + padding ||
testY + nodeHeight + padding < nodeY ||
@@ -208,11 +213,13 @@ const useFlowStore = create<FlowState>((set, get) => ({
});
};
- // Try positions in a tighter spiral pattern
+ // Try positions in a spiral pattern
while (isOverlapping(x, y) && attempts < maxAttempts) {
attempts++;
- const angle = attempts * 0.7;
- const radius = 30 + attempts * 15;
+ // Spiral parameters
+ const angle = attempts * 0.5; // Slower rotation
+ const radius = 50 + attempts * 30; // Faster expansion
+
x = baseX + Math.cos(angle) * radius;
y = baseY + Math.sin(angle) * radius;
}
@@ -1082,12 +1089,15 @@ const useFlowStore = create<FlowState>((set, get) => ({
}
// Create new node to the right
- const newNodeId = `node_${Date.now()}`;
- const newPos = {
- x: fromNode.position.x + 300,
- y: fromNode.position.y
- };
+ // Use findNonOverlappingPosition to avoid collision, starting from the ideal position
+ const idealX = fromNode.position.x + 300;
+ const idealY = fromNode.position.y;
+
+ // Check if ideal position overlaps
+ const { findNonOverlappingPosition } = get();
+ const newPos = findNonOverlappingPosition(idealX, idealY);
+ const newNodeId = `node_${Date.now()}`;
const newNode: LLMNode = {
id: newNodeId,
type: 'llmNode',
@@ -1615,24 +1625,22 @@ const useFlowStore = create<FlowState>((set, get) => ({
// A. Pass-through traces (append history) - only if there's a downstream edge
uniqueIncoming.forEach(t => {
- // When a trace passes through a node and gets modified, it effectively becomes a NEW branch of that trace.
- // We must append the current node ID to the trace ID to distinguish branches.
- // e.g. Trace "root" -> passes Node A -> becomes "root_A"
- // If it passes Node B -> becomes "root_B"
- // Downstream Node D can then distinguish "root_A" from "root_B".
+ // SIMPLIFICATION: Keep the same Trace ID for pass-through traces.
+ // This ensures A-B-C appears as a single continuous trace with the same ID.
+ // Only branch/fork traces get new IDs.
- const newTraceId = `${t.id}_${node.id}`;
+ const passThroughId = t.id;
// Only create pass-through if there's actually a downstream edge using it
const hasDownstreamEdge = updatedEdges.some(e =>
e.source === node.id &&
- (e.sourceHandle === `trace-${newTraceId}` || e.sourceHandle === `trace-${t.id}`)
+ (e.sourceHandle === `trace-${passThroughId}`)
);
if (hasDownstreamEdge) {
myOutgoingTraces.push({
...t,
- id: newTraceId,
+ id: passThroughId,
// Keep original sourceNodeId - this is a pass-through, not originated here
sourceNodeId: t.sourceNodeId,
messages: [...t.messages, ...myResponseMsg]