From d9b17431a799a0354103ef390f6db15f34fb92be Mon Sep 17 00:00:00 2001
From: blackhao <13851610112@163.com>
Date: Wed, 10 Dec 2025 19:30:26 -0600
Subject: init file sys

---
 frontend/src/components/Sidebar.tsx | 689 +++++++++++++++++++++++++++++++++---
 1 file changed, 646 insertions(+), 43 deletions(-)

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

diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 3008ba3..a8dd82e 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -1,9 +1,9 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useMemo } from 'react';
 import { useReactFlow } from 'reactflow';
 import useFlowStore from '../store/flowStore';
-import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../store/flowStore';
+import type { NodeData, Trace, Message, MergedTrace, MergeStrategy, FileMeta } 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, Navigation } from 'lucide-react';
+import { Play, Settings, Info, Save, ChevronLeft, ChevronRight, Maximize2, Edit3, X, Check, FileText, MessageCircle, Send, GripVertical, GitMerge, Trash2, AlertCircle, Loader2, Navigation, Upload, Search, Link } from 'lucide-react';
 
 interface SidebarProps {
   isOpen: boolean;
@@ -15,14 +15,21 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
   const { 
     nodes, edges, selectedNodeId, updateNodeData, getActiveContext, addNode, setSelectedNode, 
     isTraceComplete, createQuickChatNode, theme,
-    createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages
+    createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages,
+    files, uploadFile, refreshFiles, addFileScope, removeFileScope, currentBlueprintPath,
+    saveCurrentBlueprint
   } = useFlowStore();
-  const { setCenter } = useReactFlow();
+  const { setCenter, getViewport } = useReactFlow();
   const isDark = theme === 'dark';
   const [activeTab, setActiveTab] = useState<'interact' | 'settings' | 'debug'>('interact');
   const [streamBuffer, setStreamBuffer] = useState('');
   const [streamingNodeId, setStreamingNodeId] = useState<string | null>(null); // Track which node is streaming
   
+  // Attachments state
+  const [showAttachModal, setShowAttachModal] = useState(false);
+  const [attachSearch, setAttachSearch] = useState('');
+  const settingsUploadRef = useRef<HTMLInputElement>(null);
+  
   // Response Modal & Edit states
   const [isModalOpen, setIsModalOpen] = useState(false);
   const [isEditing, setIsEditing] = useState(false);
@@ -45,8 +52,14 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
   const [quickChatEffort, setQuickChatEffort] = useState<'low' | 'medium' | 'high'>('medium');
   const [quickChatNeedsDuplicate, setQuickChatNeedsDuplicate] = useState(false);
   const [quickChatWebSearch, setQuickChatWebSearch] = useState(true);
+  const [quickChatAttachedFiles, setQuickChatAttachedFiles] = useState<string[]>([]); // File IDs for current message
+  const [quickChatSentFiles, setQuickChatSentFiles] = useState<{msgId: string, fileIds: string[]}[]>([]); // Files sent with messages
+  const [showQuickChatAttachModal, setShowQuickChatAttachModal] = useState(false);
+  const [quickChatAttachSearch, setQuickChatAttachSearch] = useState('');
+  const [quickChatUploading, setQuickChatUploading] = useState(false); // Upload loading state
   const quickChatEndRef = useRef<HTMLDivElement>(null);
   const quickChatInputRef = useRef<HTMLTextAreaElement>(null);
+  const quickChatUploadRef = useRef<HTMLInputElement>(null);
   
   // Merge Trace states
   const [showMergeModal, setShowMergeModal] = useState(false);
@@ -95,6 +108,86 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     }
   }, [quickChatMessages]);
 
+  // Attachment helpers
+  const handleAttach = async (fileId: string) => {
+    if (!selectedNode) return;
+    const current = selectedNode.data.attachedFileIds || [];
+    if (!current.includes(fileId)) {
+      updateNodeData(selectedNode.id, {
+        attachedFileIds: [...current, fileId]
+      });
+      // Add scope to file for filtering
+      const projectPath = currentBlueprintPath || 'untitled';
+      const scope = `${projectPath}/${selectedNode.id}`;
+      try {
+        await addFileScope(fileId, scope);
+      } catch (e) {
+        console.error('Failed to add file scope:', e);
+      }
+      // Auto-save blueprint to persist attached files
+      if (currentBlueprintPath) {
+        saveCurrentBlueprint(currentBlueprintPath, getViewport()).catch(console.error);
+      }
+    }
+    setShowAttachModal(false);
+  };
+
+  const handleDetach = async (fileId: string) => {
+    if (!selectedNode) return;
+    const current = selectedNode.data.attachedFileIds || [];
+    updateNodeData(selectedNode.id, {
+      attachedFileIds: current.filter(id => id !== fileId)
+    });
+    // Remove scope from file
+    const projectPath = currentBlueprintPath || 'untitled';
+    const scope = `${projectPath}/${selectedNode.id}`;
+    try {
+      await removeFileScope(fileId, scope);
+    } catch (e) {
+      console.error('Failed to remove file scope:', e);
+    }
+    // Auto-save blueprint to persist detached files
+    if (currentBlueprintPath) {
+      saveCurrentBlueprint(currentBlueprintPath, getViewport()).catch(console.error);
+    }
+  };
+
+  const handleUploadAndAttach = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    if (!e.target.files || e.target.files.length === 0 || !selectedNode) return;
+    const file = e.target.files[0];
+    try {
+      // Determine provider based on node model
+      const model = selectedNode.data.model;
+      let provider: 'local' | 'openai' | 'google' = 'local';
+      if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) {
+        provider = 'openai';
+      } else if (model.startsWith('gemini')) {
+        provider = 'google';
+      }
+
+      const meta = await uploadFile(file, { provider });
+      handleAttach(meta.id);
+    } catch (err) {
+      alert(`Upload failed: ${(err as Error).message}`);
+    } finally {
+      e.target.value = '';
+    }
+  };
+
+  // Filter files for attach modal
+  const filteredFilesToAttach = useMemo(() => {
+    const q = attachSearch.trim().toLowerCase();
+    if (!q) return files;
+    return files.filter(f => f.name.toLowerCase().includes(q));
+  }, [files, attachSearch]);
+
+  // Filter files for Quick Chat attach modal
+  const filteredQuickChatFiles = useMemo(() => {
+    const q = quickChatAttachSearch.trim().toLowerCase();
+    if (!q) return files;
+    return files.filter(f => f.name.toLowerCase().includes(q));
+  }, [files, quickChatAttachSearch]);
+
   if (!isOpen) {
     return (
       <div className={`border-l h-screen flex flex-col items-center py-4 w-12 z-10 transition-all duration-300 ${
@@ -161,6 +254,44 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     // Use getActiveContext which respects the user's selected traces
     const context = getActiveContext(runningNodeId);
     
+    // Calculate scopes: all nodes in the current trace path
+    const projectPath = currentBlueprintPath || 'untitled';
+    
+    // Compute all upstream node IDs by traversing edges backward
+    const traceNodeIds = new Set<string>();
+    traceNodeIds.add(runningNodeId);
+    
+    const visited = new Set<string>();
+    const queue = [runningNodeId];
+    
+    while (queue.length > 0) {
+      const currentNodeId = queue.shift()!;
+      if (visited.has(currentNodeId)) continue;
+      visited.add(currentNodeId);
+      
+      // Find all incoming edges to this node
+      const incomingEdges = edges.filter(e => e.target === currentNodeId);
+      for (const edge of incomingEdges) {
+        const sourceNodeId = edge.source;
+        if (!visited.has(sourceNodeId)) {
+          traceNodeIds.add(sourceNodeId);
+          queue.push(sourceNodeId);
+        }
+      }
+    }
+    
+    // Build scopes for all nodes in the trace path
+    const scopes = Array.from(traceNodeIds).map(nodeId => `${projectPath}/${nodeId}`);
+    console.log('[file_search] trace scopes:', scopes);
+
+    // If no prompt but has files, use a default prompt
+    const attachedFiles = selectedNode.data.attachedFileIds || [];
+    const effectivePrompt = runningPrompt?.trim() 
+      ? runningPrompt 
+      : attachedFiles.length > 0 
+        ? 'Please analyze the attached files.'
+        : '';
+
     try {
       const response = await fetch('http://localhost:8000/api/run_node_stream', {
         method: 'POST',
@@ -168,7 +299,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
         body: JSON.stringify({
           node_id: runningNodeId,
           incoming_contexts: [{ messages: context }],
-          user_prompt: runningPrompt,
+          user_prompt: effectivePrompt,
+          attached_file_ids: attachedFiles,
+          scopes,
           merge_strategy: selectedNode.data.mergeStrategy || 'smart',
           config: {
             provider: selectedNode.data.model.includes('gpt') || selectedNode.data.model === 'o3' ? 'openai' : 'google',
@@ -415,6 +548,34 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     const hasResponse = !!selectedNode.data.response;
     const hasDraftPrompt = !!selectedNode.data.userPrompt && !hasResponse;
     
+    // Helper to extract node ID from message ID (format: nodeId-u or nodeId-a)
+    const getNodeIdFromMsgId = (msgId: string): string | null => {
+      if (!msgId) return null;
+      const parts = msgId.split('-');
+      if (parts.length >= 2) {
+        // Remove last part (-u or -a) and rejoin
+        return parts.slice(0, -1).join('-');
+      }
+      return null;
+    };
+    
+    // Helper to build sentFiles from messages
+    const buildSentFilesFromMessages = (messages: Message[]): {msgId: string, fileIds: string[]}[] => {
+      const sentFiles: {msgId: string, fileIds: string[]}[] = [];
+      for (const msg of messages) {
+        if (msg.role === 'user' && msg.id) {
+          const nodeId = getNodeIdFromMsgId(msg.id);
+          if (nodeId) {
+            const node = nodes.find(n => n.id === nodeId);
+            if (node && node.data.attachedFileIds && node.data.attachedFileIds.length > 0) {
+              sentFiles.push({ msgId: msg.id, fileIds: node.data.attachedFileIds });
+            }
+          }
+        }
+      }
+      return sentFiles;
+    };
+    
     if (isNewTrace || !trace) {
       // Start a new trace from current node
       const initialMessages: Message[] = [];
@@ -433,6 +594,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
         messages: initialMessages
       });
       setQuickChatMessages(initialMessages);
+      setQuickChatSentFiles(buildSentFilesFromMessages(initialMessages));
       setQuickChatNeedsDuplicate(false);
       setQuickChatLastNodeId(selectedNode.id);
     } else {
@@ -456,6 +618,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
         messages: fullMessages
       });
       setQuickChatMessages(fullMessages);
+      setQuickChatSentFiles(buildSentFilesFromMessages(fullMessages));
       
       // Set last node ID: if current node has response, start from here.
       // Otherwise start from trace source (which is the last completed node)
@@ -471,6 +634,58 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     setQuickChatOpen(false);
     setQuickChatTrace(null);
     setQuickChatMessages([]);
+    setQuickChatAttachedFiles([]);
+    setQuickChatSentFiles([]);
+  };
+
+  // Quick Chat file attachment helpers
+  const getQuickChatScope = () => {
+    const projectPath = currentBlueprintPath || 'untitled';
+    return `${projectPath}/quick_chat_temp`;
+  };
+
+  const handleQuickChatAttach = async (fileId: string) => {
+    if (!quickChatAttachedFiles.includes(fileId)) {
+      setQuickChatAttachedFiles(prev => [...prev, fileId]);
+      // Add scope to file for filtering
+      try {
+        await addFileScope(fileId, getQuickChatScope());
+      } catch (e) {
+        console.error('Failed to add file scope:', e);
+      }
+    }
+    setShowQuickChatAttachModal(false);
+  };
+
+  const handleQuickChatDetach = async (fileId: string) => {
+    setQuickChatAttachedFiles(prev => prev.filter(id => id !== fileId));
+    // Remove scope from file
+    try {
+      await removeFileScope(fileId, getQuickChatScope());
+    } catch (e) {
+      console.error('Failed to remove file scope:', e);
+    }
+  };
+
+  const handleQuickChatUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    if (!e.target.files || e.target.files.length === 0) return;
+    const file = e.target.files[0];
+    setQuickChatUploading(true);
+    try {
+      const meta = await uploadFile(file, { provider: 'local' });
+      setQuickChatAttachedFiles(prev => [...prev, meta.id]);
+      // Add scope to file for filtering
+      try {
+        await addFileScope(meta.id, getQuickChatScope());
+      } catch (e) {
+        console.error('Failed to add file scope:', e);
+      }
+    } catch (err) {
+      alert(`Upload failed: ${(err as Error).message}`);
+    } finally {
+      e.target.value = '';
+      setQuickChatUploading(false);
+    }
   };
   
   // Open Quick Chat for a merged trace
@@ -482,6 +697,16 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
     const hasResponse = !!selectedNode.data.response;
     const hasDraftPrompt = !!selectedNode.data.userPrompt && !hasResponse;
     
+    // Helper to extract node ID from message ID (format: nodeId-u or nodeId-a)
+    const getNodeIdFromMsgId = (msgId: string): string | null => {
+      if (!msgId) return null;
+      const parts = msgId.split('-');
+      if (parts.length >= 2) {
+        return parts.slice(0, -1).join('-');
+      }
+      return null;
+    };
+    
     // Build messages from merged trace
     const fullMessages: Message[] = [...merged.messages];
     // Only include current node's content if it was sent
@@ -492,6 +717,20 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
       fullMessages.push({ id: `${selectedNode.id}-a`, role: 'assistant', content: selectedNode.data.response });
     }
     
+    // Build sentFiles from messages
+    const sentFiles: {msgId: string, fileIds: string[]}[] = [];
+    for (const msg of fullMessages) {
+      if (msg.role === 'user' && msg.id) {
+        const nodeId = getNodeIdFromMsgId(msg.id);
+        if (nodeId) {
+          const node = nodes.find(n => n.id === nodeId);
+          if (node && node.data.attachedFileIds && node.data.attachedFileIds.length > 0) {
+            sentFiles.push({ msgId: msg.id, fileIds: node.data.attachedFileIds });
+          }
+        }
+      }
+    }
+    
     // Create a pseudo-trace for the merged context
     setQuickChatTrace({
       id: merged.id,
@@ -500,6 +739,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
       messages: fullMessages
     });
     setQuickChatMessages(fullMessages);
+    setQuickChatSentFiles(sentFiles);
     setQuickChatNeedsDuplicate(false); // Merged traces don't duplicate
     
     setQuickChatOpen(true);
@@ -718,19 +958,30 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
   const activeTracesCheck = selectedNode ? checkActiveTracesComplete() : { complete: true };
 
   const handleQuickChatSend = async () => {
-    if (!quickChatInput.trim() || !quickChatTrace || quickChatLoading || !selectedNode) return;
+    // Allow send if there's text OR attached files
+    const hasContent = quickChatInput.trim() || quickChatAttachedFiles.length > 0;
+    if (!hasContent || !quickChatTrace || quickChatLoading || !selectedNode) return;
 
     const userInput = quickChatInput;
+    const attachedFilesCopy = [...quickChatAttachedFiles];
+    const msgId = `qc_${Date.now()}_u`;
+    
     const userMessage: Message = {
-      id: `qc_${Date.now()}_u`,
+      id: msgId,
       role: 'user',
-      content: userInput
+      content: userInput || '[Files attached]'
     };
 
+    // Track sent files for display
+    if (attachedFilesCopy.length > 0) {
+      setQuickChatSentFiles(prev => [...prev, { msgId, fileIds: attachedFilesCopy }]);
+    }
+
     // Add user message to display
     const messagesBeforeSend = [...quickChatMessages];
     setQuickChatMessages(prev => [...prev, userMessage]);
     setQuickChatInput('');
+    setQuickChatAttachedFiles([]); // Clear attached files after send
     setQuickChatLoading(true);
 
     // Store model at send time to avoid issues with model switching during streaming
@@ -745,6 +996,10 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
       const reasoningModels = ['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'o3'];
       const isReasoning = reasoningModels.includes(modelAtSend);
 
+      // Build scopes for file search (Quick Chat uses a temp scope)
+      const projectPath = currentBlueprintPath || 'untitled';
+      const scopes = [`${projectPath}/quick_chat_temp`];
+
       // Call LLM API with current messages as context
       const response = await fetch('http://localhost:8000/api/run_node_stream', {
         method: 'POST',
@@ -752,7 +1007,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
         body: JSON.stringify({
           node_id: 'quick_chat_temp',
           incoming_contexts: [{ messages: messagesBeforeSend }],
-          user_prompt: userInput,
+          user_prompt: userInput || 'Please analyze the attached files.',
+          attached_file_ids: attachedFilesCopy,
+          scopes,
           merge_strategy: 'smart',
           config: {
             provider: isOpenAI ? 'openai' : 'google',
@@ -807,6 +1064,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
           temperature: isReasoning ? 1 : tempAtSend,
           reasoningEffort: effortAtSend,
           enableGoogleSearch: webSearchAtSend,
+          attachedFileIds: attachedFilesCopy,
           status: 'success',
           querySentAt: Date.now(),
           responseReceivedAt: Date.now()
@@ -850,6 +1108,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
             forkedTraces: [],
             mergedTraces: [],
             activeTraceIds: [],
+            attachedFileIds: attachedFilesCopy,
             response: fullResponse,
             status: 'success' as const,
             inputs: 1,
@@ -1575,6 +1834,81 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
 
         {activeTab === 'settings' && (
           <div className="space-y-4">
+            {/* Attachments Section */}
+            <div className={`p-3 rounded border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
+              <label className={`block text-xs font-bold uppercase tracking-wider mb-2 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
+                Attached Files
+              </label>
+              
+              <div className="flex gap-2 mb-3">
+                <button
+                  onClick={() => settingsUploadRef.current?.click()}
+                  className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 rounded text-xs font-medium transition-colors ${
+                    isDark ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-blue-600 hover:bg-blue-700 text-white'
+                  }`}
+                >
+                  <Upload size={14} />
+                  Upload & Attach
+                </button>
+                <input
+                  ref={settingsUploadRef}
+                  type="file"
+                  className="hidden"
+                  onChange={handleUploadAndAttach}
+                />
+                
+                <button
+                  onClick={() => {
+                    refreshFiles();
+                    setShowAttachModal(true);
+                  }}
+                  className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 rounded text-xs font-medium border transition-colors ${
+                    isDark 
+                      ? 'border-gray-600 hover:bg-gray-700 text-gray-200' 
+                      : 'border-gray-300 hover:bg-gray-100 text-gray-700'
+                  }`}
+                >
+                  <Link size={14} />
+                  Attach Existing
+                </button>
+              </div>
+
+              {(selectedNode.data.attachedFileIds || []).length === 0 ? (
+                <p className={`text-xs text-center italic py-2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+                  No files attached.
+                </p>
+              ) : (
+                <div className="space-y-1">
+                  {(selectedNode.data.attachedFileIds || []).map(id => {
+                    const file = files.find(f => f.id === id);
+                    if (!file) return null;
+                    return (
+                      <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'} />
+                          <span className={`truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}>
+                            {file.name}
+                          </span>
+                        </div>
+                        <button
+                          onClick={() => handleDetach(id)}
+                          className={`opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-red-500/20 text-red-500 transition-all`}
+                          title="Remove attachment"
+                        >
+                          <X size={12} />
+                        </button>
+                      </div>
+                    );
+                  })}
+                </div>
+              )}
+            </div>
+
             <div>
               <label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Merge Strategy</label>
               <select 
@@ -2082,7 +2416,11 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   <p>Start a conversation with this trace's context</p>
                 </div>
               ) : (
-                quickChatMessages.map((msg, idx) => (
+                quickChatMessages.map((msg, idx) => {
+                  // Find files sent with this message
+                  const sentFilesForMsg = quickChatSentFiles.find(sf => sf.msgId === msg.id)?.fileIds || [];
+                  
+                  return (
                   <div
                     key={msg.id || idx}
                     className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
@@ -2097,36 +2435,55 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                         />
                       )}
                       
-                      <div
-                        className={`rounded-lg px-4 py-2 ${
-                          msg.role === 'user'
-                            ? 'bg-blue-600 text-white'
-                            : isDark 
-                              ? 'bg-gray-800 border border-gray-700 text-gray-200 shadow-sm' 
-                              : 'bg-white border border-gray-200 shadow-sm'
-                        }`}
-                        style={msg.sourceTraceColor ? { borderLeftColor: msg.sourceTraceColor, borderLeftWidth: '3px' } : undefined}
-                      >
-                        {/* Source trace label for user messages from merged trace */}
-                        {msg.sourceTraceColor && msg.role === 'user' && (
-                          <div 
-                            className="text-[10px] opacity-70 mb-1 flex items-center gap-1"
-                          >
-                            <div 
-                              className="w-2 h-2 rounded-full"
-                              style={{ backgroundColor: msg.sourceTraceColor }}
-                            />
-                            <span>from trace #{msg.sourceTraceId?.slice(-4)}</span>
+                      <div className={`flex flex-col gap-1 ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
+                        {/* Files bubble (shown above text for user messages) */}
+                        {msg.role === 'user' && sentFilesForMsg.length > 0 && (
+                          <div className="bg-blue-500 text-white rounded-lg px-3 py-2 text-sm w-fit max-w-full">
+                            <div className="flex flex-wrap gap-2">
+                              {sentFilesForMsg.map(fileId => {
+                                const file = files.find(f => f.id === fileId);
+                                return (
+                                  <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>
+                                  </div>
+                                );
+                              })}
+                            </div>
                           </div>
                         )}
                         
-                        {msg.role === 'user' ? (
-                          <p className="whitespace-pre-wrap">{msg.content}</p>
-                        ) : (
-                          <div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}>
-                            <ReactMarkdown>{msg.content}</ReactMarkdown>
-                          </div>
-                        )}
+                        <div
+                          className={`rounded-lg px-4 py-2 ${
+                            msg.role === 'user'
+                              ? 'bg-blue-600 text-white'
+                              : isDark 
+                                ? 'bg-gray-800 border border-gray-700 text-gray-200 shadow-sm' 
+                                : 'bg-white border border-gray-200 shadow-sm'
+                          }`}
+                          style={msg.sourceTraceColor ? { borderLeftColor: msg.sourceTraceColor, borderLeftWidth: '3px' } : undefined}
+                        >
+                          {/* Source trace label for user messages from merged trace */}
+                          {msg.sourceTraceColor && msg.role === 'user' && (
+                            <div 
+                              className="text-[10px] opacity-70 mb-1 flex items-center gap-1"
+                            >
+                              <div 
+                                className="w-2 h-2 rounded-full"
+                                style={{ backgroundColor: msg.sourceTraceColor }}
+                              />
+                              <span>from trace #{msg.sourceTraceId?.slice(-4)}</span>
+                            </div>
+                          )}
+                          
+                          {msg.role === 'user' ? (
+                            <p className="whitespace-pre-wrap">{msg.content}</p>
+                          ) : (
+                            <div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}>
+                              <ReactMarkdown>{msg.content}</ReactMarkdown>
+                            </div>
+                          )}
+                        </div>
                       </div>
                       
                       {/* Source trace indicator for user messages (on the right side) */}
@@ -2139,7 +2496,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                       )}
                     </div>
                   </div>
-                ))
+                )})
               )}
               {quickChatLoading && (
                 <div className="flex justify-start">
@@ -2204,10 +2561,80 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Web Search</span>
                 </label>
               )}
+              
+              {/* File Attachment Buttons */}
+              <div className="flex items-center gap-1 ml-auto">
+                <button
+                  onClick={() => quickChatUploadRef.current?.click()}
+                  disabled={quickChatUploading}
+                  className={`px-2 py-0.5 rounded text-xs flex items-center gap-1 ${
+                    quickChatUploading
+                      ? 'opacity-50 cursor-not-allowed'
+                      : ''
+                  } ${
+                    isDark 
+                      ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' 
+                      : 'bg-gray-100 hover:bg-gray-200 text-gray-700'
+                  }`}
+                  title={quickChatUploading ? "Uploading..." : "Upload & Attach"}
+                >
+                  {quickChatUploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
+                  {quickChatUploading ? 'Uploading...' : 'Upload'}
+                </button>
+                <button
+                  onClick={() => {
+                    refreshFiles();
+                    setShowQuickChatAttachModal(true);
+                  }}
+                  className={`px-2 py-0.5 rounded text-xs flex items-center gap-1 ${
+                    isDark 
+                      ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' 
+                      : 'bg-gray-100 hover:bg-gray-200 text-gray-700'
+                  }`}
+                  title="Attach Existing File"
+                >
+                  <Link size={12} />
+                  Attach
+                </button>
+                <input
+                  ref={quickChatUploadRef}
+                  type="file"
+                  className="hidden"
+                  onChange={handleQuickChatUpload}
+                />
+              </div>
             </div>
 
             {/* Input Area */}
             <div className={`p-4 border-t ${isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'}`}>
+              {/* Attached Files Preview */}
+              {quickChatAttachedFiles.length > 0 && (
+                <div className={`mb-2 p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-100'}`}>
+                  <div className="flex flex-wrap gap-2">
+                    {quickChatAttachedFiles.map(fileId => {
+                      const file = files.find(f => f.id === fileId);
+                      if (!file) return null;
+                      return (
+                        <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} />
+                          <span className="max-w-[120px] truncate">{file.name}</span>
+                          <button
+                            onClick={() => handleQuickChatDetach(fileId)}
+                            className={`ml-1 p-0.5 rounded hover:bg-red-500 hover:text-white ${isDark ? 'text-gray-400' : 'text-gray-500'}`}
+                          >
+                            <X size={10} />
+                          </button>
+                        </div>
+                      );
+                    })}
+                  </div>
+                </div>
+              )}
               <div className="flex gap-2">
                 <textarea
                   ref={quickChatInputRef}
@@ -2216,15 +2643,17 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                   onKeyDown={(e) => {
                     if (e.key === 'Enter' && !e.shiftKey) {
                       e.preventDefault();
-                      // Only send if not loading
-                      if (!quickChatLoading) {
+                      // Only send if not loading and has content (text or files)
+                      if (!quickChatLoading && (quickChatInput.trim() || quickChatAttachedFiles.length > 0)) {
                         handleQuickChatSend();
                       }
                     }
                   }}
                   placeholder={quickChatLoading 
                     ? "Waiting for response... (you can type here)" 
-                    : "Type your message... (Enter to send, Shift+Enter for new line)"
+                    : quickChatAttachedFiles.length > 0
+                      ? "Add a message (optional) or just send the files..."
+                      : "Type your message... (Enter to send, Shift+Enter for new line)"
                   }
                   className={`flex-1 border rounded-lg px-4 py-3 text-sm resize-y min-h-[50px] max-h-[150px] focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
                     isDark ? 'bg-gray-700 border-gray-600 text-gray-200 placeholder-gray-400' : 'border-gray-300'
@@ -2233,7 +2662,7 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
                 />
                 <button
                   onClick={handleQuickChatSend}
-                  disabled={!quickChatInput.trim() || quickChatLoading}
+                  disabled={(!quickChatInput.trim() && quickChatAttachedFiles.length === 0) || quickChatLoading}
                   className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-300 disabled:cursor-not-allowed flex items-center gap-2"
                 >
                   {quickChatLoading ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />}
@@ -2246,6 +2675,180 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
           </div>
         </div>
       )}
+      {/* Attach File Modal */}
+      {showAttachModal && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
+          <div 
+            className={`w-full max-w-md rounded-lg shadow-xl flex flex-col max-h-[80vh] ${
+              isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'
+            }`}
+          >
+            <div className={`p-4 border-b flex justify-between items-center ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+              <h3 className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>Attach File</h3>
+              <button 
+                onClick={() => setShowAttachModal(false)}
+                className={`p-1 rounded ${isDark ? 'hover:bg-gray-700 text-gray-400' : 'hover:bg-gray-100 text-gray-500'}`}
+              >
+                <X size={20} />
+              </button>
+            </div>
+            
+            <div className="p-4 border-b border-gray-200 dark:border-gray-700">
+              <div className="relative">
+                <Search size={16} className={`absolute left-3 top-1/2 -translate-y-1/2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
+                <input
+                  type="text"
+                  placeholder="Search files..."
+                  value={attachSearch}
+                  onChange={(e) => setAttachSearch(e.target.value)}
+                  className={`w-full pl-9 pr-3 py-2 rounded-md text-sm border outline-none focus:ring-2 focus:ring-blue-500 ${
+                    isDark 
+                      ? 'bg-gray-900 border-gray-600 text-white placeholder-gray-500' 
+                      : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
+                  }`}
+                  autoFocus
+                />
+              </div>
+            </div>
+
+            <div className="flex-1 overflow-y-auto p-2">
+              {filteredFilesToAttach.length === 0 ? (
+                <div className={`text-center py-8 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+                  <FileText size={48} className="mx-auto mb-2 opacity-50" />
+                  <p>No matching files found.</p>
+                </div>
+              ) : (
+                <div className="space-y-1">
+                  {filteredFilesToAttach.map(file => {
+                    const isAttached = (selectedNode?.data.attachedFileIds || []).includes(file.id);
+                    return (
+                      <button
+                        key={file.id}
+                        onClick={() => handleAttach(file.id)}
+                        disabled={isAttached}
+                        className={`w-full flex items-center justify-between p-3 rounded text-left transition-colors ${
+                          isAttached
+                            ? isDark ? 'opacity-50 cursor-not-allowed bg-gray-700/50' : 'opacity-50 cursor-not-allowed bg-gray-100'
+                            : isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-50'
+                        }`}
+                      >
+                        <div className="flex items-center gap-3 overflow-hidden">
+                          <div className={`p-2 rounded ${isDark ? 'bg-gray-700' : 'bg-gray-100'}`}>
+                            <FileText size={18} className={isDark ? 'text-blue-400' : 'text-blue-600'} />
+                          </div>
+                          <div className="min-w-0">
+                            <div className={`font-medium truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}>
+                              {file.name}
+                            </div>
+                            <div className={`text-xs truncate ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
+                              {(file.size / 1024).toFixed(1)} KB • {new Date(file.created_at * 1000).toLocaleDateString()}
+                              {file.provider && ` • ${file.provider}`}
+                            </div>
+                          </div>
+                        </div>
+                        {isAttached && (
+                          <Check size={16} className="text-green-500 flex-shrink-0" />
+                        )}
+                      </button>
+                    );
+                  })}
+                </div>
+              )}
+            </div>
+            
+            <div className={`p-4 border-t text-xs text-center ${isDark ? 'border-gray-700 text-gray-500' : 'border-gray-200 text-gray-500'}`}>
+              Showing {filteredFilesToAttach.length} file{filteredFilesToAttach.length !== 1 ? 's' : ''}
+            </div>
+          </div>
+        </div>
+      )}
+      
+      {/* Quick Chat Attach File Modal */}
+      {showQuickChatAttachModal && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
+          <div 
+            className={`w-full max-w-md rounded-lg shadow-xl flex flex-col max-h-[80vh] ${
+              isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'
+            }`}
+          >
+            <div className={`p-4 border-b flex justify-between items-center ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+              <h3 className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>Attach File to Quick Chat</h3>
+              <button 
+                onClick={() => setShowQuickChatAttachModal(false)}
+                className={`p-1 rounded ${isDark ? 'hover:bg-gray-700 text-gray-400' : 'hover:bg-gray-100 text-gray-500'}`}
+              >
+                <X size={20} />
+              </button>
+            </div>
+            
+            <div className="p-4 border-b border-gray-200 dark:border-gray-700">
+              <div className="relative">
+                <Search size={16} className={`absolute left-3 top-1/2 -translate-y-1/2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
+                <input
+                  type="text"
+                  placeholder="Search files..."
+                  value={quickChatAttachSearch}
+                  onChange={(e) => setQuickChatAttachSearch(e.target.value)}
+                  className={`w-full pl-9 pr-3 py-2 rounded-md text-sm border outline-none focus:ring-2 focus:ring-blue-500 ${
+                    isDark 
+                      ? 'bg-gray-900 border-gray-600 text-white placeholder-gray-500' 
+                      : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'
+                  }`}
+                  autoFocus
+                />
+              </div>
+            </div>
+
+            <div className="flex-1 overflow-y-auto p-2">
+              {filteredQuickChatFiles.length === 0 ? (
+                <div className={`text-center py-8 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+                  <FileText size={48} className="mx-auto mb-2 opacity-50" />
+                  <p>No matching files found.</p>
+                </div>
+              ) : (
+                <div className="space-y-1">
+                  {filteredQuickChatFiles.map(file => {
+                    const isAttached = quickChatAttachedFiles.includes(file.id);
+                    return (
+                      <button
+                        key={file.id}
+                        onClick={() => handleQuickChatAttach(file.id)}
+                        disabled={isAttached}
+                        className={`w-full flex items-center justify-between p-3 rounded text-left transition-colors ${
+                          isAttached
+                            ? isDark ? 'opacity-50 cursor-not-allowed bg-gray-700/50' : 'opacity-50 cursor-not-allowed bg-gray-100'
+                            : isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-50'
+                        }`}
+                      >
+                        <div className="flex items-center gap-3 overflow-hidden">
+                          <div className={`p-2 rounded ${isDark ? 'bg-gray-700' : 'bg-gray-100'}`}>
+                            <FileText size={18} className={isDark ? 'text-blue-400' : 'text-blue-600'} />
+                          </div>
+                          <div className="min-w-0">
+                            <div className={`font-medium truncate ${isDark ? 'text-gray-200' : 'text-gray-700'}`}>
+                              {file.name}
+                            </div>
+                            <div className={`text-xs truncate ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
+                              {(file.size / 1024).toFixed(1)} KB • {new Date(file.created_at * 1000).toLocaleDateString()}
+                            </div>
+                          </div>
+                        </div>
+                        {isAttached && (
+                          <Check size={16} className="text-green-500 flex-shrink-0" />
+                        )}
+                      </button>
+                    );
+                  })}
+                </div>
+              )}
+            </div>
+            
+            <div className={`p-4 border-t text-xs text-center ${isDark ? 'border-gray-700 text-gray-500' : 'border-gray-200 text-gray-500'}`}>
+              Showing {filteredQuickChatFiles.length} file{filteredQuickChatFiles.length !== 1 ? 's' : ''}
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 };
-- 
cgit v1.2.3

