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.tsx366
1 files changed, 325 insertions, 41 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8c52751..cfbb141 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import ReactFlow, {
Background,
Controls,
@@ -6,20 +6,29 @@ import ReactFlow, {
ReactFlowProvider,
Panel,
useReactFlow,
+ SelectionMode,
type Node,
type Edge
} from 'reactflow';
import 'reactflow/dist/style.css';
import useFlowStore from './store/flowStore';
+import { useAuthStore } from './store/authStore';
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, Loader2 } from 'lucide-react';
+import AuthPage from './pages/AuthPage';
const nodeTypes = {
llmNode: LLMNode,
};
+const edgeTypes = {
+ merged: MergedEdge,
+};
+
function Flow() {
const {
nodes,
@@ -31,17 +40,48 @@ function Flow() {
deleteEdge,
deleteNode,
deleteBranch,
- setSelectedNode
+ deleteTrace,
+ setSelectedNode,
+ toggleNodeDisabled,
+ archiveNode,
+ createNodeFromArchive,
+ toggleTraceDisabled,
+ theme,
+ toggleTheme,
+ autoLayout,
+ findNonOverlappingPosition,
+ setLastViewport,
+ saveCurrentBlueprint,
+ currentBlueprintPath
} = 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);
+
+ // Get selected nodes
+ const selectedNodes = nodes.filter(n => n.selected);
- const onPaneClick = () => {
+ 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();
@@ -50,7 +90,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) => {
@@ -60,7 +126,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,
@@ -68,54 +147,148 @@ function Flow() {
position: pos,
data: {
label: 'New Question',
- model: 'gpt-4o',
+ model: 'gpt-5.1',
temperature: 0.7,
systemPrompt: '',
userPrompt: '',
mergeStrategy: 'smart',
+ reasoningEffort: 'medium', // Default for reasoning models
messages: [],
traces: [],
outgoingTraces: [],
forkedTraces: [],
+ mergedTraces: [],
response: '',
status: 'idle',
- inputs: 1
+ inputs: 1,
+ attachedFileIds: [],
+ activeTraceIds: []
},
});
setMenu(null);
};
const onNodeClick = (_: any, node: Node) => {
+ // Don't select disabled nodes
+ const nodeData = node.data as any;
+ if (nodeData?.disabled) return;
setSelectedNode(node.id);
};
+ const onDragOver = (event: React.DragEvent) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ };
+
+ const onDrop = (event: React.DragEvent) => {
+ event.preventDefault();
+
+ const archiveId = event.dataTransfer.getData('archiveId');
+ if (!archiveId) return;
+
+ const bounds = reactFlowWrapper.current?.getBoundingClientRect();
+ if (!bounds) return;
+
+ const position = project({
+ x: event.clientX - bounds.left,
+ y: event.clientY - bounds.top
+ });
+
+ createNodeFromArchive(archiveId, position);
+ };
+
+ // Ctrl/Cmd + S manual save
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ const isMac = navigator.platform.toLowerCase().includes('mac');
+ if ((isMac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ const path = currentBlueprintPath;
+ if (path) {
+ saveCurrentBlueprint(path, getViewport());
+ }
+ }
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [currentBlueprintPath, saveCurrentBlueprint, getViewport]);
+
return (
- <div style={{ width: '100vw', height: '100vh', display: 'flex' }}>
- <div style={{ flex: 1, height: '100%' }} ref={reactFlowWrapper}>
+ <div className={`w-screen h-screen flex ${theme === 'dark' ? 'dark bg-gray-900' : 'bg-white'}`}>
+ <LeftSidebar isOpen={isLeftOpen} onToggle={() => setIsLeftOpen(!isLeftOpen)} />
+
+ <div
+ className={`flex-1 h-full relative ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}
+ ref={reactFlowWrapper}
+ onDragOver={onDragOver}
+ onDrop={onDrop}
+ >
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
+ onMoveEnd={(_, viewport) => setLastViewport(viewport)}
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>
@@ -139,36 +312,147 @@ function Flow() {
}
}
}
- ] : menu.type === 'node' ? [
- {
- label: 'Delete Node (Cascade)',
- danger: true,
- onClick: () => menu.id && deleteBranch(menu.id)
- }
- ] : [
- {
- label: 'Disconnect',
- onClick: () => menu.id && deleteEdge(menu.id)
- },
- {
- label: 'Delete Branch',
- danger: true,
- onClick: () => menu.id && deleteBranch(undefined, menu.id)
+ ] : 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;
+
+ // If disabled, only show Enable option
+ if (isDisabled) {
+ return [
+ {
+ label: 'Enable Node',
+ onClick: () => menu.id && toggleNodeDisabled(menu.id)
+ }
+ ];
}
- ]
+
+ // Normal node menu
+ return [
+ {
+ label: 'Disable Node',
+ onClick: () => menu.id && toggleNodeDisabled(menu.id)
+ },
+ {
+ label: 'Add to Archive',
+ onClick: () => menu.id && archiveNode(menu.id)
+ },
+ {
+ label: 'Delete Node (Cascade)',
+ danger: true,
+ 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);
+ const targetNode = nodes.find(n => n.id === targetEdge?.target);
+ const isTraceDisabled = sourceNode?.data?.disabled || targetNode?.data?.disabled;
+
+ return [
+ {
+ label: isTraceDisabled ? 'Enable Trace' : 'Disable Trace',
+ onClick: () => menu.id && toggleTraceDisabled(menu.id)
+ },
+ {
+ label: 'Disconnect',
+ onClick: () => menu.id && deleteEdge(menu.id)
+ },
+ {
+ 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 />
+ <Sidebar isOpen={isRightOpen} onToggle={() => setIsRightOpen(!isRightOpen)} onInteract={closeMenu} />
</div>
);
}
-export default function App() {
+function AuthWrapper() {
+ const { isAuthenticated, checkAuth, user } = useAuthStore();
+ const { refreshProjectTree, refreshFiles, loadArchivedNodes, clearBlueprint } = useFlowStore();
+ const [checking, setChecking] = useState(true);
+ const [initializing, setInitializing] = useState(false);
+ const prevUserRef = useRef<string | null>(null);
+
+ useEffect(() => {
+ checkAuth().finally(() => setChecking(false));
+ }, [checkAuth]);
+
+ // When user changes (login/logout), refresh all user-specific data
+ useEffect(() => {
+ const currentUsername = user?.username || null;
+
+ // If user changed, reload everything
+ if (isAuthenticated && currentUsername && currentUsername !== prevUserRef.current) {
+ setInitializing(true);
+ prevUserRef.current = currentUsername;
+
+ // Clear old data and load new user's data
+ clearBlueprint();
+ Promise.all([
+ refreshProjectTree(),
+ refreshFiles(),
+ loadArchivedNodes()
+ ]).finally(() => setInitializing(false));
+ } else if (!isAuthenticated) {
+ prevUserRef.current = null;
+ }
+ }, [isAuthenticated, user, refreshProjectTree, refreshFiles, loadArchivedNodes, clearBlueprint]);
+
+ if (checking || initializing) {
+ return (
+ <div className="min-h-screen flex items-center justify-center bg-gray-900">
+ <div className="flex flex-col items-center gap-4">
+ <Loader2 className="animate-spin text-blue-500" size={48} />
+ <p className="text-gray-400">{initializing ? 'Loading workspace...' : 'Loading...'}</p>
+ </div>
+ </div>
+ );
+ }
+
+ if (!isAuthenticated) {
+ return <AuthPage />;
+ }
+
return (
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
);
}
+
+export default function App() {
+ return <AuthWrapper />;
+}