summaryrefslogtreecommitdiff
path: root/frontend/src/App.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/App.tsx')
-rw-r--r--frontend/src/App.tsx183
1 files changed, 158 insertions, 25 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9ec1340..5776091 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,21 +6,27 @@ import ReactFlow, {
ReactFlowProvider,
Panel,
useReactFlow,
+ SelectionMode,
type Node,
type Edge
} from 'reactflow';
import 'reactflow/dist/style.css';
import useFlowStore from './store/flowStore';
import LLMNode from './components/nodes/LLMNode';
+import MergedEdge from './components/edges/MergedEdge';
import Sidebar from './components/Sidebar';
import LeftSidebar from './components/LeftSidebar';
import { ContextMenu } from './components/ContextMenu';
-import { Plus } from 'lucide-react';
+import { Plus, Sun, Moon, LayoutGrid } from 'lucide-react';
const nodeTypes = {
llmNode: LLMNode,
};
+const edgeTypes = {
+ merged: MergedEdge,
+};
+
function Flow() {
const {
nodes,
@@ -32,24 +38,45 @@ function Flow() {
deleteEdge,
deleteNode,
deleteBranch,
+ deleteTrace,
setSelectedNode,
toggleNodeDisabled,
archiveNode,
createNodeFromArchive,
- toggleTraceDisabled
+ toggleTraceDisabled,
+ theme,
+ toggleTheme,
+ autoLayout,
+ findNonOverlappingPosition
} = useFlowStore();
const reactFlowWrapper = useRef<HTMLDivElement>(null);
- const { project } = useReactFlow();
- const [menu, setMenu] = useState<{ x: number; y: number; type: 'pane' | 'node' | 'edge'; id?: string } | null>(null);
+ const { project, getViewport } = useReactFlow();
+ const [menu, setMenu] = useState<{ x: number; y: number; type: 'pane' | 'node' | 'edge' | 'multiselect'; id?: string; selectedIds?: string[] } | null>(null);
const [isLeftOpen, setIsLeftOpen] = useState(true);
const [isRightOpen, setIsRightOpen] = useState(true);
- const onPaneClick = () => {
+ // Get selected nodes
+ const selectedNodes = nodes.filter(n => n.selected);
+
+ const onPaneClick = useCallback(() => {
setSelectedNode(null);
setMenu(null);
- };
+ }, [setSelectedNode]);
+
+ // Close menu on various interactions
+ const closeMenu = useCallback(() => {
+ setMenu(null);
+ }, []);
+
+ const handleNodeDragStart = useCallback(() => {
+ setMenu(null);
+ }, []);
+
+ const handleMoveStart = useCallback(() => {
+ setMenu(null);
+ }, []);
const handlePaneContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
@@ -58,7 +85,33 @@ function Flow() {
const handleNodeContextMenu = (event: React.MouseEvent, node: Node) => {
event.preventDefault();
- setMenu({ x: event.clientX, y: event.clientY, type: 'node', id: node.id });
+ // Check if multiple nodes are selected and the right-clicked node is one of them
+ if (selectedNodes.length > 1 && selectedNodes.some(n => n.id === node.id)) {
+ setMenu({
+ x: event.clientX,
+ y: event.clientY,
+ type: 'multiselect',
+ selectedIds: selectedNodes.map(n => n.id)
+ });
+ } else {
+ setMenu({ x: event.clientX, y: event.clientY, type: 'node', id: node.id });
+ }
+ };
+
+ // Batch operations for multi-select
+ const handleBatchDelete = (nodeIds: string[]) => {
+ nodeIds.forEach(id => deleteNode(id));
+ setMenu(null);
+ };
+
+ const handleBatchDisable = (nodeIds: string[]) => {
+ nodeIds.forEach(id => toggleNodeDisabled(id));
+ setMenu(null);
+ };
+
+ const handleBatchArchive = (nodeIds: string[]) => {
+ nodeIds.forEach(id => archiveNode(id));
+ setMenu(null);
};
const handleEdgeContextMenu = (event: React.MouseEvent, edge: Edge) => {
@@ -68,7 +121,20 @@ function Flow() {
const handleAddNode = (position?: { x: number, y: number }) => {
const id = `node_${Date.now()}`;
- const pos = position || { x: Math.random() * 400, y: Math.random() * 400 };
+
+ // If no position provided, use viewport center
+ let basePos = position;
+ if (!basePos && reactFlowWrapper.current) {
+ const { x, y, zoom } = getViewport();
+ const rect = reactFlowWrapper.current.getBoundingClientRect();
+ // Calculate center of viewport in flow coordinates
+ basePos = {
+ x: (-x + rect.width / 2) / zoom - 100, // offset by half node width
+ y: (-y + rect.height / 2) / zoom - 40 // offset by half node height
+ };
+ }
+ basePos = basePos || { x: 200, y: 200 };
+ const pos = findNonOverlappingPosition(basePos.x, basePos.y);
addNode({
id,
@@ -76,7 +142,7 @@ function Flow() {
position: pos,
data: {
label: 'New Question',
- model: 'gpt-4o',
+ model: 'gpt-5.1',
temperature: 0.7,
systemPrompt: '',
userPrompt: '',
@@ -86,6 +152,7 @@ function Flow() {
traces: [],
outgoingTraces: [],
forkedTraces: [],
+ mergedTraces: [],
response: '',
status: 'idle',
inputs: 1
@@ -124,11 +191,11 @@ function Flow() {
};
return (
- <div style={{ width: '100vw', height: '100vh', display: 'flex' }}>
+ <div className={`w-screen h-screen flex ${theme === 'dark' ? 'dark bg-gray-900' : 'bg-white'}`}>
<LeftSidebar isOpen={isLeftOpen} onToggle={() => setIsLeftOpen(!isLeftOpen)} />
<div
- style={{ flex: 1, height: '100%', position: 'relative' }}
+ className={`flex-1 h-full relative ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}
ref={reactFlowWrapper}
onDragOver={onDragOver}
onDrop={onDrop}
@@ -140,23 +207,64 @@ function Flow() {
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
- onNodeClick={onNodeClick}
+ edgeTypes={edgeTypes}
+ defaultEdgeOptions={{ type: 'merged' }}
+ onNodeClick={(e, node) => { closeMenu(); onNodeClick(e, node); }}
onPaneClick={onPaneClick}
onPaneContextMenu={handlePaneContextMenu}
onNodeContextMenu={handleNodeContextMenu}
onEdgeContextMenu={handleEdgeContextMenu}
+ onNodeDragStart={handleNodeDragStart}
+ onMoveStart={handleMoveStart}
+ onSelectionStart={closeMenu}
fitView
+ panOnDrag
+ selectionOnDrag
+ selectionKeyCode="Shift"
+ multiSelectionKeyCode="Shift"
+ selectionMode={SelectionMode.Partial}
>
- <Background color="#aaa" gap={16} />
- <Controls />
- <MiniMap />
+ <Background color={theme === 'dark' ? '#374151' : '#aaa'} gap={16} />
+ <Controls className={theme === 'dark' ? 'dark-controls' : ''} />
+ <MiniMap
+ nodeColor={theme === 'dark' ? '#4b5563' : '#e5e7eb'}
+ maskColor={theme === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.6)'}
+ />
<Panel position="top-left">
- <button
- onClick={() => handleAddNode()}
- className="bg-white px-4 py-2 rounded-md shadow-md font-medium text-gray-700 hover:bg-gray-50 flex items-center gap-2"
- >
- <Plus size={16} /> Add Block
- </button>
+ <div className="flex gap-2">
+ <button
+ onClick={() => handleAddNode()}
+ className={`px-4 py-2 rounded-md shadow-md font-medium flex items-center gap-2 transition-colors ${
+ theme === 'dark'
+ ? 'bg-gray-800 text-gray-200 hover:bg-gray-700 border border-gray-600'
+ : 'bg-white text-gray-700 hover:bg-gray-50'
+ }`}
+ >
+ <Plus size={16} /> Add Node
+ </button>
+ <button
+ onClick={autoLayout}
+ className={`px-3 py-2 rounded-md shadow-md font-medium flex items-center gap-2 transition-colors ${
+ theme === 'dark'
+ ? 'bg-gray-800 text-gray-200 hover:bg-gray-700 border border-gray-600'
+ : 'bg-white text-gray-700 hover:bg-gray-50'
+ }`}
+ title="Auto Layout"
+ >
+ <LayoutGrid size={16} />
+ </button>
+ <button
+ onClick={toggleTheme}
+ className={`px-3 py-2 rounded-md shadow-md font-medium flex items-center gap-2 transition-colors ${
+ theme === 'dark'
+ ? 'bg-gray-800 text-gray-200 hover:bg-gray-700 border border-gray-600'
+ : 'bg-white text-gray-700 hover:bg-gray-50'
+ }`}
+ title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
+ >
+ {theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
+ </button>
+ </div>
</Panel>
</ReactFlow>
@@ -180,7 +288,27 @@ function Flow() {
}
}
}
- ] : menu.type === 'node' ? (() => {
+ ] : menu.type === 'multiselect' ? (() => {
+ // Multi-select menu - batch operations
+ const ids = menu.selectedIds || [];
+ const allDisabled = ids.every(id => nodes.find(n => n.id === id)?.data?.disabled);
+
+ return [
+ {
+ label: allDisabled ? `Enable ${ids.length} Nodes` : `Disable ${ids.length} Nodes`,
+ onClick: () => handleBatchDisable(ids)
+ },
+ {
+ label: `Archive ${ids.length} Nodes`,
+ onClick: () => handleBatchArchive(ids)
+ },
+ {
+ label: `Delete ${ids.length} Nodes`,
+ danger: true,
+ onClick: () => handleBatchDelete(ids)
+ }
+ ];
+ })() : menu.type === 'node' ? (() => {
const targetNode = nodes.find(n => n.id === menu.id);
const isDisabled = targetNode?.data?.disabled;
@@ -210,7 +338,7 @@ function Flow() {
onClick: () => menu.id && deleteBranch(menu.id)
}
];
- })() : (() => {
+ })() : menu.type === 'edge' ? (() => {
// Check if any node connected to this edge is disabled
const targetEdge = edges.find(e => e.id === menu.id);
const sourceNode = nodes.find(n => n.id === targetEdge?.source);
@@ -230,14 +358,19 @@ function Flow() {
label: 'Delete Branch',
danger: true,
onClick: () => menu.id && deleteBranch(undefined, menu.id)
+ },
+ {
+ label: 'Delete Trace',
+ danger: true,
+ onClick: () => menu.id && deleteTrace(menu.id)
}
];
- })()
+ })() : []
}
/>
)}
</div>
- <Sidebar isOpen={isRightOpen} onToggle={() => setIsRightOpen(!isRightOpen)} />
+ <Sidebar isOpen={isRightOpen} onToggle={() => setIsRightOpen(!isRightOpen)} onInteract={closeMenu} />
</div>
);
}