summaryrefslogtreecommitdiff
path: root/frontend/src/components/Sidebar.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/Sidebar.tsx')
-rw-r--r--frontend/src/components/Sidebar.tsx689
1 files changed, 646 insertions, 43 deletions
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>
);
};