sync: interactive map canvas from private repo
- React Flow replaces custom SVG map (node dragging, edge creation, SSE sync) - Redesigned QuickAddInput and EdgeExplanationModal (match SearchModal design) - Selection highlighting with edge/node dimming - Added tabBar prop to BasePaneProps and PaneHeaderProps - Removed stale activeDelegations prop from QuickAddInput callers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
05523b7cb2
commit
65d22315e1
Generated
+221
@@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.27",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"cheerio": "^1.1.2",
|
||||
@@ -2797,6 +2798,55 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -3550,6 +3600,38 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
|
||||
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.74",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.74",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
|
||||
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -4498,6 +4580,12 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -4666,6 +4754,111 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -13253,6 +13446,34 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.27",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"cheerio": "^1.1.2",
|
||||
|
||||
@@ -241,7 +241,6 @@ export default function AgentsPanel({ openTabsData, activeTabId, activeDimension
|
||||
{/* Add Stuff - top right */}
|
||||
<div style={{ maxWidth: '200px' }}>
|
||||
<QuickAddInput
|
||||
activeDelegations={orderedDelegations}
|
||||
onSubmit={handleQuickAddSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface QuickAddSubmitPayload {
|
||||
input: string;
|
||||
@@ -10,14 +10,13 @@ interface QuickAddSubmitPayload {
|
||||
}
|
||||
|
||||
interface QuickAddInputProps {
|
||||
activeDelegations: AgentDelegation[];
|
||||
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
||||
// External control (optional - if provided, component is controlled)
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onClose }: QuickAddInputProps) {
|
||||
export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isExpandedInternal, setIsExpandedInternal] = useState(false);
|
||||
@@ -32,12 +31,6 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
? (value: boolean) => { if (!value && onClose) onClose(); }
|
||||
: setIsExpandedInternal;
|
||||
|
||||
const maxConcurrent = 5;
|
||||
const activeCount = activeDelegations.filter(
|
||||
(d) => d.status === 'queued' || d.status === 'in_progress'
|
||||
).length;
|
||||
const isSoftLimited = activeCount >= maxConcurrent;
|
||||
|
||||
const handleFileUpload = useCallback(async (file: File) => {
|
||||
setIsPosting(true);
|
||||
setUploadError(null);
|
||||
@@ -83,7 +76,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
}
|
||||
|
||||
// Otherwise, submit text as before
|
||||
if (!input.trim() || isPosting || isSoftLimited) return;
|
||||
if (!input.trim() || isPosting) return;
|
||||
|
||||
setIsPosting(true);
|
||||
try {
|
||||
@@ -159,6 +152,13 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
|
||||
const hasContent = input.trim() || uploadedFile;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsExpanded(false);
|
||||
setInput('');
|
||||
setUploadedFile(null);
|
||||
setUploadError(null);
|
||||
};
|
||||
|
||||
// Collapsed state - only show button if NOT controlled externally
|
||||
if (!isExpanded) {
|
||||
// In controlled mode, don't render anything when closed
|
||||
@@ -215,81 +215,19 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state - modal overlay (centered if controlled, absolute if uncontrolled)
|
||||
// Expanded state - the card content
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
background: '#0a0a0a',
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
border: dragOver ? '1px solid #22c55e' : '1px solid #2a2a2a',
|
||||
animation: 'fadeIn 150ms ease-out',
|
||||
transition: 'border-color 0.15s ease',
|
||||
width: isControlled ? '500px' : 'auto',
|
||||
maxWidth: isControlled ? '90vw' : 'none',
|
||||
}}
|
||||
className="qa-card"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
color: '#0a0a0a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700
|
||||
}}>+</span>
|
||||
Add Stuff
|
||||
</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded(false);
|
||||
setInput('');
|
||||
setUploadedFile(null);
|
||||
setUploadError(null);
|
||||
}}
|
||||
style={{
|
||||
padding: '6px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'color 0.15s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#999'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
<div className="qa-header">
|
||||
<span className="qa-title">Add Stuff</span>
|
||||
<button onClick={handleClose} className="qa-close">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
@@ -298,27 +236,9 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
|
||||
{/* File preview (when a file is dropped) */}
|
||||
{uploadedFile && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 14px',
|
||||
background: '#0f1a0f',
|
||||
border: '1px solid #1a3a1a',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
<div className="qa-file-preview">
|
||||
{/* PDF icon */}
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<div className="qa-file-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
@@ -339,11 +259,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
}}>
|
||||
{uploadedFile.name}
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<div style={{ color: '#666', fontSize: '11px', marginTop: '2px' }}>
|
||||
{uploadedFile.size < 1024 * 1024
|
||||
? `${Math.round(uploadedFile.size / 1024)} KB`
|
||||
: `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
|
||||
@@ -351,20 +267,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={clearFile}
|
||||
style={{
|
||||
padding: '6px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'color 0.15s ease'
|
||||
}}
|
||||
<button onClick={clearFile} className="qa-close" style={{ color: '#666' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
@@ -381,7 +284,7 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: '8px',
|
||||
borderRadius: '10px',
|
||||
color: '#ef4444',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
@@ -391,28 +294,10 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
|
||||
{/* Input area - show if no file uploaded */}
|
||||
{!uploadedFile && (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: '10px',
|
||||
border: dragOver ? '2px dashed #22c55e' : '1px solid #1f1f1f',
|
||||
background: dragOver ? 'rgba(34, 197, 94, 0.05)' : '#0a0a0a',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<div className={`qa-input-wrapper ${dragOver ? 'dragging' : ''}`}>
|
||||
{/* Drag overlay */}
|
||||
{dragOver && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(34, 197, 94, 0.05)',
|
||||
borderRadius: '10px',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<div className="qa-drag-overlay">
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -433,73 +318,32 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Paste a URL, note, or transcript — or drop a PDF file"
|
||||
disabled={isPosting || isSoftLimited}
|
||||
placeholder="Paste a URL, note, or transcript — or drop a PDF"
|
||||
disabled={isPosting}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '140px',
|
||||
maxHeight: '300px',
|
||||
padding: '14px 16px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#e5e5e5',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
lineHeight: '1.6',
|
||||
opacity: dragOver ? 0.3 : 1,
|
||||
transition: 'opacity 0.15s ease'
|
||||
}}
|
||||
className="qa-textarea"
|
||||
style={{ opacity: dragOver ? 0.3 : 1 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '11px', color: '#525252' }}>
|
||||
{uploadedFile ? 'Ready to upload' : '\u2318\u21B5 to submit \u00B7 esc to close'}
|
||||
<div className="qa-footer">
|
||||
<span className="qa-hint">
|
||||
{uploadedFile
|
||||
? 'Ready to upload'
|
||||
: <><kbd>{'\u2318\u21B5'}</kbd> submit <span className="qa-hint-sep">·</span> <kbd>esc</kbd> close</>
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasContent || isPosting || isSoftLimited}
|
||||
style={{
|
||||
width: '38px',
|
||||
height: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0',
|
||||
background: hasContent && !isPosting ? '#22c55e' : '#262626',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
cursor: hasContent && !isPosting ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: hasContent && !isPosting ? '0 0 0 0 rgba(34, 197, 94, 0)' : 'none'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (hasContent && !isPosting) {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(34, 197, 94, 0.3)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 0 rgba(34, 197, 94, 0)';
|
||||
}}
|
||||
disabled={!hasContent || isPosting}
|
||||
className={`qa-submit ${hasContent && !isPosting ? 'active' : ''}`}
|
||||
>
|
||||
{isPosting ? (
|
||||
<span style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: '2px solid #0a0a0a',
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite'
|
||||
}} />
|
||||
<span className="qa-spinner" />
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={hasContent ? '#0a0a0a' : '#525252'} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19V5"/>
|
||||
<path d="M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
@@ -507,64 +351,244 @@ export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active processing indicator */}
|
||||
{activeCount > 0 && (
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: '#0a1a0a',
|
||||
border: '1px solid #1a3a1a',
|
||||
borderRadius: '8px',
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
animation: 'pulse 2s ease-in-out infinite'
|
||||
}} />
|
||||
Adding {activeCount} node{activeCount > 1 ? 's' : ''}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
.qa-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: #141414;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #262626;
|
||||
transition: border-color 0.15s ease;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
animation: qaCardIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
width: ${isControlled ? '540px' : 'auto'};
|
||||
max-width: ${isControlled ? '90vw' : 'none'};
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
|
||||
.qa-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@keyframes spin {
|
||||
|
||||
.qa-title {
|
||||
color: #fafafa;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qa-close {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #525252;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.qa-close:hover {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.qa-file-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #0f1a0f;
|
||||
border: 1px solid #1a3a1a;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.qa-file-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qa-input-wrapper {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #262626;
|
||||
background: #0a0a0a;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.qa-input-wrapper:focus-within {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.qa-input-wrapper.dragging {
|
||||
border: 2px dashed #22c55e;
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
|
||||
.qa-drag-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
border-radius: 12px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.qa-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
max-height: 300px;
|
||||
padding: 16px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fafafa;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
resize: none;
|
||||
line-height: 1.6;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.qa-textarea::placeholder {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.qa-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.qa-hint {
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.qa-hint kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
background: #262626;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
color: #737373;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.qa-hint-sep {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.qa-submit {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: #262626;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #525252;
|
||||
cursor: default;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.qa-submit.active {
|
||||
background: #22c55e;
|
||||
color: #052e16;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qa-submit.active:hover {
|
||||
background: #16a34a;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.qa-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #0a0a0a;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: qaSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes qaCardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes qaSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
// In controlled mode, wrap with backdrop
|
||||
// In controlled mode, wrap with blurred backdrop + portal
|
||||
if (isControlled) {
|
||||
return (
|
||||
const backdrop = (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="qa-backdrop"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{modalContent}
|
||||
<div className="qa-container">
|
||||
{modalContent}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.qa-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 15vh;
|
||||
z-index: 9999;
|
||||
animation: qaBackdropIn 200ms ease-out;
|
||||
}
|
||||
|
||||
.qa-container {
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
@keyframes qaBackdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(backdrop, document.body) : null;
|
||||
}
|
||||
|
||||
// Uncontrolled mode - render with absolute positioning
|
||||
|
||||
@@ -1073,7 +1073,6 @@ export default function ThreePanelLayout() {
|
||||
<QuickAddInput
|
||||
isOpen={showAddStuff}
|
||||
onClose={() => setShowAddStuff(false)}
|
||||
activeDelegations={delegations}
|
||||
onSubmit={handleQuickAddSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+412
-515
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface EdgeExplanationModalProps {
|
||||
sourceTitle: string;
|
||||
targetTitle: string;
|
||||
onSubmit: (explanation: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function EdgeExplanationModal({
|
||||
sourceTitle,
|
||||
targetTitle,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: EdgeExplanationModalProps) {
|
||||
const [explanation, setExplanation] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && explanation.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmit(explanation.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="edge-modal-backdrop"
|
||||
onClick={onCancel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Connect nodes"
|
||||
>
|
||||
<div className="edge-modal-container">
|
||||
<div
|
||||
className="edge-modal-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Connection visualization */}
|
||||
<div className="edge-modal-connection">
|
||||
<span className="edge-modal-node-badge">{sourceTitle.length > 24 ? sourceTitle.slice(0, 22) + '\u2026' : sourceTitle}</span>
|
||||
<svg width="20" height="12" viewBox="0 0 20 12" fill="none" style={{ flexShrink: 0 }}>
|
||||
<path d="M0 6h16M12 1l5 5-5 5" stroke="#22c55e" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="edge-modal-node-badge">{targetTitle.length > 24 ? targetTitle.slice(0, 22) + '\u2026' : targetTitle}</span>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="edge-modal-input-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={explanation}
|
||||
onChange={(e) => setExplanation(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="How are these connected?"
|
||||
rows={2}
|
||||
className="edge-modal-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="edge-modal-footer">
|
||||
<span className="edge-modal-hint">
|
||||
<kbd>⌘↵</kbd> connect · <kbd>esc</kbd> cancel
|
||||
</span>
|
||||
<div className="edge-modal-actions">
|
||||
<button onClick={onCancel} className="edge-modal-btn edge-modal-btn--cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => explanation.trim() && onSubmit(explanation.trim())}
|
||||
disabled={!explanation.trim()}
|
||||
className={`edge-modal-btn edge-modal-btn--submit ${explanation.trim() ? 'active' : ''}`}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.edge-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 18vh;
|
||||
z-index: 9999;
|
||||
animation: edgeBackdropIn 200ms ease-out;
|
||||
}
|
||||
|
||||
.edge-modal-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
animation: edgeContainerIn 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.edge-modal-card {
|
||||
background: #141414;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.edge-modal-connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.edge-modal-node-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.15);
|
||||
border-radius: 8px;
|
||||
color: #86efac;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edge-modal-input-wrapper {
|
||||
border: 1px solid #262626;
|
||||
border-radius: 12px;
|
||||
background: #0a0a0a;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.edge-modal-input-wrapper:focus-within {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.edge-modal-textarea {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 16px 18px;
|
||||
color: #fafafa;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.edge-modal-textarea::placeholder {
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.edge-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.edge-modal-hint {
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
}
|
||||
|
||||
.edge-modal-hint kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 6px;
|
||||
background: #262626;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
color: #737373;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.edge-modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edge-modal-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.edge-modal-btn--cancel {
|
||||
background: transparent;
|
||||
border: 1px solid #262626;
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.edge-modal-btn--cancel:hover {
|
||||
border-color: #333;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.edge-modal-btn--submit {
|
||||
background: #262626;
|
||||
border: 1px solid transparent;
|
||||
color: #525252;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.edge-modal-btn--submit.active {
|
||||
background: #22c55e;
|
||||
color: #052e16;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edge-modal-btn--submit.active:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
@keyframes edgeBackdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes edgeContainerIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||
import type { RahNodeData } from './utils';
|
||||
import { LABEL_THRESHOLD } from './utils';
|
||||
|
||||
type RahNodeType = Node<RahNodeData, 'rahNode'>;
|
||||
|
||||
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
|
||||
const { label, dimensions, edgeCount, isExpanded } = data;
|
||||
const isTop = !isExpanded && edgeCount > 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rah-map-node',
|
||||
isExpanded && 'rah-map-node--expanded',
|
||||
isTop && 'rah-map-node--top',
|
||||
selected && 'rah-map-node--selected',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="rah-map-handle"
|
||||
/>
|
||||
<div className="rah-map-node__title">
|
||||
{label.length > 28 ? label.slice(0, 26) + '\u2026' : label}
|
||||
</div>
|
||||
{(isTop || isExpanded) && dimensions.length > 0 && (
|
||||
<div className="rah-map-node__dims">
|
||||
{dimensions.slice(0, 3).map(d => d.length > 12 ? d.slice(0, 11) + '\u2026' : d).join(' \u00b7 ')}
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="rah-map-handle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const RahNode = memo(RahNodeComponent);
|
||||
@@ -0,0 +1,132 @@
|
||||
/* React Flow dark theme overrides for RA-H Map */
|
||||
|
||||
.rah-map-wrapper .react-flow__background {
|
||||
background: #080808 !important;
|
||||
}
|
||||
|
||||
.rah-map-wrapper .react-flow__pane {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.rah-map-wrapper .react-flow__pane:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Edges */
|
||||
.rah-map-wrapper .react-flow__edge-path {
|
||||
stroke: #374151;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.rah-map-wrapper .react-flow__edge.selected .react-flow__edge-path {
|
||||
stroke: #22c55e;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* Connection line while dragging */
|
||||
.rah-map-wrapper .react-flow__connection-path {
|
||||
stroke: #22c55e;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
|
||||
/* Attribution */
|
||||
.rah-map-wrapper .react-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.rah-map-wrapper .react-flow__controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Selection box */
|
||||
.rah-map-wrapper .react-flow__selection {
|
||||
border: 1px solid rgba(34, 197, 94, 0.4);
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
|
||||
/* Custom node styles */
|
||||
.rah-map-node {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
color: #e5e7eb;
|
||||
font-size: 12px;
|
||||
min-width: 60px;
|
||||
max-width: 180px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rah-map-node:hover {
|
||||
border-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.rah-map-node--selected {
|
||||
border-color: #22c55e !important;
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.rah-map-node--top {
|
||||
border-color: #166534;
|
||||
background: #0a1a0f;
|
||||
}
|
||||
|
||||
.rah-map-node--expanded {
|
||||
border-color: #b45309;
|
||||
background: #1a150a;
|
||||
}
|
||||
|
||||
.rah-map-node--expanded .rah-map-node__title {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.rah-map-node__title {
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rah-map-node__dims {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: #a1a1aa;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Handles */
|
||||
.rah-map-handle {
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
background: #22c55e !important;
|
||||
border: 2px solid #0f0f0f !important;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.rah-map-node:hover .rah-map-handle,
|
||||
.react-flow__node.selected .rah-map-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Connecting state: show all handles */
|
||||
.rah-map-wrapper .react-flow__node.connecting .rah-map-handle,
|
||||
.rah-map-wrapper.connecting .rah-map-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dimmed nodes (not selected or connected to selection) */
|
||||
.rah-map-wrapper .react-flow__node.dimmed {
|
||||
opacity: 0.2;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.rah-map-wrapper .react-flow__node.dimmed:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
|
||||
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
|
||||
|
||||
export interface RahNodeData {
|
||||
label: string;
|
||||
dimensions: string[];
|
||||
edgeCount: number;
|
||||
isExpanded: boolean;
|
||||
dbNode: DbNode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const NODE_LIMIT = 200;
|
||||
const LABEL_THRESHOLD = 15;
|
||||
|
||||
export { NODE_LIMIT, LABEL_THRESHOLD };
|
||||
|
||||
/**
|
||||
* Get node position from saved metadata or calculate using Fibonacci spiral.
|
||||
*/
|
||||
export function getNodePosition(
|
||||
node: DbNode,
|
||||
index: number,
|
||||
total: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
maxEdges: number,
|
||||
): { x: number; y: number } {
|
||||
// Check for saved position in metadata
|
||||
const metadata = typeof node.metadata === 'string'
|
||||
? safeParseJSON(node.metadata)
|
||||
: node.metadata;
|
||||
const savedPos = metadata?.map_position;
|
||||
if (savedPos?.x !== undefined && savedPos?.y !== undefined) {
|
||||
return { x: savedPos.x, y: savedPos.y };
|
||||
}
|
||||
|
||||
// Fibonacci spiral layout
|
||||
const edgeCount = node.edge_count ?? 0;
|
||||
const edgeRatio = maxEdges > 0 ? edgeCount / maxEdges : 0;
|
||||
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const angle = index * goldenAngle;
|
||||
|
||||
const isLabeled = index < LABEL_THRESHOLD;
|
||||
const labelSpacing = isLabeled ? 60 : 0;
|
||||
const containerSize = Math.min(centerX * 2, centerY * 2);
|
||||
const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * containerSize * 0.35;
|
||||
const distance = baseDistance + index * 4;
|
||||
|
||||
return {
|
||||
x: centerX + Math.cos(angle) * distance,
|
||||
y: centerY + Math.sin(angle) * distance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Position expanded (traversal) nodes in a circle around a reference node.
|
||||
*/
|
||||
export function getExpandedNodePosition(
|
||||
index: number,
|
||||
total: number,
|
||||
refX: number,
|
||||
refY: number,
|
||||
): { x: number; y: number } {
|
||||
const angle = (index / Math.max(total, 1)) * Math.PI * 2;
|
||||
const distance = 150 + (index % 3) * 40;
|
||||
return {
|
||||
x: refX + Math.cos(angle) * distance,
|
||||
y: refY + Math.sin(angle) * distance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform DB nodes into React Flow nodes.
|
||||
* When a node is selected, non-connected nodes get dimmed via className.
|
||||
*/
|
||||
export function toRFNodes(
|
||||
baseNodes: DbNode[],
|
||||
expandedNodes: DbNode[],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
selectedNodeId: number | null,
|
||||
connectedNodeIds: Set<number>,
|
||||
existingPositions: Map<string, { x: number; y: number }>,
|
||||
): RFNode<RahNodeData>[] {
|
||||
const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1);
|
||||
const baseNodeIds = new Set(baseNodes.map(n => n.id));
|
||||
const hasSelection = selectedNodeId !== null;
|
||||
|
||||
const rfNodes: RFNode<RahNodeData>[] = sortedBase.map((node, index) => {
|
||||
const id = String(node.id);
|
||||
// Prefer React Flow's current position (for drag state), then saved, then calculated
|
||||
const existingPos = existingPositions.get(id);
|
||||
const pos = existingPos || getNodePosition(node, index, sortedBase.length, centerX, centerY, maxEdges);
|
||||
|
||||
// Dim nodes that aren't selected or connected to selection
|
||||
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'rahNode',
|
||||
position: pos,
|
||||
className: isDimmed ? 'dimmed' : undefined,
|
||||
data: {
|
||||
label: node.title || 'Untitled',
|
||||
dimensions: node.dimensions || [],
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
isExpanded: false,
|
||||
dbNode: node,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Add expanded nodes not already in base
|
||||
const uniqueExpanded = expandedNodes.filter(n => !baseNodeIds.has(n.id));
|
||||
|
||||
// Find reference position for expanded nodes (the selected node)
|
||||
let refX = centerX;
|
||||
let refY = centerY;
|
||||
if (selectedNodeId) {
|
||||
const selectedRF = rfNodes.find(n => n.id === String(selectedNodeId));
|
||||
if (selectedRF) {
|
||||
refX = selectedRF.position.x;
|
||||
refY = selectedRF.position.y;
|
||||
}
|
||||
}
|
||||
|
||||
uniqueExpanded.forEach((node, index) => {
|
||||
const id = String(node.id);
|
||||
const existingPos = existingPositions.get(id);
|
||||
|
||||
// Check for saved metadata position
|
||||
const metadata = typeof node.metadata === 'string'
|
||||
? safeParseJSON(node.metadata)
|
||||
: node.metadata;
|
||||
const savedPos = metadata?.map_position;
|
||||
|
||||
const pos = existingPos
|
||||
|| (savedPos?.x !== undefined ? { x: savedPos.x, y: savedPos.y } : null)
|
||||
|| getExpandedNodePosition(index, uniqueExpanded.length, refX, refY);
|
||||
|
||||
const isDimmed = hasSelection && node.id !== selectedNodeId && !connectedNodeIds.has(node.id);
|
||||
|
||||
rfNodes.push({
|
||||
id,
|
||||
type: 'rahNode',
|
||||
position: pos,
|
||||
className: isDimmed ? 'dimmed' : undefined,
|
||||
data: {
|
||||
label: node.title || 'Untitled',
|
||||
dimensions: node.dimensions || [],
|
||||
edgeCount: node.edge_count ?? 0,
|
||||
isExpanded: true,
|
||||
dbNode: node,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return rfNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform DB edges into React Flow edges, filtering to only those
|
||||
* connecting nodes currently in the graph.
|
||||
* When a node is selected, connected edges are highlighted and others dimmed.
|
||||
*/
|
||||
export function toRFEdges(
|
||||
dbEdges: DbEdge[],
|
||||
nodeIds: Set<string>,
|
||||
selectedNodeId: number | null,
|
||||
): RFEdge[] {
|
||||
const hasSelection = selectedNodeId !== null;
|
||||
|
||||
return dbEdges
|
||||
.filter(e => nodeIds.has(String(e.from_node_id)) && nodeIds.has(String(e.to_node_id)))
|
||||
.map(e => {
|
||||
const isConnected = hasSelection && (
|
||||
e.from_node_id === selectedNodeId || e.to_node_id === selectedNodeId
|
||||
);
|
||||
const isDimmed = hasSelection && !isConnected;
|
||||
|
||||
return {
|
||||
id: String(e.id),
|
||||
source: String(e.from_node_id),
|
||||
target: String(e.to_node_id),
|
||||
animated: isConnected,
|
||||
style: isConnected
|
||||
? { stroke: '#22c55e', strokeWidth: 2.5, opacity: 1 }
|
||||
: isDimmed
|
||||
? { stroke: '#374151', strokeWidth: 1, opacity: 0.15 }
|
||||
: undefined,
|
||||
zIndex: isConnected ? 10 : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function safeParseJSON(str: string | null | undefined): Record<string, unknown> | null {
|
||||
if (!str || str === 'null') return null;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Node } from '@/types/database';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
@@ -29,6 +30,7 @@ export interface BasePaneProps {
|
||||
onPaneAction?: (action: PaneAction) => void;
|
||||
onCollapse?: () => void;
|
||||
onSwapPanes?: () => void;
|
||||
tabBar?: React.ReactNode;
|
||||
}
|
||||
|
||||
// NodePane specific props
|
||||
@@ -103,6 +105,7 @@ export interface PaneHeaderProps {
|
||||
slot?: 'A' | 'B';
|
||||
onCollapse?: () => void;
|
||||
onSwapPanes?: () => void;
|
||||
tabBar?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user