diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/src/components/Sidebar.tsx | 261 | ||||
| -rw-r--r-- | frontend/src/components/nodes/LLMNode.tsx | 17 | ||||
| -rw-r--r-- | frontend/src/store/flowStore.ts | 48 |
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] |
