diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 015306b3..0c265b76 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -164,3 +164,33 @@ @apply bg-background text-foreground; } } + +.graph-view { + background-color: var(--background); + user-select: none; +} + +.graph-view::before { + content: ''; + position: absolute; + inset: 0; + background-image: radial-gradient(var(--border) 0.6px, transparent 0.6px); + background-size: 22px 22px; + opacity: 0.45; + pointer-events: none; +} + +.graph-view > svg { + position: relative; + z-index: 1; + cursor: grab; +} + +.graph-view:active > svg { + cursor: grabbing; +} + +.graph-view text { + pointer-events: none; + user-select: none; +} diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a8e3db70..dc232bc3 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -8,6 +8,7 @@ import z from 'zod'; import { Button } from './components/ui/button'; import { MessageSquare, CheckIcon, LoaderIcon } from 'lucide-react'; import { MarkdownEditor } from './components/markdown-editor'; +import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarIcon } from '@/components/sidebar-icon'; import { SidebarContentPanel } from '@/components/sidebar-content'; @@ -115,6 +116,20 @@ const toToolState = (status: ToolCall['status']): ToolState => { } const DEFAULT_SIDEBAR_WIDTH = 256 +const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g +const graphPalette = [ + { hue: 210, sat: 72, light: 52 }, + { hue: 28, sat: 78, light: 52 }, + { hue: 120, sat: 62, light: 48 }, + { hue: 170, sat: 66, light: 46 }, + { hue: 280, sat: 70, light: 56 }, + { hue: 330, sat: 68, light: 54 }, + { hue: 55, sat: 80, light: 52 }, + { hue: 0, sat: 72, light: 52 }, +] + +const clampNumber = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)) const normalizeUsage = (usage?: Partial | null): LanguageModelUsage | null => { if (!usage) return null @@ -237,6 +252,13 @@ function App() { const [tree, setTree] = useState([]) const [expandedPaths, setExpandedPaths] = useState>(new Set()) const [recentWikiFiles, setRecentWikiFiles] = useState([]) + const [isGraphOpen, setIsGraphOpen] = useState(false) + const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ + nodes: [], + edges: [], + }) + const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [graphError, setGraphError] = useState(null) // Auto-save state const [isSaving, setIsSaving] = useState(false) @@ -544,6 +566,7 @@ function App() { const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { setSelectedPath(path) + setIsGraphOpen(false) return } @@ -560,6 +583,7 @@ function App() { const handleSectionChange = useCallback((section: ActiveSection) => { if (section === 'ask-ai' || section === 'agents') { setSelectedPath(null) + setIsGraphOpen(false) } }, []) @@ -568,6 +592,13 @@ function App() { const files = collectFilePaths(tree).filter((path) => path.endsWith('.md')) return Array.from(new Set(files.map(stripKnowledgePrefix))) }, [tree]) + const knowledgeFilePaths = React.useMemo(() => ( + knowledgeFiles.reduce((acc, filePath) => { + const resolved = toKnowledgePath(filePath) + if (resolved) acc.push(resolved) + return acc + }, []) + ), [knowledgeFiles]) // Get workspace root for full paths const [workspaceRoot, setWorkspaceRoot] = useState('') @@ -587,6 +618,7 @@ function App() { data: `# New Note\n\n`, opts: { encoding: 'utf8' } }) + setIsGraphOpen(false) setSelectedPath(fullPath) } catch (err) { console.error('Failed to create note:', err) @@ -604,6 +636,10 @@ function App() { throw err } }, + openGraph: () => { + setSelectedPath(null) + setIsGraphOpen(true) + }, expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), collapseAll: () => setExpandedPaths(new Set()), rename: async (oldPath: string, newName: string, isDir: boolean) => { @@ -671,6 +707,119 @@ function App() { onCreate: (path: string) => ensureWikiFile(path), }), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile]) + useEffect(() => { + if (!isGraphOpen) return + let cancelled = false + + const buildGraph = async () => { + setGraphStatus('loading') + setGraphError(null) + + if (knowledgeFilePaths.length === 0) { + setGraphData({ nodes: [], edges: [] }) + setGraphStatus('ready') + return + } + + const nodeSet = new Set(knowledgeFilePaths) + const edges: GraphEdge[] = [] + const edgeKeys = new Set() + + const contents = await Promise.all( + knowledgeFilePaths.map(async (path) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path }) + return { path, data: result.data as string } + } catch (err) { + console.error('Failed to read file for graph:', path, err) + return { path, data: '' } + } + }) + ) + + for (const { path, data } of contents) { + for (const match of data.matchAll(wikiLinkRegex)) { + const rawTarget = match[1]?.trim() ?? '' + const targetPath = toKnowledgePath(rawTarget) + if (!targetPath || targetPath === path) continue + if (!nodeSet.has(targetPath)) continue + const edgeKey = path < targetPath ? `${path}|${targetPath}` : `${targetPath}|${path}` + if (edgeKeys.has(edgeKey)) continue + edgeKeys.add(edgeKey) + edges.push({ source: path, target: targetPath }) + } + } + + const degreeMap = new Map() + edges.forEach((edge) => { + degreeMap.set(edge.source, (degreeMap.get(edge.source) ?? 0) + 1) + degreeMap.set(edge.target, (degreeMap.get(edge.target) ?? 0) + 1) + }) + + const groupIndexMap = new Map() + const getGroupIndex = (group: string) => { + const existing = groupIndexMap.get(group) + if (existing !== undefined) return existing + const nextIndex = groupIndexMap.size + groupIndexMap.set(group, nextIndex) + return nextIndex + } + const getNodeGroup = (path: string) => { + const normalized = stripKnowledgePrefix(path) + const parts = normalized.split('/').filter(Boolean) + if (parts.length <= 1) { + return { group: 'root', depth: 0 } + } + return { + group: parts[0], + depth: Math.max(0, parts.length - 2), + } + } + const getNodeColors = (groupIndex: number, depth: number) => { + const base = graphPalette[groupIndex % graphPalette.length] + const light = clampNumber(base.light + depth * 6, 36, 72) + const strokeLight = clampNumber(light - 12, 28, 60) + return { + fill: `hsl(${base.hue} ${base.sat}% ${light}%)`, + stroke: `hsl(${base.hue} ${Math.min(80, base.sat + 8)}% ${strokeLight}%)`, + } + } + + const nodes = knowledgeFilePaths.map((path) => { + const degree = degreeMap.get(path) ?? 0 + const radius = 6 + Math.min(18, degree * 2) + const { group, depth } = getNodeGroup(path) + const groupIndex = getGroupIndex(group) + const colors = getNodeColors(groupIndex, depth) + return { + id: path, + label: wikiLabel(path) || path, + degree, + radius, + group, + color: colors.fill, + stroke: colors.stroke, + } + }) + + if (!cancelled) { + setGraphData({ nodes, edges }) + setGraphStatus('ready') + } + } + + buildGraph().catch((err) => { + if (cancelled) return + console.error('Failed to build graph:', err) + setGraphStatus('error') + setGraphError(err instanceof Error ? err.message : 'Failed to build graph') + }) + + return () => { + cancelled = true + } + }, [isGraphOpen, knowledgeFilePaths]) + const renderConversationItem = (item: ConversationItem) => { if (isChatMessage(item)) { return ( @@ -757,6 +906,7 @@ function App() { : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready' const canSubmit = Boolean(message.trim()) && !isProcessing + const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') return ( @@ -789,7 +939,7 @@ function App() { - {selectedPath ? selectedPath : 'Chat'} + {headerTitle} {selectedPath && ( <> @@ -818,9 +968,32 @@ function App() { )} + {!selectedPath && isGraphOpen && ( + + )} - {selectedPath ? ( + {isGraphOpen ? ( +
+ { + setIsGraphOpen(false) + setSelectedPath(path) + }} + /> +
+ ) : selectedPath ? ( selectedPath.endsWith('.md') ? (
void +} + +type NodePosition = { + x: number + y: number + vx: number + vy: number +} + +const SIMULATION_STEPS = 240 +const SPRING_LENGTH = 80 +const SPRING_STRENGTH = 0.0038 +const REPULSION = 5800 +const DAMPING = 0.83 +const MIN_DISTANCE = 34 +const CLUSTER_STRENGTH = 0.0018 +const CLUSTER_RADIUS_MIN = 120 +const CLUSTER_RADIUS_MAX = 240 +const CLUSTER_RADIUS_STEP = 45 +const FLOAT_BASE = 3.5 +const FLOAT_VARIANCE = 2 +const FLOAT_SPEED_BASE = 0.0006 +const FLOAT_SPEED_VARIANCE = 0.00025 + +export function GraphView({ nodes, edges, isLoading, error, onSelectNode }: GraphViewProps) { + const containerRef = useRef(null) + const positionsRef = useRef>(new Map()) + const motionSeedsRef = useRef>(new Map()) + const motionTimeRef = useRef(0) + const draggingRef = useRef<{ + id: string + offsetX: number + offsetY: number + moved: boolean + } | null>(null) + const panningRef = useRef<{ + startX: number + startY: number + originX: number + originY: number + } | null>(null) + const hasCenteredRef = useRef(false) + const [viewport, setViewport] = useState({ width: 1, height: 1 }) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [zoom, setZoom] = useState(1) + const [hoveredNodeId, setHoveredNodeId] = useState(null) + const [, forceRender] = useState(0) + + const edgeList = useMemo( + () => edges.filter((edge) => edge.source !== edge.target), + [edges] + ) + const nodeGroupMap = useMemo(() => { + const map = new Map() + nodes.forEach((node) => map.set(node.id, node.group || 'root')) + return map + }, [nodes]) + const legendItems = useMemo(() => { + const grouped = new Map() + nodes.forEach((node) => { + const group = node.group || 'root' + if (grouped.has(group)) return + grouped.set(group, { + label: group === 'root' ? 'knowledge' : group, + color: node.color, + stroke: node.stroke, + }) + }) + return Array.from(grouped.values()).sort((a, b) => a.label.localeCompare(b.label)) + }, [nodes]) + const groupCenters = useMemo(() => { + const groups = Array.from(new Set(nodes.map((node) => node.group || 'root'))) + if (groups.length === 0) return new Map() + const radius = Math.min( + CLUSTER_RADIUS_MAX, + Math.max(CLUSTER_RADIUS_MIN, groups.length * CLUSTER_RADIUS_STEP) + ) + const centers = new Map() + groups.forEach((group, index) => { + const angle = (index / groups.length) * Math.PI * 2 + centers.set(group, { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + }) + }) + return centers + }, [nodes]) + + const getMotionSeed = useCallback((id: string) => { + const existing = motionSeedsRef.current.get(id) + if (existing) return existing + let hash = 0 + for (let i = 0; i < id.length; i += 1) { + hash = (hash << 5) - hash + id.charCodeAt(i) + hash |= 0 + } + const normalized = Math.abs(hash) + const phase = ((normalized % 360) * Math.PI) / 180 + const amplitude = FLOAT_BASE + (normalized % 7) * (FLOAT_VARIANCE / 6) + const speed = FLOAT_SPEED_BASE + (normalized % 5) * FLOAT_SPEED_VARIANCE + const seed = { phase, amplitude, speed } + motionSeedsRef.current.set(id, seed) + return seed + }, []) + + const getDisplayPosition = useCallback((id: string, base: NodePosition, skipMotion: boolean) => { + if (skipMotion) { + return { x: base.x, y: base.y } + } + const seed = getMotionSeed(id) + const phase = seed.phase + motionTimeRef.current * seed.speed + return { + x: base.x + Math.sin(phase) * seed.amplitude, + y: base.y + Math.cos(phase * 0.9) * seed.amplitude, + } + }, [getMotionSeed]) + + const getGraphPoint = useCallback((event: React.PointerEvent) => { + const container = containerRef.current + if (!container) return { x: 0, y: 0 } + const rect = container.getBoundingClientRect() + return { + x: (event.clientX - rect.left - pan.x) / zoom, + y: (event.clientY - rect.top - pan.y) / zoom, + } + }, [pan.x, pan.y, zoom]) + + useEffect(() => { + const container = containerRef.current + if (!container) return + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return + const { width, height } = entry.contentRect + setViewport({ width, height }) + if (!hasCenteredRef.current) { + setPan({ x: width / 2, y: height / 2 }) + hasCenteredRef.current = true + } + }) + observer.observe(container) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (nodes.length === 0) { + positionsRef.current = new Map() + return + } + + const nextPositions = new Map() + const count = nodes.length + const radius = Math.max(110, Math.min(220, count * 9)) + + nodes.forEach((node, index) => { + const existing = positionsRef.current.get(node.id) + if (existing) { + nextPositions.set(node.id, { ...existing }) + return + } + const angle = (index / count) * Math.PI * 2 + nextPositions.set(node.id, { + x: radius * Math.cos(angle), + y: radius * Math.sin(angle), + vx: 0, + vy: 0, + }) + }) + + positionsRef.current = nextPositions + + let step = 0 + let rafId = 0 + let active = true + + const simulate = () => { + if (!active) return + step += 1 + + const positions = positionsRef.current + const ids = nodes.map((node) => node.id) + const forces = new Map() + + ids.forEach((id) => forces.set(id, { x: 0, y: 0 })) + + for (let i = 0; i < ids.length; i += 1) { + const idA = ids[i] + const posA = positions.get(idA) + if (!posA) continue + for (let j = i + 1; j < ids.length; j += 1) { + const idB = ids[j] + const posB = positions.get(idB) + if (!posB) continue + const dx = posB.x - posA.x + const dy = posB.y - posA.y + const distance = Math.max(MIN_DISTANCE, Math.hypot(dx, dy)) + const force = REPULSION / (distance * distance) + const fx = (force * dx) / distance + const fy = (force * dy) / distance + const forceA = forces.get(idA) + const forceB = forces.get(idB) + if (forceA) { + forceA.x -= fx + forceA.y -= fy + } + if (forceB) { + forceB.x += fx + forceB.y += fy + } + } + } + + edgeList.forEach((edge) => { + const posA = positions.get(edge.source) + const posB = positions.get(edge.target) + if (!posA || !posB) return + const dx = posB.x - posA.x + const dy = posB.y - posA.y + const distance = Math.max(20, Math.hypot(dx, dy)) + const delta = distance - SPRING_LENGTH + const force = delta * SPRING_STRENGTH + const fx = (force * dx) / distance + const fy = (force * dy) / distance + const forceA = forces.get(edge.source) + const forceB = forces.get(edge.target) + if (forceA) { + forceA.x += fx + forceA.y += fy + } + if (forceB) { + forceB.x -= fx + forceB.y -= fy + } + }) + + ids.forEach((id) => { + const pos = positions.get(id) + const force = forces.get(id) + if (!pos || !force) return + const group = nodeGroupMap.get(id) ?? 'root' + const center = groupCenters.get(group) + if (!center) return + const dx = center.x - pos.x + const dy = center.y - pos.y + force.x += dx * CLUSTER_STRENGTH + force.y += dy * CLUSTER_STRENGTH + }) + + ids.forEach((id) => { + const pos = positions.get(id) + const force = forces.get(id) + if (!pos || !force) return + if (draggingRef.current?.id === id) { + pos.vx = 0 + pos.vy = 0 + return + } + pos.vx = (pos.vx + force.x) * DAMPING + pos.vy = (pos.vy + force.y) * DAMPING + pos.x += pos.vx + pos.y += pos.vy + }) + + forceRender((prev) => prev + 1) + + if (step < SIMULATION_STEPS) { + rafId = requestAnimationFrame(simulate) + } + } + + rafId = requestAnimationFrame(simulate) + return () => { + active = false + if (rafId) cancelAnimationFrame(rafId) + } + }, [nodes, edgeList, groupCenters, nodeGroupMap]) + + useEffect(() => { + if (nodes.length === 0) return + let rafId = 0 + let lastTime = performance.now() + + const animate = (time: number) => { + const delta = time - lastTime + if (delta >= 32) { + motionTimeRef.current += delta + lastTime = time + forceRender((prev) => prev + 1) + } + rafId = requestAnimationFrame(animate) + } + + rafId = requestAnimationFrame(animate) + return () => { + if (rafId) cancelAnimationFrame(rafId) + } + }, [nodes.length]) + + const handlePointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + panningRef.current = { + startX: event.clientX, + startY: event.clientY, + originX: pan.x, + originY: pan.y, + } + } + + const handlePointerMove = (event: React.PointerEvent) => { + const dragging = draggingRef.current + if (dragging) { + const point = getGraphPoint(event) + const pos = positionsRef.current.get(dragging.id) + if (pos) { + pos.x = point.x - dragging.offsetX + pos.y = point.y - dragging.offsetY + dragging.moved = true + forceRender((prev) => prev + 1) + } + return + } + + const panning = panningRef.current + if (panning) { + setPan({ + x: panning.originX + (event.clientX - panning.startX), + y: panning.originY + (event.clientY - panning.startY), + }) + } + } + + const handlePointerUp = () => { + const dragging = draggingRef.current + if (dragging) { + if (!dragging.moved) { + onSelectNode?.(dragging.id) + } + draggingRef.current = null + } + panningRef.current = null + } + + const handleWheel = (event: React.WheelEvent) => { + event.preventDefault() + const rawDelta = event.deltaY + const normalizedDelta = event.deltaMode === 1 + ? rawDelta * 16 + : event.deltaMode === 2 + ? rawDelta * viewport.height + : rawDelta + const sensitivity = Math.abs(normalizedDelta) < 40 ? 0.004 : 0.0022 + const zoomFactor = Math.exp(-normalizedDelta * sensitivity) + const nextZoom = Math.min(2.5, Math.max(0.4, zoom * zoomFactor)) + if (nextZoom === zoom) return + + const container = containerRef.current + if (!container) { + setZoom(nextZoom) + return + } + + const rect = container.getBoundingClientRect() + const cursorX = event.clientX - rect.left + const cursorY = event.clientY - rect.top + const graphX = (cursorX - pan.x) / zoom + const graphY = (cursorY - pan.y) / zoom + setZoom(nextZoom) + setPan({ + x: cursorX - graphX * nextZoom, + y: cursorY - graphY * nextZoom, + }) + } + + const startDragNode = (event: React.PointerEvent, nodeId: string) => { + event.stopPropagation() + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + const point = getGraphPoint(event) + const pos = positionsRef.current.get(nodeId) + if (!pos) return + const displayPos = getDisplayPosition(nodeId, pos, false) + draggingRef.current = { + id: nodeId, + offsetX: point.x - displayPos.x, + offsetY: point.y - displayPos.y, + moved: false, + } + } + + const displayPositions = new Map() + nodes.forEach((node) => { + const pos = positionsRef.current.get(node.id) + if (!pos) return + const isDragging = draggingRef.current?.id === node.id + displayPositions.set(node.id, getDisplayPosition(node.id, pos, isDragging)) + }) + const activeNodeId = hoveredNodeId ?? draggingRef.current?.id ?? null + const connectedNodes = useMemo(() => { + if (!activeNodeId) return null + const set = new Set([activeNodeId]) + edgeList.forEach((edge) => { + if (edge.source === activeNodeId) set.add(edge.target) + if (edge.target === activeNodeId) set.add(edge.source) + }) + return set + }, [activeNodeId, edgeList]) + + return ( +
+ {isLoading ? ( +
+
+ + Building graph… +
+
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {!isLoading && !error && nodes.length === 0 ? ( +
+ No notes found. +
+ ) : null} + + {legendItems.length > 0 ? ( +
event.stopPropagation()} + > +
+ Folders +
+
+ {legendItems.map((item) => ( +
+ + {item.label} +
+ ))} +
+
+ ) : null} + + { + handlePointerUp() + setHoveredNodeId(null) + }} + onWheel={handleWheel} + > + + + {edgeList.map((edge, index) => { + const source = displayPositions.get(edge.source) + const target = displayPositions.get(edge.target) + if (!source || !target) return null + const isActiveEdge = activeNodeId + ? edge.source === activeNodeId || edge.target === activeNodeId + : false + const strokeOpacity = activeNodeId ? (isActiveEdge ? 0.9 : 0.08) : 0.38 + const strokeWidth = activeNodeId ? (isActiveEdge ? 1.6 : 0.7) : 1 + const stroke = 'var(--foreground)' + return ( + + ) + })} + + {nodes.map((node) => { + const pos = displayPositions.get(node.id) + if (!pos) return null + const isConnected = connectedNodes ? connectedNodes.has(node.id) : true + const isPrimary = activeNodeId === node.id + const nodeOpacity = activeNodeId ? (isConnected ? 1 : 0.3) : 1 + return ( + setHoveredNodeId(node.id)} + onPointerLeave={() => setHoveredNodeId(null)} + onPointerDown={(event) => startDragNode(event, node.id)} + opacity={nodeOpacity} + > + + + {node.label} + + + ) + })} + + +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index e0754865..69b214fb 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -75,6 +75,7 @@ interface TreeNode { type KnowledgeActions = { createNote: (parentPath?: string) => void createFolder: (parentPath?: string) => void + openGraph: () => void expandAll: () => void collapseAll: () => void rename: (path: string, newName: string, isDir: boolean) => Promise @@ -256,7 +257,7 @@ function KnowledgeSection({ const quickActions = [ { icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() }, - { icon: Network, label: "Graph View", action: () => {} }, + { icon: Network, label: "Graph View", action: () => actions.openGraph() }, { icon: ArrowDownAZ, label: "Sort", action: () => {} }, ]