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/Sidebar.tsx | 98 ++++++++++++++++++++++++++++++++-----
 1 file changed, 87 insertions(+), 11 deletions(-)

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

diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 78d2475..65d5cd2 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -6,7 +6,7 @@ import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../st
 import type { Edge } from 'reactflow';
 import ReactMarkdown from 'react-markdown';
 import remarkGfm from 'remark-gfm';
-import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link } from 'lucide-react';
+import { Play, Settings, Info, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link, Layers } from 'lucide-react';
 
 interface SidebarProps {
   isOpen: boolean;
@@ -18,7 +18,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
   const { 
     nodes, edges, selectedNodeId, updateNodeData, getActiveContext, addNode, setSelectedNode, 
     isTraceComplete, theme,
-    createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages,
+    createMergedTrace, updateMergedTrace, deleteMergedTrace, unfoldMergedTrace, computeMergedMessages,
     files, uploadFile, refreshFiles, addFileScope, removeFileScope, currentBlueprintPath,
     saveCurrentBlueprint
   } = useFlowStore();
@@ -183,6 +183,39 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     }
   };
 
+  // Image helpers
+  const isImageFile = (mime: string) => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(mime);
+  const getImageUrl = (fileId: string) => `${import.meta.env.VITE_BACKEND_URL || ''}/api/files/download?user=${encodeURIComponent(user?.username || 'test')}&file_id=${encodeURIComponent(fileId)}`;
+
+  // Paste handler: upload pasted image and attach it
+  const handlePasteImage = async (
+    e: React.ClipboardEvent<HTMLTextAreaElement>,
+    addFile: (fileId: string) => void,
+    scopeFn?: () => string,
+  ) => {
+    const items = e.clipboardData?.items;
+    if (!items) return;
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i];
+      if (item.type.startsWith('image/')) {
+        e.preventDefault();
+        const blob = item.getAsFile();
+        if (!blob) continue;
+        const file = new File([blob], `paste-${Date.now()}.${blob.type.split('/')[1] || 'png'}`, { type: blob.type });
+        try {
+          const meta = await uploadFile(file, { provider: 'local' });
+          addFile(meta.id);
+          if (scopeFn) {
+            try { await addFileScope(meta.id, scopeFn()); } catch {}
+          }
+        } catch (err) {
+          console.error('Paste upload failed:', err);
+        }
+        return; // only handle first image
+      }
+    }
+  };
+
   // Filter files for attach modal
   const filteredFilesToAttach = useMemo(() => {
     const q = attachSearch.trim().toLowerCase();
@@ -1036,6 +1069,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
         })
       });
 
+      if (!response.ok) {
+        const errText = await response.text();
+        throw new Error(errText || `HTTP ${response.status}`);
+      }
       if (!response.body) throw new Error('No response body');
 
       const reader = response.body.getReader();
@@ -1671,7 +1708,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                         {/* Alternating color indicator */}
                         <div className="flex -space-x-1 shrink-0">
                           {merged.colors.slice(0, 3).map((color, idx) => (
-                            <div 
+                            <div
                               key={idx}
                               className="w-3 h-3 rounded-full border-2"
                               style={{ backgroundColor: color, borderColor: isDark ? '#1f2937' : '#fff' }}
@@ -1685,7 +1722,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                             </div>
                           )}
                         </div>
-                        
+
                         <div className="flex-1 min-w-0">
                           <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>
@@ -1715,6 +1752,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                           <MessageCircle size={12} />
                         </button>
                         
+                        <button
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            unfoldMergedTrace(selectedNode.id, merged.id);
+                          }}
+                          className={`p-1 rounded shrink-0 ${
+                            isDark ? 'hover:bg-blue-900 text-gray-500 hover:text-blue-400' : 'hover:bg-blue-50 text-gray-400 hover:text-blue-600'
+                          }`}
+                          title="Unfold to regular trace"
+                        >
+                          <Layers size={12} />
+                        </button>
+
                         <button
                           onClick={(e) => {
                             e.stopPropagation();
@@ -1736,9 +1786,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
 
             <div>
               <label className="block text-sm font-medium text-gray-700 mb-1">User Prompt</label>
-              <textarea 
+              <textarea
                 value={selectedNode.data.userPrompt}
                 onChange={(e) => handleChange('userPrompt', e.target.value)}
+                onPaste={(e) => handlePasteImage(
+                  e,
+                  (id) => {
+                    const current = selectedNode.data.attachedFileIds || [];
+                    if (!current.includes(id)) {
+                      updateNodeData(selectedNode.id, { attachedFileIds: [...current, id] });
+                    }
+                  },
+                  () => `${currentBlueprintPath || 'untitled'}/${selectedNode.id}`,
+                )}
                 onKeyDown={(e) => {
                   if (e.key === 'Enter' && !e.shiftKey) {
                     e.preventDefault();
@@ -1924,15 +1984,20 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   {(selectedNode.data.attachedFileIds || []).map(id => {
                     const file = files.find(f => f.id === id);
                     if (!file) return null;
+                    const isImg = isImageFile(file.mime);
                     return (
-                      <div 
-                        key={id} 
+                      <div
+                        key={id}
                         className={`group flex items-center justify-between p-2 rounded text-xs ${
                           isDark ? 'bg-gray-700/50' : 'bg-white border border-gray-200'
                         }`}
                       >
                         <div className="flex items-center gap-2 overflow-hidden">
-                          <FileText size={14} className={isDark ? 'text-blue-400' : 'text-blue-500'} />
+                          {isImg ? (
+                            <img src={getImageUrl(id)} alt={file.name} className="w-8 h-8 object-cover rounded" />
+                          ) : (
+                            <FileText size={14} className={isDark ? 'text-blue-400' : 'text-blue-500'} />
+                          )}
                           <span className={`truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}>
                             {file.name}
                           </span>
@@ -2497,7 +2562,12 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                             <div className="flex flex-wrap gap-2">
                               {sentFilesForMsg.map(fileId => {
                                 const file = files.find(f => f.id === fileId);
-                                return (
+                                const isImg = file && isImageFile(file.mime);
+                                return isImg ? (
+                                  <a key={fileId} href={getImageUrl(fileId)} target="_blank" rel="noopener noreferrer" title={file?.name}>
+                                    <img src={getImageUrl(fileId)} alt={file?.name || 'Image'} className="max-w-[200px] max-h-[150px] rounded object-cover" />
+                                  </a>
+                                ) : (
                                   <div key={fileId} className="flex items-center gap-1 bg-blue-600 rounded px-2 py-1 text-xs">
                                     <FileText size={12} />
                                     <span className="max-w-[120px] truncate">{file?.name || 'File'}</span>
@@ -2671,14 +2741,19 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                     {quickChatAttachedFiles.map(fileId => {
                       const file = files.find(f => f.id === fileId);
                       if (!file) return null;
+                      const isImg = isImageFile(file.mime);
                       return (
-                        <div 
+                        <div
                           key={fileId}
                           className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
                             isDark ? 'bg-gray-600 text-gray-200' : 'bg-white text-gray-700 border border-gray-300'
                           }`}
                         >
-                          <FileText size={12} />
+                          {isImg ? (
+                            <img src={getImageUrl(fileId)} alt={file.name} className="w-8 h-8 object-cover rounded" />
+                          ) : (
+                            <FileText size={12} />
+                          )}
                           <span className="max-w-[120px] truncate">{file.name}</span>
                           <button
                             onClick={() => handleQuickChatDetach(fileId)}
@@ -2697,6 +2772,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   ref={quickChatInputRef}
                   value={quickChatInput}
                   onChange={(e) => setQuickChatInput(e.target.value)}
+                  onPaste={(e) => handlePasteImage(e, (id) => setQuickChatAttachedFiles(prev => [...prev, id]), getQuickChatScope)}
                   onKeyDown={(e) => {
                     if (e.key === 'Enter' && !e.shiftKey) {
                       e.preventDefault();
-- 
cgit v1.2.3

