5 Commits

Author SHA1 Message Date
Devin AI
af58c71f40 fix: remove nonsensical $X% fee format, use percentage only
Fee displays combined dollar sign with percent sign (e.g. $0.02%)
which is invalid in any financial context. Changed to percentage
format (0.02%) across all three locations: simulation results,
canvas inspector, and right panel context display.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 18:03:05 +00:00
Devin AI
1a459c695b fix: per-tab undo/redo history, stale closure in node ops, zoom animation
1. Per-tab history: Changed reducer from single HistoryState to
   Record<string, HistoryState> keyed by tab ID. Undo/redo now
   only affects the active tab. Creating a new tab initializes
   its own history; closing a tab cleans up its history.

2. Stale closure fix: onDropComponent, onConnect, deleteSelectedNodes,
   and duplicateSelectedNodes now use setTransactionTabs directly to
   read both nodes and edges from the same state snapshot, preventing
   inconsistent history entries.

3. Zoom animation: handleZoomIn/handleZoomOut now use .then() on the
   zoomIn()/zoomOut() promises to read the zoom level after animation
   completes, preventing stale zoom display.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 17:55:19 +00:00
Devin AI
81506c63ec fix: remove skipHistoryRef that silently drops history entry after undo/redo
skipHistoryRef was set to true in undo/redo but never consumed during
those operations (setNodes/setEdges update transactionTabs directly
without calling pushHistory). The ref stayed true indefinitely, causing
the next user action's pushHistory call to silently skip recording,
breaking the undo chain.

Since pushHistory is never called during undo/redo operations, the
skip guard serves no purpose. Removed entirely.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 17:41:32 +00:00
Devin AI
ce481fd2c1 fix: undo/redo history index divergence when exceeding 50 entries
Fixes two bugs identified by Devin Review on PR #1:

1. History index goes out of bounds (50) when entries exceed the
   50-entry cap, causing the first undo to be a silent no-op.
   The shift() removed an entry but the index still incremented
   past the array bounds.

2. pushHistory uses stale historyIndex closure value inside
   setHistory's functional updater, causing entries to be silently
   dropped when multiple pushHistory calls are batched by React.

Fix: Combine history entries and index into a single useReducer
state so both are always updated atomically. Add 'reset' action
for new transaction tab creation.

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 17:33:26 +00:00
Devin AI
52676016fb feat: Solace Bank Group PLC Treasury Management Portal
- Web3 authentication with MetaMask, WalletConnect, Coinbase wallet options
- Demo mode for testing without wallet
- Overview dashboard with KPI cards, asset allocation, positions, accounts, alerts
- Transaction Builder module (full IDE-style drag-and-drop canvas with 28 gap fixes)
- Accounts module with multi-account/subaccount hierarchical structures
- Treasury Management module with positions table and 14-day cash forecast
- Financial Reporting module with IPSAS, US GAAP, IFRS compliance
- Compliance & Risk module with KYC/AML/Sanctions monitoring
- Settlement & Clearing module with DVP/FOP/PVP operations
- Settings with role-based permissions and enterprise controls
- Dark theme professional UI with Solace Bank branding
- HashRouter for static hosting compatibility

Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
2026-04-18 17:20:13 +00:00
3 changed files with 110 additions and 71 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef, useReducer } from 'react';
import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react'; import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react';
import TitleBar from './components/TitleBar'; import TitleBar from './components/TitleBar';
import ActivityBar from './components/ActivityBar'; import ActivityBar from './components/ActivityBar';
@@ -66,44 +66,75 @@ export default function App() {
})); }));
}, [activeTransactionId]); }, [activeTransactionId]);
// Undo/redo // Undo/redo — per-tab history to prevent cross-tab state corruption
const [history, setHistory] = useState<HistoryEntry[]>([{ nodes: [], edges: [] }]); type HistoryState = { entries: HistoryEntry[]; index: number };
const [historyIndex, setHistoryIndex] = useState(0); type PerTabHistoryState = Record<string, HistoryState>;
const skipHistoryRef = useRef(false); type HistoryAction =
| { type: 'push'; tabId: string; nodes: Node[]; edges: Edge[] }
| { type: 'undo'; tabId: string }
| { type: 'redo'; tabId: string }
| { type: 'reset'; tabId: string }
| { type: 'remove'; tabId: string };
const pushHistory = useCallback((n: Node[], e: Edge[]) => { const defaultHistory: HistoryState = { entries: [{ nodes: [], edges: [] }], index: 0 };
if (skipHistoryRef.current) { skipHistoryRef.current = false; return; }
setHistory(prev => { const historyReducer = useCallback((state: PerTabHistoryState, action: HistoryAction): PerTabHistoryState => {
const trimmed = prev.slice(0, historyIndex + 1); const tabState = state[action.tabId] || defaultHistory;
const entry = { nodes: JSON.parse(JSON.stringify(n)), edges: JSON.parse(JSON.stringify(e)) }; switch (action.type) {
const next = [...trimmed, entry]; case 'push': {
if (next.length > 50) next.shift(); const trimmed = tabState.entries.slice(0, tabState.index + 1);
return next; const entry = { nodes: JSON.parse(JSON.stringify(action.nodes)), edges: JSON.parse(JSON.stringify(action.edges)) };
}); const next = [...trimmed, entry];
setHistoryIndex(prev => Math.min(prev + 1, 50)); let newIndex = trimmed.length;
}, [historyIndex]); if (next.length > 50) {
next.shift();
newIndex = next.length - 1;
}
return { ...state, [action.tabId]: { entries: next, index: newIndex } };
}
case 'undo': {
if (tabState.index <= 0) return state;
return { ...state, [action.tabId]: { ...tabState, index: tabState.index - 1 } };
}
case 'redo': {
if (tabState.index >= tabState.entries.length - 1) return state;
return { ...state, [action.tabId]: { ...tabState, index: tabState.index + 1 } };
}
case 'reset': {
return { ...state, [action.tabId]: { entries: [{ nodes: [], edges: [] }], index: 0 } };
}
case 'remove': {
const { [action.tabId]: _, ...rest } = state;
return rest;
}
}
}, []);
const [historyState, dispatchHistory] = useReducer(historyReducer, { 'tx-1': { entries: [{ nodes: [], edges: [] }], index: 0 } });
const activeHistory = historyState[activeTransactionId] || defaultHistory;
const history = activeHistory.entries;
const historyIndex = activeHistory.index;
const pushHistory = useCallback((tabId: string, n: Node[], e: Edge[]) => {
dispatchHistory({ type: 'push', tabId, nodes: n, edges: e });
}, []);
const undo = useCallback(() => { const undo = useCallback(() => {
if (historyIndex <= 0) return; const tabHistory = historyState[activeTransactionId] || defaultHistory;
const newIndex = historyIndex - 1; const entry = tabHistory.entries[tabHistory.index - 1];
const entry = history[newIndex]; if (!entry || tabHistory.index <= 0) return;
if (!entry) return; dispatchHistory({ type: 'undo', tabId: activeTransactionId });
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
setNodes(JSON.parse(JSON.stringify(entry.nodes))); setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges))); setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyIndex, history, setNodes, setEdges]); }, [historyState, activeTransactionId, setNodes, setEdges]);
const redo = useCallback(() => { const redo = useCallback(() => {
if (historyIndex >= history.length - 1) return; const tabHistory = historyState[activeTransactionId] || defaultHistory;
const newIndex = historyIndex + 1; const entry = tabHistory.entries[tabHistory.index + 1];
const entry = history[newIndex]; if (!entry || tabHistory.index >= tabHistory.entries.length - 1) return;
if (!entry) return; dispatchHistory({ type: 'redo', tabId: activeTransactionId });
skipHistoryRef.current = true;
setHistoryIndex(newIndex);
setNodes(JSON.parse(JSON.stringify(entry.nodes))); setNodes(JSON.parse(JSON.stringify(entry.nodes)));
setEdges(JSON.parse(JSON.stringify(entry.edges))); setEdges(JSON.parse(JSON.stringify(entry.edges)));
}, [historyIndex, history, setNodes, setEdges]); }, [historyState, activeTransactionId, setNodes, setEdges]);
// Selected nodes // Selected nodes
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set()); const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
@@ -205,25 +236,29 @@ export default function App() {
position, position,
data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined }, data: { label: item.label, category: item.category, icon: item.icon, color: item.color, status: undefined },
}; };
setNodes(prev => { const tabId = activeTransactionId;
const next = [...prev, newNode]; setTransactionTabs(prev => prev.map(t => {
pushHistory(next, edges); if (t.id !== tabId) return t;
return next; const nextNodes = [...t.nodes, newNode];
}); pushHistory(tabId, nextNodes, t.edges);
return { ...t, nodes: nextNodes };
}));
addRecentComponent(item.id); addRecentComponent(item.id);
addTerminalEntry('info', 'canvas', `Node added: ${item.label}`); addTerminalEntry('info', 'canvas', `Node added: ${item.label}`);
addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`); addAuditEntry('NODE_ADD', `Added ${item.label} node to canvas`);
}, [setNodes, edges, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]); }, [activeTransactionId, pushHistory, addRecentComponent, addTerminalEntry, addAuditEntry]);
const onConnect = useCallback((params: Connection) => { const onConnect = useCallback((params: Connection) => {
setEdges(prev => { const tabId = activeTransactionId;
const next = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, prev); setTransactionTabs(prev => prev.map(t => {
pushHistory(nodes, next); if (t.id !== tabId) return t;
return next; const nextEdges = addEdge({ ...params, animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 } }, t.edges);
}); pushHistory(tabId, t.nodes, nextEdges);
return { ...t, edges: nextEdges };
}));
addTerminalEntry('info', 'canvas', `Edge connected: ${params.source}${params.target}`); addTerminalEntry('info', 'canvas', `Edge connected: ${params.source}${params.target}`);
addAuditEntry('EDGE_CREATE', `Connection created`); addAuditEntry('EDGE_CREATE', `Connection created`);
}, [setEdges, nodes, pushHistory, addTerminalEntry, addAuditEntry]); }, [activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
const onNodesChange = useCallback((changes: NodeChange[]) => { const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((prev: Node[]) => applyNodeChanges(changes, prev)); setNodes((prev: Node[]) => applyNodeChanges(changes, prev));
@@ -239,34 +274,38 @@ export default function App() {
const deleteSelectedNodes = useCallback(() => { const deleteSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return; if (selectedNodeIds.size === 0) return;
setNodes(prev => { const tabId = activeTransactionId;
const next = prev.filter(n => !selectedNodeIds.has(n.id)); setTransactionTabs(prev => prev.map(t => {
pushHistory(next, edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target))); if (t.id !== tabId) return t;
return next; const nextNodes = t.nodes.filter(n => !selectedNodeIds.has(n.id));
}); const nextEdges = t.edges.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target));
setEdges(prev => prev.filter(e => !selectedNodeIds.has(e.source) && !selectedNodeIds.has(e.target))); pushHistory(tabId, nextNodes, nextEdges);
return { ...t, nodes: nextNodes, edges: nextEdges };
}));
addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`); addTerminalEntry('info', 'canvas', `Deleted ${selectedNodeIds.size} node(s)`);
addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`); addAuditEntry('NODE_DELETE', `Removed ${selectedNodeIds.size} node(s)`);
setSelectedNodeIds(new Set()); setSelectedNodeIds(new Set());
}, [selectedNodeIds, setNodes, setEdges, edges, pushHistory, addTerminalEntry, addAuditEntry]); }, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
const duplicateSelectedNodes = useCallback(() => { const duplicateSelectedNodes = useCallback(() => {
if (selectedNodeIds.size === 0) return; if (selectedNodeIds.size === 0) return;
const selected = nodes.filter(n => selectedNodeIds.has(n.id)); const tabId = activeTransactionId;
const newNodes = selected.map(n => ({ setTransactionTabs(prev => prev.map(t => {
...n, if (t.id !== tabId) return t;
id: `node_${nodeIdCounter.current++}`, const selected = t.nodes.filter(n => selectedNodeIds.has(n.id));
position: { x: n.position.x + 40, y: n.position.y + 40 }, const newNodes = selected.map(n => ({
selected: false, ...n,
id: `node_${nodeIdCounter.current++}`,
position: { x: n.position.x + 40, y: n.position.y + 40 },
selected: false,
}));
const nextNodes = [...t.nodes, ...newNodes];
pushHistory(tabId, nextNodes, t.edges);
return { ...t, nodes: nextNodes };
})); }));
setNodes(prev => { addTerminalEntry('info', 'canvas', `Duplicated node(s)`);
const next = [...prev, ...newNodes]; addAuditEntry('NODE_DUPLICATE', `Duplicated node(s)`);
pushHistory(next, edges); }, [selectedNodeIds, activeTransactionId, pushHistory, addTerminalEntry, addAuditEntry]);
return next;
});
addTerminalEntry('info', 'canvas', `Duplicated ${selected.length} node(s)`);
addAuditEntry('NODE_DUPLICATE', `Duplicated ${selected.length} node(s)`);
}, [selectedNodeIds, nodes, setNodes, edges, pushHistory, addTerminalEntry, addAuditEntry]);
// Validate // Validate
const runValidation = useCallback(() => { const runValidation = useCallback(() => {
@@ -326,7 +365,7 @@ export default function App() {
const fee = (Math.random() * 0.1).toFixed(4); const fee = (Math.random() * 0.1).toFixed(4);
const results = [ const results = [
`Simulation complete for ${nodes.length} nodes, ${edges.length} edges`, `Simulation complete for ${nodes.length} nodes, ${edges.length} edges`,
`Estimated fees: $${fee}%`, `Estimated fees: ${fee}%`,
`Settlement window: T+${routingNodes.length > 0 ? '1' : '2'}`, `Settlement window: T+${routingNodes.length > 0 ? '1' : '2'}`,
`Compliance: ${hasCompliance ? 'All checks passed' : 'WARNING - No compliance checks in flow'}`, `Compliance: ${hasCompliance ? 'All checks passed' : 'WARNING - No compliance checks in flow'}`,
`Routing: ${routingNodes.length} venue(s) evaluated`, `Routing: ${routingNodes.length} venue(s) evaluated`,
@@ -359,14 +398,14 @@ export default function App() {
const id = `tx-${Date.now()}`; const id = `tx-${Date.now()}`;
setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]); setTransactionTabs(prev => [...prev, { id, name: `Transaction ${prev.length + 1}`, nodes: [], edges: [] }]);
setActiveTransactionId(id); setActiveTransactionId(id);
setHistory([{ nodes: [], edges: [] }]); dispatchHistory({ type: 'reset', tabId: id });
setHistoryIndex(0);
addTerminalEntry('info', 'system', 'New transaction tab created'); addTerminalEntry('info', 'system', 'New transaction tab created');
}, [addTerminalEntry]); }, [addTerminalEntry]);
const closeTransactionTab = useCallback((id: string) => { const closeTransactionTab = useCallback((id: string) => {
if (transactionTabs.length <= 1) return; if (transactionTabs.length <= 1) return;
setTransactionTabs(prev => prev.filter(t => t.id !== id)); setTransactionTabs(prev => prev.filter(t => t.id !== id));
dispatchHistory({ type: 'remove', tabId: id });
if (activeTransactionId === id) { if (activeTransactionId === id) {
const remaining = transactionTabs.filter(t => t.id !== id); const remaining = transactionTabs.filter(t => t.id !== id);
setActiveTransactionId(remaining[0].id); setActiveTransactionId(remaining[0].id);

View File

@@ -58,7 +58,7 @@ interface CanvasProps {
onCloseTab: (id: string) => void; onCloseTab: (id: string) => void;
splitView: boolean; splitView: boolean;
onToggleSplitView: () => void; onToggleSplitView: () => void;
pushHistory: (nodes: Node[], edges: Edge[]) => void; pushHistory: (tabId: string, nodes: Node[], edges: Edge[]) => void;
} }
function CanvasInner({ function CanvasInner({
@@ -105,8 +105,8 @@ function CanvasInner({
setZoomLevel(Math.round(zoom * 100)); setZoomLevel(Math.round(zoom * 100));
}, [reactFlowInstance]); }, [reactFlowInstance]);
const handleZoomIn = () => { reactFlowInstance.zoomIn(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); }; const handleZoomIn = () => { reactFlowInstance.zoomIn().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); };
const handleZoomOut = () => { reactFlowInstance.zoomOut(); setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)); }; const handleZoomOut = () => { reactFlowInstance.zoomOut().then(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100))); };
const handleFitView = () => { reactFlowInstance.fitView(); setTimeout(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)), 100); }; const handleFitView = () => { reactFlowInstance.fitView(); setTimeout(() => setZoomLevel(Math.round(reactFlowInstance.getZoom() * 100)), 100); };
const errorCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'error').length; const errorCount = nodes.filter(n => (n.data as Record<string, unknown>).status === 'error').length;
@@ -321,7 +321,7 @@ function CanvasInner({
<div className="inspector-separator" /> <div className="inspector-separator" />
<div className="inspector-item"> <div className="inspector-item">
<DollarSign size={12} /> <DollarSign size={12} />
<span>Est. fees: {nodes.length > 0 ? '$0.02%' : '—'}</span> <span>Est. fees: {nodes.length > 0 ? '0.02%' : '—'}</span>
</div> </div>
<div className="inspector-item"> <div className="inspector-item">
<Clock size={12} /> <Clock size={12} />

View File

@@ -287,7 +287,7 @@ export default function RightPanel({
</div> </div>
<div className="context-section"> <div className="context-section">
<span className="context-label">Est. Fees</span> <span className="context-label">Est. Fees</span>
<span className="context-value">{ctx.nodeCount > 0 ? '$0.02%' : '—'}</span> <span className="context-value">{ctx.nodeCount > 0 ? '0.02%' : '—'}</span>
</div> </div>
</div> </div>
)} )}