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.tsx2753
1 files changed, 2659 insertions, 94 deletions
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index f62f3cb..4febe4f 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -1,25 +1,235 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import { useReactFlow } from 'reactflow';
import useFlowStore from '../store/flowStore';
-import type { NodeData } from '../store/flowStore';
+import { useAuthStore } from '../store/authStore';
+import type { NodeData, Trace, Message, MergedTrace, MergeStrategy } from '../store/flowStore';
+import type { Edge } from 'reactflow';
import ReactMarkdown from 'react-markdown';
-import { Play, Settings, Info, Save } 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 } from 'lucide-react';
-const Sidebar = () => {
- const { nodes, selectedNodeId, updateNodeData, getActiveContext } = useFlowStore();
+interface SidebarProps {
+ isOpen: boolean;
+ onToggle: () => void;
+ onInteract?: () => void;
+}
+
+const Sidebar: React.FC<SidebarProps> = ({ isOpen, onToggle, onInteract }) => {
+ const {
+ nodes, edges, selectedNodeId, updateNodeData, getActiveContext, addNode, setSelectedNode,
+ isTraceComplete, theme,
+ createMergedTrace, updateMergedTrace, deleteMergedTrace, computeMergedMessages,
+ files, uploadFile, refreshFiles, addFileScope, removeFileScope, currentBlueprintPath,
+ saveCurrentBlueprint
+ } = useFlowStore();
+ const { getAuthHeader } = useAuthStore();
+ 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);
+ const [editedResponse, setEditedResponse] = useState('');
+
+ // Summary states
+ const [showSummaryModal, setShowSummaryModal] = useState(false);
+ const [summaryModel, setSummaryModel] = useState('gpt-5-nano');
+ const [isSummarizing, setIsSummarizing] = useState(false);
+
+ // 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');
+ const [quickChatLoading, setQuickChatLoading] = useState(false);
+ const [quickChatTemp, setQuickChatTemp] = useState(0.7);
+ 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);
+ const [mergeSelectedIds, setMergeSelectedIds] = useState<string[]>([]);
+ const [mergeStrategy, setMergeStrategy] = useState<MergeStrategy>('query_time');
+ const [mergeDraggedId, setMergeDraggedId] = useState<string | null>(null);
+ const [mergeOrder, setMergeOrder] = useState<string[]>([]);
+ const [showMergePreview, setShowMergePreview] = useState(false);
+ const [isSummarizingMerge, setIsSummarizingMerge] = useState(false);
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
- // Reset stream buffer when node changes
+ // Reset stream buffer and modal states when node changes
useEffect(() => {
setStreamBuffer('');
+ setIsModalOpen(false);
+ setIsEditing(false);
+ setShowMergeModal(false);
+ setMergeSelectedIds([]);
+ setShowMergePreview(false);
}, [selectedNodeId]);
+
+ // Default select first trace when node changes and no trace is selected
+ useEffect(() => {
+ if (selectedNode &&
+ selectedNode.data.traces &&
+ selectedNode.data.traces.length > 0 &&
+ (!selectedNode.data.activeTraceIds || selectedNode.data.activeTraceIds.length === 0)) {
+ updateNodeData(selectedNode.id, {
+ activeTraceIds: [selectedNode.data.traces[0].id]
+ });
+ }
+ }, [selectedNodeId, selectedNode?.data.traces?.length]);
+
+ // Sync editedResponse when entering edit mode
+ useEffect(() => {
+ if (isEditing && selectedNode) {
+ setEditedResponse(selectedNode.data.response || '');
+ }
+ }, [isEditing, selectedNode?.data.response]);
+
+ // Scroll to bottom when quick chat messages change
+ useEffect(() => {
+ if (quickChatEndRef.current) {
+ quickChatEndRef.current.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [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 ${
+ isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'
+ }`}>
+ <button
+ onClick={onToggle}
+ className={`p-2 rounded mb-4 ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
+ title="Expand"
+ >
+ <ChevronLeft size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
+ </button>
+ {selectedNode && (
+ <div className={`writing-vertical text-xs font-bold uppercase tracking-widest mt-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} style={{ writingMode: 'vertical-rl' }}>
+ {selectedNode.data.label}
+ </div>
+ )}
+ </div>
+ );
+ }
if (!selectedNode) {
return (
- <div className="w-96 border-l border-gray-200 h-screen p-4 bg-gray-50 text-gray-500 text-center flex flex-col justify-center">
+ <div className={`w-96 border-l h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${
+ isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'
+ }`}>
+ <div className={`p-3 border-b flex justify-between items-center ${
+ isDark ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-gray-50'
+ }`}>
+ <span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Details</span>
+ <button onClick={onToggle} className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}>
+ <ChevronRight size={16} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
+ </button>
+ </div>
+ <div className={`flex-1 p-4 text-center flex flex-col justify-center ${
+ isDark ? 'bg-gray-900 text-gray-400' : 'bg-gray-50 text-gray-500'
+ }`}>
<p>Select a node to edit</p>
+ </div>
</div>
);
}
@@ -27,27 +237,83 @@ const Sidebar = () => {
const handleRun = async () => {
if (!selectedNode) return;
- updateNodeData(selectedNode.id, { status: 'loading', response: '' });
+ // Check if upstream is complete before running
+ const tracesCheck = checkActiveTracesComplete();
+ if (!tracesCheck.complete) {
+ console.warn('Cannot run: upstream context is incomplete');
+ return;
+ }
+
+ // Capture the node ID at the start of the request
+ const runningNodeId = selectedNode.id;
+ const runningPrompt = selectedNode.data.userPrompt;
+
+ // Record query sent timestamp
+ const querySentAt = Date.now();
+ updateNodeData(runningNodeId, { status: 'loading', response: '', querySentAt });
setStreamBuffer('');
+ setStreamingNodeId(runningNodeId);
// Use getActiveContext which respects the user's selected traces
- const context = getActiveContext(selectedNode.id);
+ 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', {
+ const response = await fetch('/api/run_node_stream', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify({
- node_id: selectedNode.id,
- incoming_contexts: [{ messages: context }], // Simple list wrap for now
- user_prompt: selectedNode.data.userPrompt,
+ node_id: runningNodeId,
+ incoming_contexts: [{ messages: context }],
+ user_prompt: effectivePrompt,
+ attached_file_ids: attachedFiles,
+ scopes,
merge_strategy: selectedNode.data.mergeStrategy || 'smart',
config: {
- provider: selectedNode.data.model.includes('gpt') ? 'openai' : 'google',
+ provider: selectedNode.data.model.includes('gpt') || selectedNode.data.model === 'o3' ? 'openai' : 'google',
model_name: selectedNode.data.model,
temperature: selectedNode.data.temperature,
system_prompt: selectedNode.data.systemPrompt,
api_key: selectedNode.data.apiKey,
+ enable_google_search: selectedNode.data.enableGoogleSearch !== false,
+ reasoning_effort: selectedNode.data.reasoningEffort || 'medium',
}
})
});
@@ -59,20 +325,22 @@ const Sidebar = () => {
while (true) {
const { value, done } = await reader.read();
- if (done) break;
+ if (done) {
+ console.log('[stream] done, fullResponse length:', fullResponse.length);
+ break;
+ }
const chunk = decoder.decode(value);
+ console.log('[stream] received chunk:', chunk.substring(0, 50));
fullResponse += chunk;
+ // Only update stream buffer, the display logic will check streamingNodeId
setStreamBuffer(prev => prev + chunk);
- // We update the store less frequently or at the end to avoid too many re-renders
- // But for "live" feel we might want to update local state `streamBuffer` and sync to store at end
}
- // Update final state
- // Append the new interaction to the node's output messages
+ // Update final state using captured nodeId
const newUserMsg = {
id: `msg_${Date.now()}_u`,
role: 'user',
- content: selectedNode.data.userPrompt
+ content: runningPrompt
};
const newAssistantMsg = {
id: `msg_${Date.now()}_a`,
@@ -80,44 +348,1032 @@ const Sidebar = () => {
content: fullResponse
};
- updateNodeData(selectedNode.id, {
+ const responseReceivedAt = Date.now();
+
+ updateNodeData(runningNodeId, {
status: 'success',
response: fullResponse,
+ responseReceivedAt,
messages: [...context, newUserMsg, newAssistantMsg] as any
});
+
+ // Auto-generate title
+ generateTitle(runningNodeId, runningPrompt, fullResponse);
} catch (error) {
console.error(error);
- updateNodeData(selectedNode.id, { status: 'error' });
+ updateNodeData(runningNodeId, { status: 'error' });
+ } finally {
+ setStreamingNodeId(prev => prev === runningNodeId ? null : prev);
}
};
const handleChange = (field: keyof NodeData, value: any) => {
updateNodeData(selectedNode.id, { [field]: value });
};
+
+ const handleSaveEdit = () => {
+ if (!selectedNode) return;
+ updateNodeData(selectedNode.id, { response: editedResponse });
+ setIsEditing(false);
+ };
+
+ const handleCancelEdit = () => {
+ setIsEditing(false);
+ setEditedResponse(selectedNode?.data.response || '');
+ };
+
+ // Summarize response
+ const handleSummarize = async () => {
+ if (!selectedNode?.data.response) return;
+
+ setIsSummarizing(true);
+ setShowSummaryModal(false);
+
+ try {
+ const res = await fetch('/api/summarize', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
+ body: JSON.stringify({
+ content: selectedNode.data.response,
+ model: summaryModel
+ })
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ if (data.summary) {
+ // Replace response with summary
+ updateNodeData(selectedNode.id, { response: data.summary });
+ }
+ }
+ } catch (error) {
+ console.error('Summarization failed:', error);
+ } finally {
+ setIsSummarizing(false);
+ }
+ };
+
+ // Auto-generate title using gpt-5-nano
+ const generateTitle = async (nodeId: string, userPrompt: string, response: string) => {
+ try {
+ const res = await fetch('/api/generate_title', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
+ body: JSON.stringify({ user_prompt: userPrompt, response })
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ if (data.title) {
+ updateNodeData(nodeId, { label: data.title });
+ }
+ }
+ } catch (error) {
+ console.error('Failed to generate title:', error);
+ // Silently fail - keep the original title
+ }
+ };
+
+ // Open merge modal
+ const openMergeModal = () => {
+ if (!selectedNode?.data.traces) return;
+ const traceIds = selectedNode.data.traces.map((t: Trace) => t.id);
+ setMergeOrder(traceIds);
+ setMergeSelectedIds([]);
+ setShowMergePreview(false);
+ setShowMergeModal(true);
+ };
+
+ // Drag-and-drop handlers for merge modal
+ const handleMergeDragStart = (e: React.DragEvent, traceId: string) => {
+ setMergeDraggedId(traceId);
+ e.dataTransfer.effectAllowed = 'move';
+ };
+
+ const handleMergeDragOver = (e: React.DragEvent, overTraceId: string) => {
+ e.preventDefault();
+ if (!mergeDraggedId || mergeDraggedId === overTraceId) return;
+
+ const newOrder = [...mergeOrder];
+ const draggedIndex = newOrder.indexOf(mergeDraggedId);
+ const overIndex = newOrder.indexOf(overTraceId);
+
+ if (draggedIndex !== -1 && overIndex !== -1) {
+ newOrder.splice(draggedIndex, 1);
+ newOrder.splice(overIndex, 0, mergeDraggedId);
+ setMergeOrder(newOrder);
+ }
+ };
+
+ const handleMergeDragEnd = () => {
+ setMergeDraggedId(null);
+ };
+
+ // Toggle trace selection in merge modal
+ const toggleMergeSelection = (traceId: string) => {
+ setMergeSelectedIds(prev => {
+ if (prev.includes(traceId)) {
+ return prev.filter(id => id !== traceId);
+ } else {
+ return [...prev, traceId];
+ }
+ });
+ };
+
+ // Create merged trace
+ const handleCreateMergedTrace = async () => {
+ if (!selectedNode || mergeSelectedIds.length < 2) return;
+
+ // Get the ordered trace IDs based on mergeOrder
+ const orderedSelectedIds = mergeOrder.filter(id => mergeSelectedIds.includes(id));
+
+ if (mergeStrategy === 'summary') {
+ setIsSummarizingMerge(true);
+ try {
+ const messages = computeMergedMessages(selectedNode.id, orderedSelectedIds, 'trace_order');
+ const content = messages.map(m => `${m.role}: ${m.content}`).join('\n\n');
+
+ const res = await fetch('/api/summarize', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
+ body: JSON.stringify({
+ content,
+ model_name: 'gpt-5-nano',
+ api_key: selectedNode.data.apiKey
+ })
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ const mergedId = createMergedTrace(selectedNode.id, orderedSelectedIds, 'summary');
+ if (mergedId && data.summary) {
+ updateMergedTrace(selectedNode.id, mergedId, { summarizedContent: data.summary });
+ }
+ }
+ } catch (error) {
+ console.error('Failed to summarize for merge:', error);
+ } finally {
+ setIsSummarizingMerge(false);
+ }
+ } else {
+ createMergedTrace(selectedNode.id, orderedSelectedIds, mergeStrategy);
+ }
+
+ // Close modal and reset
+ setShowMergeModal(false);
+ setMergeSelectedIds([]);
+ setShowMergePreview(false);
+ };
+
+ // Get preview of merged messages
+ const getMergePreview = () => {
+ if (!selectedNode || mergeSelectedIds.length < 2) return [];
+ const orderedSelectedIds = mergeOrder.filter(id => mergeSelectedIds.includes(id));
+ return computeMergedMessages(selectedNode.id, orderedSelectedIds, mergeStrategy);
+ };
+
+ // Check if a trace has downstream nodes from the current selected node
+ const traceHasDownstream = (_trace: Trace): boolean => {
+ if (!selectedNode) return false;
+
+ // Find edges going out from selectedNode that are part of this trace
+ const outgoingEdge = edges.find(e =>
+ e.source === selectedNode.id &&
+ e.sourceHandle?.startsWith('trace-')
+ );
+
+ return !!outgoingEdge;
+ };
+
+ // Quick Chat functions
+ const openQuickChat = (trace: Trace | null, isNewTrace: boolean = false) => {
+ if (!selectedNode) return;
+ onInteract?.(); // Close context menu when opening quick chat
+
+ // Check if current node has a "sent" query (has response) or just unsent draft
+ 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[] = [];
+ // Only include user prompt as message if it was actually sent (has response)
+ if (selectedNode.data.userPrompt && hasResponse) {
+ initialMessages.push({ id: `${selectedNode.id}-u`, role: 'user', content: selectedNode.data.userPrompt });
+ }
+ if (selectedNode.data.response) {
+ initialMessages.push({ id: `${selectedNode.id}-a`, role: 'assistant', content: selectedNode.data.response });
+ }
+
+ setQuickChatTrace({
+ id: `new-trace-${selectedNode.id}`,
+ sourceNodeId: selectedNode.id,
+ color: '#888',
+ messages: initialMessages
+ });
+ setQuickChatMessages(initialMessages);
+ setQuickChatSentFiles(buildSentFilesFromMessages(initialMessages));
+ setQuickChatNeedsDuplicate(false);
+ setQuickChatLastNodeId(selectedNode.id);
+ } else {
+ // Use existing trace context
+ const hasDownstream = traceHasDownstream(trace);
+ setQuickChatNeedsDuplicate(hasDownstream);
+
+ // Build full message history
+ const fullMessages: Message[] = [...trace.messages];
+ // Only include current node's content if it was sent
+ if (selectedNode.data.userPrompt && hasResponse) {
+ fullMessages.push({ id: `${selectedNode.id}-u`, role: 'user', content: selectedNode.data.userPrompt });
+ }
+ if (selectedNode.data.response) {
+ fullMessages.push({ id: `${selectedNode.id}-a`, role: 'assistant', content: selectedNode.data.response });
+ }
+
+ setQuickChatTrace({
+ ...trace,
+ sourceNodeId: selectedNode.id,
+ messages: fullMessages
+ });
+ setQuickChatMessages(fullMessages);
+ setQuickChatSentFiles(buildSentFilesFromMessages(fullMessages));
+
+ // Always set last node ID to current selected node
+ // handleQuickChatSend will decide whether to overwrite (if empty) or create new node (if has response)
+ setQuickChatLastNodeId(selectedNode.id);
+ }
+
+ setQuickChatOpen(true);
+ // If there's an unsent draft, put it in the input box
+ setQuickChatInput(hasDraftPrompt ? selectedNode.data.userPrompt : '');
+ };
+
+ const closeQuickChat = () => {
+ 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
+ const openMergedQuickChat = (merged: MergedTrace) => {
+ if (!selectedNode) return;
+ onInteract?.();
+
+ // Check if current node has a "sent" query (has response) or just unsent draft
+ 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
+ if (selectedNode.data.userPrompt && hasResponse) {
+ fullMessages.push({ id: `${selectedNode.id}-u`, role: 'user', content: selectedNode.data.userPrompt });
+ }
+ if (selectedNode.data.response) {
+ 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,
+ sourceNodeId: selectedNode.id,
+ color: merged.colors[0] || '#888',
+ messages: fullMessages
+ });
+ setQuickChatMessages(fullMessages);
+ setQuickChatSentFiles(sentFiles);
+ setQuickChatNeedsDuplicate(false); // Merged traces don't duplicate
+
+ setQuickChatOpen(true);
+ // If there's an unsent draft, put it in the input box
+ setQuickChatInput(hasDraftPrompt ? selectedNode.data.userPrompt : '');
+ };
+
+ // Check if a trace is complete (all upstream nodes have Q&A)
+ const canQuickChat = (trace: Trace): boolean => {
+ return isTraceComplete(trace);
+ };
+
+ // Helper: Check if all upstream nodes have complete Q&A by traversing edges
+ const checkUpstreamNodesComplete = (nodeId: string, visited: Set<string> = new Set()): boolean => {
+ if (visited.has(nodeId)) return true; // Avoid cycles
+ visited.add(nodeId);
+
+ const node = nodes.find(n => n.id === nodeId);
+ if (!node) return true;
+
+ // Find all incoming edges to this node
+ const incomingEdges = edges.filter(e => e.target === nodeId);
+
+ for (const edge of incomingEdges) {
+ const sourceNode = nodes.find(n => n.id === edge.source);
+ if (!sourceNode) continue;
+
+ // Check if source node is disabled - skip disabled nodes
+ if (sourceNode.data.disabled) continue;
+
+ // Check if source node has complete Q&A
+ if (!sourceNode.data.userPrompt || !sourceNode.data.response) {
+ return false; // Found an incomplete upstream node
+ }
+
+ // Recursively check further upstream
+ if (!checkUpstreamNodesComplete(edge.source, visited)) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ // Helper: find incoming edge for a given trace ID (with fallbacks)
+ const findIncomingEdgeForTrace = (nodeId: string, traceId: string): Edge | null => {
+ // 1) exact match by sourceHandle
+ let edge = edges.find(e => e.target === nodeId && e.sourceHandle === `trace-${traceId}`);
+ if (edge) return edge;
+ // 2) fallback: any incoming edge whose source has this trace in outgoingTraces
+ edge = edges.find(e => {
+ if (e.target !== nodeId) return false;
+ const src = nodes.find(n => n.id === e.source);
+ return src?.data.outgoingTraces?.some((t: Trace) => t.id === traceId);
+ });
+ return edge || null;
+ };
+
+ // Helper: get source trace IDs for a merged trace on a given node (supports propagated merged traces)
+ const getMergedSourceIds = (nodeId: string, traceId: string): string[] => {
+ const node = nodes.find(n => n.id === nodeId);
+ if (!node) return [];
+ const mergedLocal = node.data.mergedTraces?.find((m: MergedTrace) => m.id === traceId);
+ if (mergedLocal) return mergedLocal.sourceTraceIds || [];
+ const incomingMatch = node.data.traces?.find((t: Trace) => t.id === traceId);
+ if (incomingMatch?.isMerged && incomingMatch.sourceTraceIds) return incomingMatch.sourceTraceIds;
+ const outgoingMatch = node.data.outgoingTraces?.find((t: Trace) => t.id === traceId);
+ if (outgoingMatch?.isMerged && outgoingMatch.sourceTraceIds) return outgoingMatch.sourceTraceIds;
+ return [];
+ };
+
+ // Recursive: Check if specific trace path upstream has complete nodes (supports multi-level merged)
+ const checkTracePathComplete = (
+ nodeId: string,
+ traceId: string,
+ visited: Set<string> = new Set()
+ ): boolean => {
+ const visitKey = `${nodeId}-${traceId}`;
+ if (visited.has(visitKey)) return true;
+ visited.add(visitKey);
+
+ // Determine if this node is the merge owner or just receiving a propagated merged trace
+ const localMerge = nodes.find(n => n.id === nodeId)?.data.mergedTraces?.some(m => m.id === traceId);
+ const localParents = getMergedSourceIds(nodeId, traceId);
+
+ const incomingEdge = findIncomingEdgeForTrace(nodeId, traceId);
+ if (!incomingEdge) {
+ // If no incoming edge and this node owns the merge, check parents from here
+ if (localMerge && localParents.length > 0) {
+ for (const pid of localParents) {
+ if (!checkTracePathComplete(nodeId, pid, visited)) return false;
+ }
+ return true;
+ }
+ return true; // head
+ }
+
+ const sourceNode = nodes.find(n => n.id === incomingEdge.source);
+ if (!sourceNode || sourceNode.data.disabled) return true;
+
+ // If merged at sourceNode (or propagated merged), recurse into each parent from the merge owner
+ const parentIds = localMerge ? localParents : getMergedSourceIds(sourceNode.id, traceId);
+ if (parentIds.length > 0) {
+ const mergeOwnerId = localMerge ? nodeId : sourceNode.id;
+ for (const pid of parentIds) {
+ if (!checkTracePathComplete(mergeOwnerId, pid, visited)) return false;
+ }
+ return true;
+ }
+
+ // Regular trace: check node content then continue upstream
+ if (!sourceNode.data.userPrompt || !sourceNode.data.response) return false;
+ return checkTracePathComplete(sourceNode.id, traceId, visited);
+ };
+
+ // Recursive: Find the first empty node on a specific trace path (supports multi-level merged)
+ const findEmptyNodeOnTrace = (
+ nodeId: string,
+ traceId: string,
+ visited: Set<string> = new Set()
+ ): string | null => {
+ const visitKey = `${nodeId}-${traceId}`;
+ if (visited.has(visitKey)) return null;
+ visited.add(visitKey);
+
+ // Determine if this node owns the merge or just receives propagated merged trace
+ const localMerge = nodes.find(n => n.id === nodeId)?.data.mergedTraces?.some(m => m.id === traceId);
+ const localParents = getMergedSourceIds(nodeId, traceId);
+
+ const incomingEdge = findIncomingEdgeForTrace(nodeId, traceId);
+ if (!incomingEdge) {
+ if (localMerge && localParents.length > 0) {
+ for (const pid of localParents) {
+ const upstreamEmpty = findEmptyNodeOnTrace(nodeId, pid, visited);
+ if (upstreamEmpty) return upstreamEmpty;
+ }
+ }
+ return null;
+ }
+
+ const sourceNode = nodes.find(n => n.id === incomingEdge.source);
+ if (!sourceNode || sourceNode.data.disabled) return null;
+
+ const parentIds = localMerge ? localParents : getMergedSourceIds(sourceNode.id, traceId);
+ if (parentIds.length > 0) {
+ const mergeOwnerId = localMerge ? nodeId : sourceNode.id;
+ for (const pid of parentIds) {
+ const upstreamEmpty = findEmptyNodeOnTrace(mergeOwnerId, pid, visited);
+ if (upstreamEmpty) return upstreamEmpty;
+ }
+ }
+
+ if (!sourceNode.data.userPrompt || !sourceNode.data.response) {
+ return sourceNode.id;
+ }
+ return findEmptyNodeOnTrace(sourceNode.id, traceId, visited);
+ };
+
+ // Check if all active traces are complete (for main Run Node button)
+ const checkActiveTracesComplete = (): { complete: boolean; incompleteTraceId?: string } => {
+ if (!selectedNode) return { complete: true };
+
+ const activeTraceIds = selectedNode.data.activeTraceIds || [];
+ if (activeTraceIds.length === 0) return { complete: true };
+
+ // Check upstream nodes ONLY for active traces (supports merged trace recursion)
+ for (const traceId of activeTraceIds) {
+ if (!checkTracePathComplete(selectedNode.id, traceId)) {
+ return { complete: false, incompleteTraceId: 'upstream' };
+ }
+ }
+
+ // Check incoming traces content (message integrity)
+ const incomingTraces = selectedNode.data.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 merged traces content (including propagated merged traces)
+ for (const traceId of activeTraceIds) {
+ const sourceIds = getMergedSourceIds(selectedNode.id, traceId);
+ if (sourceIds.length > 0) {
+ for (const sourceId of sourceIds) {
+ const sourceTrace = incomingTraces.find((t: Trace) => t.id === sourceId);
+ if (sourceTrace && !isTraceComplete(sourceTrace)) {
+ return { complete: false, incompleteTraceId: sourceId };
+ }
+ }
+ }
+ }
+
+ 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) {
+ const 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 };
+
+ const handleQuickChatSend = async () => {
+ // 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: msgId,
+ role: 'user',
+ 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
+ const modelAtSend = quickChatModel;
+ const tempAtSend = quickChatTemp;
+ const effortAtSend = quickChatEffort;
+ const webSearchAtSend = quickChatWebSearch;
+
+ try {
+ // Determine provider
+ const isOpenAI = modelAtSend.includes('gpt') || modelAtSend === 'o3';
+ 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('/api/run_node_stream', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
+ body: JSON.stringify({
+ node_id: 'quick_chat_temp',
+ incoming_contexts: [{ messages: messagesBeforeSend }],
+ user_prompt: userInput || 'Please analyze the attached files.',
+ attached_file_ids: attachedFilesCopy,
+ scopes,
+ merge_strategy: 'smart',
+ config: {
+ provider: isOpenAI ? 'openai' : 'google',
+ model_name: modelAtSend,
+ temperature: isReasoning ? 1 : tempAtSend,
+ enable_google_search: webSearchAtSend,
+ reasoning_effort: effortAtSend,
+ }
+ })
+ });
+
+ if (!response.body) throw new Error('No response body');
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let fullResponse = '';
+
+ // Stream response
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ const chunk = decoder.decode(value);
+ fullResponse += chunk;
+
+ // Update display in real-time
+ setQuickChatMessages(prev => {
+ const newMsgs = [...prev];
+ const lastMsg = newMsgs[newMsgs.length - 1];
+ if (lastMsg?.role === 'assistant') {
+ // Update existing assistant message
+ return [...newMsgs.slice(0, -1), { ...lastMsg, content: fullResponse }];
+ } else {
+ // Add new assistant message
+ return [...newMsgs, { id: `qc_${Date.now()}_a`, role: 'assistant', content: fullResponse }];
+ }
+ });
+ }
+
+ // Determine whether to overwrite current node or create new one
+ // 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() !== '';
+
+ if (!fromNodeHasResponse && fromNode) {
+ // Overwrite the source node (it's empty)
+ updateNodeData(fromNodeId, {
+ userPrompt: userInput,
+ response: fullResponse,
+ model: modelAtSend,
+ temperature: isReasoning ? 1 : tempAtSend,
+ reasoningEffort: effortAtSend,
+ enableGoogleSearch: webSearchAtSend,
+ attachedFileIds: attachedFilesCopy,
+ status: 'success',
+ querySentAt: Date.now(),
+ responseReceivedAt: Date.now()
+ });
+
+ // Update trace to reflect current node now has content
+ setQuickChatTrace(prev => prev ? {
+ ...prev,
+ 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 {
+ // Create new node (source node has response, continue the chain)
+ const newNodeId = `node_${Date.now()}`;
+ const sourceNode = fromNode || selectedNode;
+ const newPos = {
+ x: sourceNode.position.x + 300,
+ y: sourceNode.position.y
+ };
+
+ const newNode = {
+ id: newNodeId,
+ type: 'llmNode',
+ position: newPos,
+ data: {
+ label: 'Quick Chat',
+ model: modelAtSend,
+ temperature: isReasoning ? 1 : tempAtSend,
+ systemPrompt: '',
+ userPrompt: userInput,
+ mergeStrategy: 'smart' as const,
+ reasoningEffort: effortAtSend,
+ enableGoogleSearch: webSearchAtSend,
+ traces: [],
+ outgoingTraces: [],
+ forkedTraces: [],
+ mergedTraces: [],
+ activeTraceIds: [],
+ attachedFileIds: attachedFilesCopy,
+ response: fullResponse,
+ status: 'success' as const,
+ inputs: 1,
+ querySentAt: Date.now(),
+ responseReceivedAt: Date.now()
+ }
+ };
+
+ addNode(newNode);
+
+ // Connect to the source node
+ setTimeout(() => {
+ const store = useFlowStore.getState();
+ const currentEdges = store.edges;
+ const sourceNodeData = store.nodes.find(n => n.id === fromNodeId);
+
+ // Find the right trace handle to use
+ let sourceHandle = 'new-trace';
+
+ // Get the base trace ID (e.g., 'trace-A' from 'trace-A_B_C' or 'new-trace-A' or 'merged-xxx')
+ const currentTraceId = quickChatTrace?.id || '';
+ const isNewTrace = currentTraceId.startsWith('new-trace-');
+ const isMergedTrace = currentTraceId.startsWith('merged-');
+
+ if (isMergedTrace) {
+ // For merged trace: find the merged trace handle on the source node
+ // The trace ID may have evolved (e.g., 'merged-xxx' -> 'merged-xxx_nodeA' -> 'merged-xxx_nodeA_nodeB')
+ // We need to find the version that ends with the current source node ID
+
+ // First try: exact match with evolved ID (merged-xxx_sourceNodeId)
+ const evolvedMergedId = `${currentTraceId}_${fromNodeId}`;
+ let mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(
+ t => t.id === evolvedMergedId
+ );
+
+ // Second try: find trace that starts with merged ID and ends with this node
+ if (!mergedOutgoing) {
+ mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(
+ t => t.id.startsWith(currentTraceId) && t.id.endsWith(`_${fromNodeId}`)
+ );
+ }
+
+ // Third try: find any trace that contains the merged ID
+ if (!mergedOutgoing) {
+ mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(
+ t => t.id.startsWith(currentTraceId) || t.id === currentTraceId
+ );
+ }
+
+ // Fourth try: find any merged trace
+ if (!mergedOutgoing) {
+ mergedOutgoing = sourceNodeData?.data.outgoingTraces?.find(
+ t => t.id.startsWith('merged-')
+ );
+ }
+
+ if (mergedOutgoing) {
+ sourceHandle = `trace-${mergedOutgoing.id}`;
+ } else {
+ // Last resort: use the merged trace ID directly
+ sourceHandle = `trace-${currentTraceId}`;
+ }
+ } else if (isNewTrace) {
+ // For "Start New Trace": create a fresh independent trace from the original node
+ // First, check if this is the original starting node or a continuation node
+ const originalStartNodeId = currentTraceId.replace('new-trace-', '');
+ const isOriginalNode = fromNodeId === originalStartNodeId;
+
+ if (isOriginalNode) {
+ // This is the first round - starting from original node
+ const hasOutgoingEdges = currentEdges.some(e => e.source === fromNodeId);
+ if (hasOutgoingEdges) {
+ // Original node already has downstream - create a new fork
+ sourceHandle = 'new-trace';
+ } else {
+ // No downstream yet - use self trace
+ const selfTrace = sourceNodeData?.data.outgoingTraces?.find(
+ t => t.id === `trace-${fromNodeId}`
+ );
+ if (selfTrace) {
+ sourceHandle = `trace-${selfTrace.id}`;
+ }
+ }
+ } else {
+ // 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 => {
+ return t.id.includes(originalStartNodeId);
+ });
+
+ if (matchingTrace) {
+ sourceHandle = `trace-${matchingTrace.id}`;
+ } else {
+ // Fallback 1: Check INCOMING traces (Connect to Continue Handle)
+ const incoming = sourceNodeData?.data.traces?.find(t =>
+ t.id.includes(originalStartNodeId)
+ );
+
+ if (incoming) {
+ // ID is preserved, so handle ID is just trace-{id}
+ sourceHandle = `trace-${incoming.id}`;
+ } else {
+ // Fallback 2: find any trace that ends with fromNodeId (unlikely if ID preserved)
+ const anyMatch = sourceNodeData?.data.outgoingTraces?.find(
+ t => t.id === `trace-${fromNodeId}`
+ );
+ if (anyMatch) {
+ sourceHandle = `trace-${anyMatch.id}`;
+ }
+ }
+ }
+ }
+ } else {
+ // 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 === baseTraceId; // Exact match now
+ });
+
+ if (matchingOutgoing) {
+ sourceHandle = `trace-${matchingOutgoing.id}`;
+ } else {
+ // 2. Try INCOMING traces (Connect to Continue Handle)
+ const matchingIncoming = sourceNodeData?.data.traces?.find(t => {
+ const tId = t.id.replace(/^trace-/, '');
+ return tId === baseTraceId; // Exact match now
+ });
+
+ if (matchingIncoming) {
+ // ID is preserved
+ sourceHandle = `trace-${matchingIncoming.id}`;
+ }
+ }
+ }
+
+ // If this is the first message and we need to duplicate (has downstream),
+ // onConnect will automatically handle the trace duplication
+ // because the sourceHandle already has an outgoing edge
+
+ store.onConnect({
+ source: fromNodeId,
+ sourceHandle,
+ target: newNodeId,
+ targetHandle: 'input-0'
+ });
+
+ // After first duplication, subsequent messages continue on the new trace
+ // Reset the duplicate flag since we're now on the new branch
+ setQuickChatNeedsDuplicate(false);
+
+ // Update trace for continued chat - use newNodeId as the new source
+ // Find the actual trace ID on the new node to ensure continuity
+ const newNode = store.nodes.find(n => n.id === newNodeId);
+ const currentId = quickChatTrace?.id || '';
+ const isMerged = currentId.startsWith('merged-');
+ const isCurrentNewTrace = currentId.startsWith('new-trace-');
+
+ let nextTraceId = currentId;
+
+ if (newNode && newNode.data.outgoingTraces) {
+ // Find the trace that continues the current conversation
+ // 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, 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)
+ );
+ if (match) nextTraceId = match.id;
+ } else {
+ // Regular trace: ID should be preserved
+ nextTraceId = currentId;
+ }
+ }
+
+ setQuickChatTrace(prev => prev ? {
+ ...prev,
+ id: nextTraceId,
+ sourceNodeId: newNodeId,
+ 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);
+ }
+
+ } catch (error) {
+ console.error('Quick chat error:', error);
+ setQuickChatMessages(prev => [...prev, {
+ id: `qc_err_${Date.now()}`,
+ role: 'assistant',
+ content: `Error: ${error}`
+ }]);
+ } finally {
+ setQuickChatLoading(false);
+ // Refocus the input after sending
+ setTimeout(() => {
+ quickChatInputRef.current?.focus();
+ }, 50);
+ }
+ };
return (
- <div className="w-96 border-l border-gray-200 h-screen flex flex-col bg-white shadow-xl z-10">
+ <div
+ className={`w-96 border-l h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${
+ isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'
+ }`}
+ onClick={onInteract}
+ >
{/* Header */}
- <div className="p-4 border-b border-gray-200 bg-gray-50">
- <input
- type="text"
- value={selectedNode.data.label}
- onChange={(e) => handleChange('label', e.target.value)}
- className="font-bold text-lg bg-transparent border-none focus:ring-0 focus:outline-none w-full"
- />
+ <div className={`p-4 border-b flex flex-col gap-2 ${
+ isDark ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-gray-50'
+ }`}>
+ <div className="flex justify-between items-center">
+ <input
+ type="text"
+ value={selectedNode.data.label}
+ onChange={(e) => handleChange('label', e.target.value)}
+ className={`font-bold text-lg bg-transparent border-none focus:ring-0 focus:outline-none w-full ${
+ isDark ? 'text-gray-200' : 'text-gray-900'
+ }`}
+ />
+ <button onClick={onToggle} className={`p-1 rounded shrink-0 ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}>
+ <ChevronRight size={16} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
+ </button>
+ </div>
<div className="flex items-center justify-between mt-1">
- <div className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded uppercase">
- {selectedNode.data.status}
+ <div className={`text-xs px-2 py-1 rounded uppercase ${
+ isDark ? 'bg-blue-900 text-blue-300' : 'bg-blue-100 text-blue-700'
+ }`}>
+ {selectedNode.data.status}
</div>
- <div className="text-xs text-gray-500">
+ <div className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
ID: {selectedNode.id}
</div>
</div>
</div>
{/* Tabs */}
- <div className="flex border-b border-gray-200">
+ <div className={`flex border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
<button
onClick={() => setActiveTab('interact')}
className={`flex-1 p-3 text-sm flex justify-center items-center gap-2 ${activeTab === 'interact' ? 'border-b-2 border-blue-500 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
@@ -146,30 +1402,134 @@ const Sidebar = () => {
<label className="block text-sm font-medium text-gray-700 mb-1">Model</label>
<select
value={selectedNode.data.model}
- onChange={(e) => handleChange('model', e.target.value)}
+ onChange={(e) => {
+ const newModel = e.target.value;
+ // Auto-set temperature to 1 for reasoning models
+ 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(newModel);
+
+ if (isReasoning) {
+ handleChange('temperature', 1);
+ }
+ handleChange('model', newModel);
+ }}
className="w-full border border-gray-300 rounded-md p-2 text-sm"
>
- <option value="gpt-4o">GPT-4o</option>
- <option value="gpt-4o-mini">GPT-4o Mini</option>
- <option value="gemini-1.5-pro">Gemini 1.5 Pro</option>
- <option value="gemini-1.5-flash">Gemini 1.5 Flash</option>
+ <optgroup label="Gemini">
+ <option value="gemini-2.5-flash">gemini-2.5-flash</option>
+ <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
+ <option value="gemini-3-pro-preview">gemini-3-pro-preview</option>
+ </optgroup>
+ <optgroup label="OpenAI (Standard)">
+ <option value="gpt-4.1">gpt-4.1</option>
+ <option value="gpt-4o">gpt-4o</option>
+ </optgroup>
+ <optgroup label="OpenAI (Reasoning)">
+ <option value="gpt-5">gpt-5</option>
+ <option value="gpt-5-chat-latest">gpt-5-chat-latest</option>
+ <option value="gpt-5-mini">gpt-5-mini</option>
+ <option value="gpt-5-nano">gpt-5-nano</option>
+ <option value="gpt-5-pro">gpt-5-pro</option>
+ <option value="gpt-5.1">gpt-5.1</option>
+ <option value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option>
+ <option value="o3">o3</option>
+ </optgroup>
</select>
</div>
- {/* Trace Selector */}
- {selectedNode.data.traces && selectedNode.data.traces.length > 0 && (
- <div className="bg-gray-50 p-2 rounded border border-gray-200">
- <label className="block text-xs font-bold text-gray-500 mb-2 uppercase">Select Context Traces</label>
+ {/* Trace Selector - Single Select */}
+ <div className={`p-2 rounded border ${isDark ? 'bg-gray-900 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
+ <div className="flex items-center justify-between mb-2">
+ <label className={`block text-xs font-bold uppercase ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
+ Select Context
+ </label>
+ {/* Create Merged Trace Button - only show if 2+ traces */}
+ {selectedNode.data.traces && selectedNode.data.traces.length >= 2 && (
+ <button
+ onClick={openMergeModal}
+ className={`text-xs px-2 py-1 rounded flex items-center gap-1 ${
+ isDark
+ ? 'bg-purple-900 hover:bg-purple-800 text-purple-300'
+ : 'bg-purple-100 hover:bg-purple-200 text-purple-600'
+ }`}
+ >
+ <GitMerge size={12} />
+ Merge
+ </button>
+ )}
+ </div>
+
+ {/* New Trace option */}
+ <div className={`flex items-center gap-2 text-sm p-1 rounded group mb-1 border-b pb-2 ${
+ isDark ? 'hover:bg-gray-800 border-gray-700' : 'hover:bg-white border-gray-200'
+ }`}>
+ <div className="flex items-center gap-2 flex-1">
+ <div className="w-2 h-2 rounded-full bg-gray-400"></div>
+ <span className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>Start New Trace</span>
+ </div>
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ openQuickChat(null, true);
+ }}
+ className={`p-1 rounded transition-all ${
+ isDark ? 'hover:bg-blue-900 text-gray-500 hover:text-blue-400' : 'hover:bg-blue-100 text-gray-400 hover:text-blue-600'
+ }`}
+ title="Start New Trace Quick Chat"
+ >
+ <MessageCircle size={14} />
+ </button>
+ </div>
+
+ {/* All Available Traces - Incoming + Outgoing that this node originated */}
+ {(() => {
+ // 1. Incoming traces (context from upstream)
+ const incomingTraces = selectedNode.data.traces || [];
+
+ // 2. Outgoing traces that this node ORIGINATED (not pass-through, not merged)
+ // This includes self-started traces, forked traces, and prepend traces
+ const outgoingTraces = (selectedNode.data.outgoingTraces || []) as Trace[];
+ const originatedTraces = outgoingTraces.filter(t => {
+ // Exclude merged traces - they have their own display section
+ if (t.id.startsWith('merged-')) return false;
+
+ // Include if this node is the source (originated here)
+ // OR if the trace ID matches a forked/prepend trace pattern from this node
+ const isOriginated = t.sourceNodeId === selectedNode.id;
+ const isForkedHere = t.id.includes(`fork-${selectedNode.id}`);
+ const isSelfTrace = t.id === `trace-${selectedNode.id}`;
+ return isOriginated || isForkedHere || isSelfTrace;
+ });
+
+ // Combine and deduplicate by ID
+ // Priority: incoming traces (have full context) > originated outgoing traces
+ const allTracesMap = new Map<string, Trace>();
+ // Add originated outgoing traces first
+ originatedTraces.forEach(t => allTracesMap.set(t.id, t));
+ // Then incoming traces (will overwrite if same ID, as they have fuller context)
+ incomingTraces.forEach(t => allTracesMap.set(t.id, t));
+ const allTraces = Array.from(allTracesMap.values());
+
+ if (allTraces.length === 0) return null;
+
+ return (
<div className="space-y-1 max-h-[150px] overflow-y-auto">
- {selectedNode.data.traces.map((trace) => {
+ {allTraces.map((trace: Trace) => {
const isActive = selectedNode.data.activeTraceIds?.includes(trace.id);
+ const isComplete = canQuickChat(trace);
+
return (
- <div key={trace.id} className="flex items-start gap-2 text-sm p-1 hover:bg-white rounded cursor-pointer"
- onClick={() => {
- const current = selectedNode.data.activeTraceIds || [];
- const next = [trace.id]; // Single select mode
- handleChange('activeTraceIds', next);
- }}
+ <div
+ key={trace.id}
+ onClick={() => handleChange('activeTraceIds', [trace.id])}
+ className={`flex items-start gap-2 text-sm p-1.5 rounded group cursor-pointer transition-all ${
+ isActive
+ ? isDark ? 'bg-blue-900/50 border border-blue-700' : 'bg-blue-50 border border-blue-200'
+ : isDark ? 'hover:bg-gray-800' : 'hover:bg-white'
+ }`}
>
<input
type="radio"
@@ -177,72 +1537,431 @@ const Sidebar = () => {
readOnly
className="mt-1"
/>
+
<div className="flex-1">
- <div className="flex items-center gap-2">
- <div className="w-2 h-2 rounded-full" style={{ backgroundColor: trace.color }}></div>
- <span className="font-mono text-xs text-gray-400">#{trace.id.slice(-4)}</span>
- </div>
- <div className="text-xs text-gray-600 truncate">
- From Node: {trace.sourceNodeId}
- </div>
- <div className="text-[10px] text-gray-400">
- {trace.messages.length} msgs
- </div>
+ <div className="flex items-center gap-2">
+ {trace.isMerged ? (
+ // Merged Trace Rendering (for propagated merged traces)
+ <div className="flex -space-x-1 shrink-0">
+ {(trace.mergedColors || [trace.color]).slice(0, 3).map((color, idx) => (
+ <div
+ key={idx}
+ className="w-2 h-2 rounded-full border-2"
+ style={{ backgroundColor: color, borderColor: isDark ? '#1f2937' : '#fff' }}
+ />
+ ))}
+ {(trace.mergedColors?.length || 0) > 3 && (
+ <div className={`w-2 h-2 rounded-full flex items-center justify-center text-[6px] ${
+ isDark ? 'bg-gray-700 text-gray-400' : 'bg-gray-200 text-gray-500'
+ }`}>
+ +
+ </div>
+ )}
+ </div>
+ ) : (
+ // Regular Trace Rendering
+ <div className="w-2 h-2 rounded-full" style={{ backgroundColor: trace.color }}></div>
+ )}
+
+ <span className={`font-mono text-xs ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+ {trace.isMerged ? 'Merged ' : ''}#{trace.id.slice(-4)}
+ </span>
+ {!isComplete && (
+ <span className="text-[9px] text-orange-500">(incomplete)</span>
+ )}
+ </div>
+ <div className={`text-[10px] ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+ {trace.messages.length} msgs
+ </div>
</div>
+
+ {/* Quick Chat Button */}
+ {(() => {
+ const hasDownstream = edges.some(e =>
+ e.source === selectedNode.id &&
+ e.sourceHandle?.startsWith('trace-')
+ );
+ const buttonLabel = hasDownstream ? "Duplicate & Quick Chat" : "Quick Chat";
+
+ return (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ openQuickChat(trace, false);
+ }}
+ disabled={!isComplete}
+ className={`opacity-0 group-hover:opacity-100 p-1 rounded transition-all ${
+ isComplete
+ ? hasDownstream
+ ? isDark ? 'hover:bg-orange-900 text-gray-500 hover:text-orange-400' : 'hover:bg-orange-100 text-gray-400 hover:text-orange-600'
+ : isDark ? 'hover:bg-blue-900 text-gray-500 hover:text-blue-400' : 'hover:bg-blue-100 text-gray-400 hover:text-blue-600'
+ : 'text-gray-500 cursor-not-allowed'
+ }`}
+ title={isComplete ? buttonLabel : "Trace incomplete - all nodes need Q&A"}
+ >
+ <MessageCircle size={14} />
+ </button>
+ );
+ })()}
</div>
);
})}
</div>
- </div>
- )}
+ );
+ })()}
+
+ {/* Merged Traces - also single selectable */}
+ {selectedNode.data.mergedTraces && selectedNode.data.mergedTraces.length > 0 && (
+ <div className={`mt-2 pt-2 border-t space-y-1 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+ <label className={`block text-[10px] font-bold uppercase mb-1 ${isDark ? 'text-purple-400' : 'text-purple-600'}`}>
+ Merged Traces
+ </label>
+ {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}
+ onClick={() => handleChange('activeTraceIds', [merged.id])}
+ className={`flex items-center gap-2 p-1.5 rounded text-xs cursor-pointer transition-all ${
+ isActive
+ ? isDark ? 'bg-purple-900/50 border border-purple-600' : 'bg-purple-50 border border-purple-300'
+ : isDark ? 'bg-gray-800 hover:bg-gray-700' : 'bg-white border border-gray-200 hover:bg-gray-50'
+ }`}
+ >
+ <input
+ type="radio"
+ checked={isActive || false}
+ readOnly
+ className="shrink-0"
+ />
+
+ {/* Alternating color indicator */}
+ <div className="flex -space-x-1 shrink-0">
+ {merged.colors.slice(0, 3).map((color, idx) => (
+ <div
+ key={idx}
+ className="w-3 h-3 rounded-full border-2"
+ style={{ backgroundColor: color, borderColor: isDark ? '#1f2937' : '#fff' }}
+ />
+ ))}
+ {merged.colors.length > 3 && (
+ <div className={`w-3 h-3 rounded-full flex items-center justify-center text-[8px] ${
+ isDark ? 'bg-gray-700 text-gray-400' : 'bg-gray-200 text-gray-500'
+ }`}>
+ +{merged.colors.length - 3}
+ </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>
+ {!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
+ </div>
+ </div>
+
+ {/* Quick Chat for Merged Trace */}
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ openMergedQuickChat(merged);
+ }}
+ disabled={!isComplete}
+ className={`p-1 rounded shrink-0 ${
+ isComplete
+ ? isDark ? 'hover:bg-purple-900 text-gray-500 hover:text-purple-400' : 'hover:bg-purple-100 text-gray-400 hover:text-purple-600'
+ : 'text-gray-500 cursor-not-allowed opacity-50'
+ }`}
+ title={isComplete ? "Quick Chat with merged context" : "Trace incomplete"}
+ >
+ <MessageCircle size={12} />
+ </button>
+
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ deleteMergedTrace(selectedNode.id, merged.id);
+ }}
+ className={`p-1 rounded shrink-0 ${
+ isDark ? 'hover:bg-red-900 text-gray-500 hover:text-red-400' : 'hover:bg-red-50 text-gray-400 hover:text-red-600'
+ }`}
+ title="Delete merged trace"
+ >
+ <Trash2 size={12} />
+ </button>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">User Prompt</label>
<textarea
value={selectedNode.data.userPrompt}
onChange={(e) => handleChange('userPrompt', e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (selectedNode.data.status !== 'loading' && activeTracesCheck.complete) {
+ handleRun();
+ }
+ }
+ // Shift+Enter allows normal newline
+ }}
className="w-full border border-gray-300 rounded-md p-2 text-sm min-h-[100px]"
- placeholder="Type your message here..."
+ placeholder="Type your message here... (Enter to run, Shift+Enter for newline)"
/>
</div>
+ {/* Warning for incomplete upstream traces */}
+ {!activeTracesCheck.complete && (
+ <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} 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>
+ )}
+
<button
onClick={handleRun}
- disabled={selectedNode.data.status === 'loading'}
- className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-blue-300 flex items-center justify-center gap-2"
+ disabled={selectedNode.data.status === 'loading' || !activeTracesCheck.complete}
+ className={`w-full py-2 px-4 rounded-md flex items-center justify-center gap-2 transition-colors ${
+ selectedNode.data.status === 'loading' || !activeTracesCheck.complete
+ ? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
+ : 'bg-blue-600 text-white hover:bg-blue-700'
+ }`}
>
{selectedNode.data.status === 'loading' ? <Loader2 className="animate-spin" size={16} /> : <Play size={16} />}
Run Node
</button>
<div className="mt-6">
- <label className="block text-sm font-medium text-gray-700 mb-2">Response</label>
- <div className="bg-gray-50 p-3 rounded-md border border-gray-200 min-h-[150px] text-sm prose prose-sm max-w-none">
- <ReactMarkdown>{selectedNode.data.response || streamBuffer}</ReactMarkdown>
+ <div className="flex items-center justify-between mb-2">
+ <label className="block text-sm font-medium text-gray-700">Response</label>
+ <div className="flex gap-1">
+ {selectedNode.data.response && (
+ <>
+ <button
+ onClick={() => setShowSummaryModal(true)}
+ disabled={isSummarizing}
+ className="p-1 hover:bg-gray-200 rounded text-gray-500 hover:text-gray-700 disabled:opacity-50"
+ title="Summarize"
+ >
+ {isSummarizing ? <Loader2 className="animate-spin" size={14} /> : <FileText size={14} />}
+ </button>
+ <button
+ onClick={() => setIsEditing(true)}
+ className="p-1 hover:bg-gray-200 rounded text-gray-500 hover:text-gray-700"
+ title="Edit Response"
+ >
+ <Edit3 size={14} />
+ </button>
+ <button
+ onClick={() => setIsModalOpen(true)}
+ className="p-1 hover:bg-gray-200 rounded text-gray-500 hover:text-gray-700"
+ title="Expand"
+ >
+ <Maximize2 size={14} />
+ </button>
+ </>
+ )}
+ </div>
</div>
+
+ {isEditing ? (
+ <div className="space-y-2">
+ <textarea
+ value={editedResponse}
+ onChange={(e) => setEditedResponse(e.target.value)}
+ className={`w-full border rounded-md p-2 text-sm min-h-[200px] font-mono focus:ring-2 focus:ring-blue-500 ${
+ isDark
+ ? 'bg-gray-800 border-gray-600 text-gray-200 placeholder-gray-500'
+ : 'bg-white border-blue-300 text-gray-900'
+ }`}
+ />
+ <div className="flex gap-2 justify-end">
+ <button
+ onClick={handleCancelEdit}
+ className={`px-3 py-1 text-sm rounded flex items-center gap-1 ${
+ isDark ? 'text-gray-400 hover:bg-gray-800' : 'text-gray-600 hover:bg-gray-100'
+ }`}
+ >
+ <X size={14} /> Cancel
+ </button>
+ <button
+ onClick={handleSaveEdit}
+ className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-1"
+ >
+ <Check size={14} /> Save
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div className={`p-3 rounded-md border min-h-[150px] text-sm prose prose-sm max-w-none ${
+ isDark
+ ? 'bg-gray-900 border-gray-700 prose-invert text-gray-200'
+ : 'bg-gray-50 border-gray-200 text-gray-900'
+ }`}>
+ <ReactMarkdown>{selectedNode.data.response || (streamingNodeId === selectedNode.id ? streamBuffer : '')}</ReactMarkdown>
+ </div>
+ )}
</div>
</div>
)}
{activeTab === 'settings' && (
<div className="space-y-4">
+ {/* Attachments Section */}
+ {(() => {
+ const isGemini = selectedNode.data.model.startsWith('gemini');
+ return (
+ <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>
+
+ {isGemini && (
+ <p className={`text-xs mb-2 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`}>
+ File attachments are not supported for Gemini models.
+ </p>
+ )}
+
+ <div className="flex gap-2 mb-3">
+ <button
+ onClick={() => settingsUploadRef.current?.click()}
+ disabled={isGemini}
+ className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 rounded text-xs font-medium transition-colors ${
+ isGemini
+ ? 'opacity-50 cursor-not-allowed bg-gray-400 text-gray-200'
+ : isDark ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-blue-600 hover:bg-blue-700 text-white'
+ }`}
+ title={isGemini ? 'Not supported for Gemini models' : 'Upload & Attach'}
+ >
+ <Upload size={14} />
+ Upload & Attach
+ </button>
+ <input
+ ref={settingsUploadRef}
+ type="file"
+ className="hidden"
+ onChange={handleUploadAndAttach}
+ />
+
+ <button
+ onClick={() => {
+ refreshFiles();
+ setShowAttachModal(true);
+ }}
+ disabled={isGemini}
+ className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 px-3 rounded text-xs font-medium border transition-colors ${
+ isGemini
+ ? 'opacity-50 cursor-not-allowed border-gray-400 text-gray-400'
+ : isDark
+ ? 'border-gray-600 hover:bg-gray-700 text-gray-200'
+ : 'border-gray-300 hover:bg-gray-100 text-gray-700'
+ }`}
+ title={isGemini ? 'Not supported for Gemini models' : 'Attach Existing File'}
+ >
+ <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 text-gray-700 mb-1">Merge Strategy</label>
+ <label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>Merge Strategy</label>
<select
value={selectedNode.data.mergeStrategy || 'smart'}
onChange={(e) => handleChange('mergeStrategy', e.target.value)}
- className="w-full border border-gray-300 rounded-md p-2 text-sm"
+ className={`w-full border rounded-md p-2 text-sm ${
+ isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300 bg-white text-gray-900'
+ }`}
>
<option value="smart">Smart (Auto-merge roles)</option>
<option value="raw">Raw (Concatenate)</option>
</select>
- <p className="text-xs text-gray-500 mt-1">
+ <p className={`text-xs mt-1 ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
Smart merge combines consecutive messages from the same role to avoid API errors.
</p>
</div>
<div>
- <label className="block text-sm font-medium text-gray-700 mb-1">Temperature ({selectedNode.data.temperature})</label>
+ <label className={`block text-sm font-medium mb-1 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
+ Temperature ({selectedNode.data.temperature})
+ {[
+ 'gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano',
+ 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'o3'
+ ].includes(selectedNode.data.model) && (
+ <span className="text-xs text-orange-500 ml-2">(Locked for Reasoning Model)</span>
+ )}
+ </label>
<input
type="range"
min="0"
@@ -250,9 +1969,37 @@ const Sidebar = () => {
step="0.1"
value={selectedNode.data.temperature}
onChange={(e) => handleChange('temperature', parseFloat(e.target.value))}
- className="w-full"
+ disabled={[
+ 'gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano',
+ 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'o3'
+ ].includes(selectedNode.data.model)}
+ className="w-full disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
+
+ {/* Reasoning Effort - Only for OpenAI reasoning models (except chat-latest) */}
+ {[
+ 'gpt-5', 'gpt-5-mini', 'gpt-5-nano',
+ 'gpt-5-pro', 'gpt-5.1', 'o3'
+ ].includes(selectedNode.data.model) && (
+ <div>
+ <label className="block text-sm font-medium text-gray-700 mb-1">
+ Reasoning Effort
+ </label>
+ <select
+ value={selectedNode.data.reasoningEffort || 'medium'}
+ onChange={(e) => handleChange('reasoningEffort', e.target.value)}
+ className="w-full border border-gray-300 rounded-md p-2 text-sm"
+ >
+ <option value="low">Low (Faster, less thorough)</option>
+ <option value="medium">Medium (Balanced)</option>
+ <option value="high">High (Slower, more thorough)</option>
+ </select>
+ <p className="text-xs text-gray-500 mt-1">
+ Controls how much reasoning the model performs before responding. Higher = more tokens used.
+ </p>
+ </div>
+ )}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Key (Optional)</label>
@@ -274,47 +2021,865 @@ const Sidebar = () => {
placeholder="Global system prompt will be used if empty..."
/>
</div>
+
+ {(selectedNode.data.model.startsWith('gemini') ||
+ selectedNode.data.model.startsWith('gpt-5') ||
+ ['o3', 'o4-mini', 'gpt-4o'].includes(selectedNode.data.model)) && (
+ <div className="flex items-center gap-2 mt-4">
+ <input
+ type="checkbox"
+ id="web-search"
+ checked={selectedNode.data.enableGoogleSearch !== false} // Default to true
+ onChange={(e) => handleChange('enableGoogleSearch', e.target.checked)}
+ />
+ <label htmlFor="web-search" className="text-sm font-medium text-gray-700 select-none cursor-pointer">
+ Enable Web Search
+ </label>
+ </div>
+ )}
</div>
)}
{activeTab === 'debug' && (
<div className="space-y-4">
+ {/* Timestamps */}
+ <div className="bg-gray-50 p-3 rounded border border-gray-200">
+ <label className="block text-xs font-bold text-gray-500 mb-2 uppercase">Timestamps</label>
+ <div className="grid grid-cols-2 gap-2 text-xs">
+ <div>
+ <span className="text-gray-500">Query Sent:</span>
+ <div className="font-mono text-gray-700">
+ {selectedNode.data.querySentAt
+ ? new Date(selectedNode.data.querySentAt).toLocaleString()
+ : '-'}
+ </div>
+ </div>
+ <div>
+ <span className="text-gray-500">Response Received:</span>
+ <div className="font-mono text-gray-700">
+ {selectedNode.data.responseReceivedAt
+ ? new Date(selectedNode.data.responseReceivedAt).toLocaleString()
+ : '-'}
+ </div>
+ </div>
+ </div>
+ {selectedNode.data.querySentAt && selectedNode.data.responseReceivedAt && (
+ <div className="mt-2 text-xs text-gray-500">
+ Duration: {((selectedNode.data.responseReceivedAt - selectedNode.data.querySentAt) / 1000).toFixed(2)}s
+ </div>
+ )}
+ </div>
+
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Active Context (Sent to LLM)</label>
- <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto">
+ <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto max-h-[200px]">
{JSON.stringify(getActiveContext(selectedNode.id), null, 2)}
</pre>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Node Traces (Incoming)</label>
- <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto">
+ <pre className="bg-gray-900 text-gray-100 p-2 rounded text-xs overflow-x-auto max-h-[200px]">
{JSON.stringify(selectedNode.data.traces, null, 2)}
</pre>
</div>
</div>
)}
</div>
+
+ {/* Response Modal */}
+ {isModalOpen && selectedNode && (
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setIsModalOpen(false)}>
+ <div
+ className="bg-white rounded-lg shadow-2xl w-[80vw] max-w-4xl max-h-[80vh] flex flex-col"
+ onClick={(e) => e.stopPropagation()}
+ >
+ {/* Modal Header */}
+ <div className="flex items-center justify-between p-4 border-b border-gray-200">
+ <h3 className="font-semibold text-lg">{selectedNode.data.label} - Response</h3>
+ <div className="flex gap-2">
+ {!isEditing && (
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded flex items-center gap-1"
+ >
+ <Edit3 size={14} /> Edit
+ </button>
+ )}
+ <button
+ onClick={() => { setIsModalOpen(false); setIsEditing(false); }}
+ className="p-1 hover:bg-gray-200 rounded text-gray-500"
+ >
+ <X size={18} />
+ </button>
+ </div>
+ </div>
+
+ {/* Modal Content */}
+ <div className="flex-1 overflow-y-auto p-6">
+ {isEditing ? (
+ <textarea
+ value={editedResponse}
+ onChange={(e) => setEditedResponse(e.target.value)}
+ className="w-full h-full min-h-[400px] border border-gray-300 rounded-md p-3 text-sm font-mono focus:ring-2 focus:ring-blue-500 resize-y"
+ />
+ ) : (
+ <div className="prose prose-sm max-w-none">
+ <ReactMarkdown>{selectedNode.data.response}</ReactMarkdown>
+ </div>
+ )}
+ </div>
+
+ {/* Modal Footer (only when editing) */}
+ {isEditing && (
+ <div className="flex justify-end gap-2 p-4 border-t border-gray-200">
+ <button
+ onClick={handleCancelEdit}
+ className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded flex items-center gap-1"
+ >
+ <X size={14} /> Cancel
+ </button>
+ <button
+ onClick={() => { handleSaveEdit(); setIsModalOpen(false); }}
+ className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-1"
+ >
+ <Check size={14} /> Save Changes
+ </button>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Summary Model Selection Modal */}
+ {showSummaryModal && (
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowSummaryModal(false)}>
+ <div
+ className="bg-white rounded-lg shadow-2xl w-80 p-4"
+ onClick={(e) => e.stopPropagation()}
+ >
+ <h3 className="font-semibold text-lg mb-4">Summarize Response</h3>
+
+ <div className="mb-4">
+ <label className="block text-sm font-medium text-gray-700 mb-2">Select Model</label>
+ <select
+ value={summaryModel}
+ onChange={(e) => setSummaryModel(e.target.value)}
+ className="w-full border border-gray-300 rounded-md p-2 text-sm"
+ >
+ <optgroup label="Fast (Recommended)">
+ <option value="gpt-5-nano">gpt-5-nano</option>
+ <option value="gpt-5-mini">gpt-5-mini</option>
+ <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
+ <option value="gemini-2.5-flash">gemini-2.5-flash</option>
+ </optgroup>
+ <optgroup label="Standard">
+ <option value="gpt-4o">gpt-4o</option>
+ <option value="gpt-5">gpt-5</option>
+ </optgroup>
+ </select>
+ </div>
+
+ <div className="flex justify-end gap-2">
+ <button
+ onClick={() => setShowSummaryModal(false)}
+ className="px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSummarize}
+ className="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-1"
+ >
+ <FileText size={14} /> Summarize
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Merge Traces Modal */}
+ {showMergeModal && selectedNode && (
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowMergeModal(false)}>
+ <div
+ className={`rounded-xl shadow-2xl w-[500px] max-h-[80vh] flex flex-col ${
+ isDark ? 'bg-gray-800' : 'bg-white'
+ }`}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {/* Header */}
+ <div className={`flex items-center justify-between p-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+ <div className="flex items-center gap-2">
+ <GitMerge size={20} className={isDark ? 'text-purple-400' : 'text-purple-600'} />
+ <h3 className={`font-semibold text-lg ${isDark ? 'text-gray-100' : 'text-gray-900'}`}>
+ Create Merged Trace
+ </h3>
+ </div>
+ <button
+ onClick={() => setShowMergeModal(false)}
+ className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+ >
+ <X size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
+ </button>
+ </div>
+
+ {/* Trace Selection - draggable */}
+ <div className={`p-4 flex-1 overflow-y-auto ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
+ <p className={`text-xs mb-3 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
+ Select traces to merge. Drag to reorder for "Trace Order" strategy.
+ </p>
+
+ <div className="space-y-1">
+ {mergeOrder
+ .map(traceId => selectedNode.data.traces?.find((t: Trace) => t.id === traceId))
+ .filter((trace): trace is Trace => trace !== undefined)
+ .map((trace) => {
+ const isSelected = mergeSelectedIds.includes(trace.id);
+ const isDragging = mergeDraggedId === trace.id;
+ const traceColors = trace.isMerged && trace.mergedColors && trace.mergedColors.length > 0
+ ? trace.mergedColors
+ : [trace.color];
+
+ return (
+ <div
+ key={trace.id}
+ draggable
+ onDragStart={(e) => handleMergeDragStart(e, trace.id)}
+ onDragOver={(e) => handleMergeDragOver(e, trace.id)}
+ onDragEnd={handleMergeDragEnd}
+ className={`flex items-center gap-3 p-2 rounded cursor-move transition-all ${
+ isDragging ? 'opacity-50' : ''
+ } ${isSelected
+ ? isDark ? 'bg-purple-900/50 border border-purple-600' : 'bg-purple-50 border border-purple-300'
+ : isDark ? 'bg-gray-800 hover:bg-gray-700' : 'bg-white border border-gray-200 hover:bg-gray-50'
+ }`}
+ >
+ <GripVertical size={16} className={isDark ? 'text-gray-600' : 'text-gray-300'} />
+
+ <input
+ type="checkbox"
+ checked={isSelected}
+ onChange={() => toggleMergeSelection(trace.id)}
+ />
+
+ <div className="flex -space-x-1">
+ {traceColors.slice(0, 3).map((c, idx) => (
+ <div
+ key={idx}
+ className="w-3 h-3 rounded-full border-2"
+ style={{ backgroundColor: c, borderColor: isDark ? '#1f2937' : '#fff' }}
+ />
+ ))}
+ {traceColors.length > 3 && (
+ <div className={`w-3 h-3 rounded-full flex items-center justify-center text-[8px] ${
+ isDark ? 'bg-gray-700 text-gray-400' : 'bg-gray-200 text-gray-500'
+ }`}>
+ +{traceColors.length - 3}
+ </div>
+ )}
+ </div>
+
+ <div className="flex-1">
+ <span className={`font-mono text-sm ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
+ #{trace.id.slice(-6)}
+ </span>
+ <span className={`ml-2 text-xs ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+ {trace.messages.length} msgs
+ </span>
+ </div>
</div>
);
-};
+ })}
+ </div>
+
+ {mergeSelectedIds.length < 2 && (
+ <p className={`text-xs mt-3 text-center ${isDark ? 'text-orange-400' : 'text-orange-500'}`}>
+ Select at least 2 traces to merge
+ </p>
+ )}
+ </div>
+
+ {/* Settings */}
+ <div className={`p-4 border-t ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
+ <label className={`block text-xs font-bold mb-2 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
+ Merge Strategy
+ </label>
+ <select
+ value={mergeStrategy}
+ onChange={(e) => setMergeStrategy(e.target.value as MergeStrategy)}
+ className={`w-full border rounded-md p-2 text-sm mb-3 ${
+ isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300'
+ }`}
+ >
+ <option value="query_time">By Query Time</option>
+ <option value="response_time">By Response Time</option>
+ <option value="trace_order">By Trace Order (drag to reorder)</option>
+ <option value="grouped">Grouped (each trace in full)</option>
+ <option value="interleaved">Interleaved (true timeline)</option>
+ <option value="summary">Summary (LLM compressed)</option>
+ </select>
+
+ {/* Preview */}
+ {mergeSelectedIds.length >= 2 && (
+ <>
+ <button
+ onClick={() => setShowMergePreview(!showMergePreview)}
+ className={`w-full text-xs py-1.5 rounded mb-2 ${
+ isDark ? 'bg-gray-700 hover:bg-gray-600 text-gray-300' : 'bg-gray-100 hover:bg-gray-200 text-gray-600'
+ }`}
+ >
+ {showMergePreview ? 'Hide Preview' : 'Show Preview'} ({getMergePreview().length} messages)
+ </button>
+
+ {showMergePreview && (
+ <div className={`max-h-[100px] overflow-y-auto mb-3 p-2 rounded text-xs ${
+ isDark ? 'bg-gray-700' : 'bg-white border border-gray-200'
+ }`}>
+ {getMergePreview().map((msg, idx) => (
+ <div key={idx} className={`mb-1 ${msg.role === 'user' ? 'text-blue-400' : isDark ? 'text-gray-300' : 'text-gray-600'}`}>
+ <span className="font-bold">{msg.role}:</span> {msg.content.slice(0, 40)}...
+ </div>
+ ))}
+ </div>
+ )}
+ </>
+ )}
+
+ <button
+ onClick={handleCreateMergedTrace}
+ disabled={mergeSelectedIds.length < 2 || isSummarizingMerge}
+ className={`w-full py-2.5 rounded-md text-sm font-medium flex items-center justify-center gap-2 ${
+ mergeSelectedIds.length >= 2
+ ? isDark
+ ? 'bg-purple-600 hover:bg-purple-500 text-white disabled:bg-purple-900'
+ : 'bg-purple-600 hover:bg-purple-700 text-white disabled:bg-purple-300'
+ : isDark ? 'bg-gray-700 text-gray-500' : 'bg-gray-200 text-gray-400'
+ }`}
+ >
+ {isSummarizingMerge ? (
+ <>
+ <Loader2 className="animate-spin" size={16} />
+ Summarizing...
+ </>
+ ) : (
+ <>
+ <GitMerge size={16} />
+ Create Merged Trace
+ </>
+ )}
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Quick Chat Modal */}
+ {quickChatOpen && quickChatTrace && (
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => { onInteract?.(); closeQuickChat(); }}>
+ <div
+ className={`rounded-xl shadow-2xl w-[85vw] max-w-4xl h-[85vh] flex flex-col ${
+ isDark ? 'bg-gray-800' : 'bg-white'
+ }`}
+ onClick={(e) => { e.stopPropagation(); onInteract?.(); }}
+ >
+ {/* Header */}
+ <div className={`flex items-center justify-between p-4 border-b ${
+ isDark ? 'border-gray-700' : 'border-gray-200'
+ }`}>
+ <div className="flex items-center gap-3">
+ <div className="w-3 h-3 rounded-full" style={{ backgroundColor: quickChatTrace.color }}></div>
+ <h3 className={`font-semibold text-lg ${isDark ? 'text-gray-100' : 'text-gray-900'}`}>
+ {quickChatNeedsDuplicate ? 'Duplicate & Quick Chat' : 'Quick Chat'}
+ </h3>
+ {quickChatNeedsDuplicate && (
+ <span className={`text-xs px-2 py-0.5 rounded-full ${
+ isDark ? 'bg-orange-900/50 text-orange-300' : 'bg-orange-100 text-orange-600'
+ }`}>
+ Will create new branch
+ </span>
+ )}
+ <span className={`text-xs font-mono ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>#{quickChatTrace.id.slice(-8)}</span>
+ </div>
+ <div className="flex items-center gap-3">
+ {/* Model Selector */}
+ <select
+ value={quickChatModel}
+ onChange={(e) => setQuickChatModel(e.target.value)}
+ className={`border rounded-md px-3 py-1.5 text-sm ${
+ isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300 text-gray-900'
+ }`}
+ >
+ <optgroup label="Gemini">
+ <option value="gemini-2.5-flash">gemini-2.5-flash</option>
+ <option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</option>
+ <option value="gemini-3-pro-preview">gemini-3-pro-preview</option>
+ </optgroup>
+ <optgroup label="OpenAI (Standard)">
+ <option value="gpt-4.1">gpt-4.1</option>
+ <option value="gpt-4o">gpt-4o</option>
+ </optgroup>
+ <optgroup label="OpenAI (Reasoning)">
+ <option value="gpt-5">gpt-5</option>
+ <option value="gpt-5-chat-latest">gpt-5-chat-latest</option>
+ <option value="gpt-5-mini">gpt-5-mini</option>
+ <option value="gpt-5-nano">gpt-5-nano</option>
+ <option value="gpt-5-pro">gpt-5-pro</option>
+ <option value="gpt-5.1">gpt-5.1</option>
+ <option value="gpt-5.1-chat-latest">gpt-5.1-chat-latest</option>
+ <option value="o3">o3</option>
+ </optgroup>
+ </select>
+ <button onClick={closeQuickChat} className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}>
+ <X size={20} className={isDark ? 'text-gray-400' : 'text-gray-500'} />
+ </button>
+ </div>
+ </div>
-// Helper component for icon
-const Loader2 = ({ className, size }: { className?: string, size?: number }) => (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- width={size || 24}
- height={size || 24}
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- className={className}
- >
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
- </svg>
-);
+ {/* Chat Messages */}
+ <div className={`flex-1 overflow-y-auto p-4 space-y-4 ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
+ {quickChatMessages.length === 0 ? (
+ <div className={`text-center py-8 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+ <MessageCircle size={48} className="mx-auto mb-2 opacity-50" />
+ <p>Start a conversation with this trace's context</p>
+ </div>
+ ) : (
+ 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'}`}
+ >
+ <div className="flex items-start gap-2 max-w-[80%]">
+ {/* Source trace indicator for merged traces */}
+ {msg.sourceTraceColor && msg.role !== 'user' && (
+ <div
+ className="w-2 h-2 rounded-full mt-3 shrink-0"
+ style={{ backgroundColor: msg.sourceTraceColor }}
+ title={`From trace: ${msg.sourceTraceId?.slice(-6) || 'unknown'}`}
+ />
+ )}
+
+ <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>
+ )}
+
+ <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) */}
+ {msg.sourceTraceColor && msg.role === 'user' && (
+ <div
+ className="w-2 h-2 rounded-full mt-3 shrink-0"
+ style={{ backgroundColor: msg.sourceTraceColor }}
+ title={`From trace: ${msg.sourceTraceId?.slice(-6) || 'unknown'}`}
+ />
+ )}
+ </div>
+ </div>
+ )})
+ )}
+ {quickChatLoading && (
+ <div className="flex justify-start">
+ <div className={`rounded-lg px-4 py-3 shadow-sm ${
+ isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white border border-gray-200'
+ }`}>
+ <Loader2 className="animate-spin text-blue-500" size={20} />
+ </div>
+ </div>
+ )}
+ <div ref={quickChatEndRef} />
+ </div>
+
+ {/* Settings Row */}
+ <div className={`px-4 py-2 border-t flex items-center gap-4 text-xs ${
+ isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-100 bg-white'
+ }`}>
+ {/* Temperature (hide for reasoning models) */}
+ {!['gpt-5', 'gpt-5-chat-latest', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'gpt-5.1-chat-latest', 'o3'].includes(quickChatModel) && (
+ <div className="flex items-center gap-2">
+ <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Temp:</span>
+ <input
+ type="range"
+ min="0"
+ max="2"
+ step="0.1"
+ value={quickChatTemp}
+ onChange={(e) => setQuickChatTemp(parseFloat(e.target.value))}
+ className="w-20"
+ />
+ <span className={`w-8 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>{quickChatTemp}</span>
+ </div>
+ )}
+
+ {/* Reasoning Effort (only for reasoning models, except chat-latest) */}
+ {['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro', 'gpt-5.1', 'o3'].includes(quickChatModel) && (
+ <div className="flex items-center gap-2">
+ <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Effort:</span>
+ <select
+ value={quickChatEffort}
+ onChange={(e) => setQuickChatEffort(e.target.value as 'low' | 'medium' | 'high')}
+ className={`border rounded px-2 py-0.5 text-xs ${
+ isDark ? 'bg-gray-700 border-gray-600 text-gray-200' : 'border-gray-300'
+ }`}
+ >
+ <option value="low">Low</option>
+ <option value="medium">Medium</option>
+ <option value="high">High</option>
+ </select>
+ </div>
+ )}
+
+ {/* Web Search */}
+ {(quickChatModel.startsWith('gemini') || quickChatModel.startsWith('gpt-5') || ['o3', 'gpt-4o'].includes(quickChatModel)) && (
+ <label className="flex items-center gap-1 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={quickChatWebSearch}
+ onChange={(e) => setQuickChatWebSearch(e.target.checked)}
+ className="form-checkbox h-3 w-3"
+ />
+ <span className={isDark ? 'text-gray-400' : 'text-gray-500'}>Web Search</span>
+ </label>
+ )}
+
+ {/* File Attachment Buttons - Hidden for Gemini */}
+ {!quickChatModel.startsWith('gemini') && (
+ <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}
+ value={quickChatInput}
+ onChange={(e) => setQuickChatInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ // 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)"
+ : 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'
+ }`}
+ autoFocus
+ />
+ <button
+ onClick={handleQuickChatSend}
+ 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} />}
+ </button>
+ </div>
+ <p className={`text-[10px] mt-2 ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+ Each message creates a new node on the canvas, automatically connected to this trace.
+ </p>
+ </div>
+ </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>
+ );
+};
export default Sidebar;