feat(desktop): onboarding file indexing & knowledge graph improvements#4900
feat(desktop): onboarding file indexing & knowledge graph improvements#4900
Conversation
Remove consent screen and start file scanning immediately on appear. Add pipelineStarted guard to prevent duplicate pipeline runs. Set hasCompletedFileIndexing flag early to prevent duplicate sheet from DesktopHomeView. Add isBrainMapPhase binding for full-bleed layout in OnboardingView. Update loading UI with clearer messaging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add connectionCount tracking to GraphNode3D. Change physics parameters to var for runtime adaptation. Adjust repulsion, attraction, centerGravity, and restLength based on graph size (small/medium/large) for better visual layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add text labels, connection-based node sizing, colored edges, smooth glow halos (48 segments), and auto-fit camera. Nodes scale by connection count for visual hierarchy. Edge colors blend source and target node colors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces significant improvements to the onboarding flow and the knowledge graph visualization. The onboarding process is now more streamlined, and a potential race condition that could cause duplicate file indexing has been fixed. The 3D knowledge graph has been greatly enhanced with new visual features like text labels, dynamic node sizing, colored edges, and an auto-fitting camera, which greatly improves its usability and aesthetics.
My review focuses on potential performance and correctness issues in the new graph rendering logic. I've identified a performance regression where geometries are no longer shared, and a minor issue in the camera auto-fitting logic that could lead to visual clipping. Addressing these will ensure the new features are both robust and performant.
| for node in simulation.nodes { | ||
| let scnNode: SCNNode | ||
| let radius = nodeRadius(for: node) | ||
| let containerNode = SCNNode() | ||
| containerNode.position = SCNVector3(node.position) | ||
| containerNode.name = node.id | ||
|
|
||
| // Core sphere | ||
| let sphere = SCNSphere(radius: radius) | ||
| sphere.segmentCount = node.isFixed ? 24 : 16 | ||
| let mat = SCNMaterial() | ||
| if node.isFixed { | ||
| // User node — larger and white | ||
| let userSphere = SCNSphere(radius: 30) | ||
| userSphere.segmentCount = 16 | ||
| let mat = SCNMaterial() | ||
| mat.diffuse.contents = NSColor.white | ||
| mat.emission.contents = NSColor.white.withAlphaComponent(0.6) | ||
| mat.lightingModel = .constant | ||
| userSphere.materials = [mat] | ||
| scnNode = SCNNode(geometry: userSphere) | ||
| mat.emission.contents = NSColor.white.withAlphaComponent(0.8) | ||
| } else { | ||
| let sphere = getSharedSphere(for: node.nodeType) | ||
| scnNode = SCNNode(geometry: sphere) | ||
| mat.diffuse.contents = node.nodeType.nsColor | ||
| mat.emission.contents = node.nodeType.nsColor.withAlphaComponent(0.5) | ||
| } | ||
| scnNode.position = SCNVector3(node.position) | ||
| scnNode.name = node.id | ||
| mat.lightingModel = .constant | ||
| sphere.materials = [mat] | ||
| let sphereNode = SCNNode(geometry: sphere) | ||
| containerNode.addChildNode(sphereNode) | ||
|
|
||
| // Glow halo (larger semi-transparent sphere around node) | ||
| let glowRadius = radius * 2.5 | ||
| let glowSphere = SCNSphere(radius: glowRadius) | ||
| glowSphere.segmentCount = 48 | ||
| let glowMat = SCNMaterial() | ||
| let glowColor = node.isFixed ? NSColor.white : node.nodeType.nsColor | ||
| glowMat.diffuse.contents = glowColor.withAlphaComponent(0.03) | ||
| glowMat.emission.contents = glowColor.withAlphaComponent(0.025) | ||
| glowMat.lightingModel = .constant | ||
| glowMat.isDoubleSided = true | ||
| glowMat.blendMode = .add | ||
| glowSphere.materials = [glowMat] | ||
| let glowNode = SCNNode(geometry: glowSphere) | ||
| containerNode.addChildNode(glowNode) | ||
|
|
||
| // Text label (billboard — always faces camera) | ||
| let labelNode = createLabelNode(text: node.label, nodeRadius: radius, isFixed: node.isFixed) | ||
| labelNode.constraints = [billboardConstraint] | ||
| containerNode.addChildNode(labelNode) | ||
|
|
||
| scene.rootNode.addChildNode(containerNode) | ||
| nodeSceneNodes[node.id] = containerNode | ||
| } |
There was a problem hiding this comment.
The new implementation for creating scene nodes in createSceneNodes creates a new SCNSphere geometry and SCNMaterial for each node's core and glow halo. This is a performance regression from the previous implementation which used shared geometries to reduce memory usage and improve rendering speed. For large graphs, creating unique geometries and materials for every node can lead to significant performance degradation and higher memory consumption.
To optimize this, you can reintroduce geometry and material sharing.
1. **Share Geometries:** Create shared unit `SCNSphere` geometries (radius 1.0) - one for regular nodes and one for fixed nodes (due to different `segmentCount`). For each node in the scene, create an `SCNNode` with the appropriate shared geometry and then set its `scale` property to match the desired radius (e.g., `sphereNode.scale = SCNVector3(radius, radius, radius)`). This applies to both the core sphere and the glow halo.
2. **Share Materials:** Cache and reuse `SCNMaterial` instances instead of creating a new one for each node. You can use a dictionary `[NSColor: SCNMaterial]` to store materials based on their diffuse color.
Here's a conceptual example for sharing the sphere geometry:
```swift
// At the class level
private let sharedSphere = SCNSphere(radius: 1.0)
private let sharedFixedSphere = SCNSphere(radius: 1.0) // with different segmentCount if needed
// Inside createSceneNodes() for each node
let radius = nodeRadius(for: node)
let sphereNode = SCNNode(geometry: node.isFixed ? sharedFixedSphere : sharedSphere)
sphereNode.scale = SCNVector3(radius, radius, radius)
// ... assign a shared/cached material to sphereNode.geometry
containerNode.addChildNode(sphereNode)| private func autoFitCamera() { | ||
| guard !simulation.nodes.isEmpty else { return } | ||
|
|
||
| var maxDist: Float = 0 | ||
| for node in simulation.nodes { | ||
| let dist = simd_length(node.position) | ||
| if dist > maxDist { maxDist = dist } | ||
| } | ||
|
|
||
| // Camera needs to be far enough to see the outermost node | ||
| // Account for field of view (60°) — distance = maxDist / tan(fov/2) + padding | ||
| let fovRadians: Float = 60.0 * Float.pi / 180.0 | ||
| let minDistance = maxDist / tan(fovRadians / 2) * 1.3 // 30% padding | ||
| let cameraZ = max(minDistance, 1200) // minimum distance for very small graphs | ||
|
|
||
| cameraNode.position = SCNVector3(0, 0, cameraZ) | ||
| } |
There was a problem hiding this comment.
The autoFitCamera logic calculates the required camera distance based on the maximum distance of a node's center from the origin. However, it doesn't account for the radius of the nodes themselves. A large node near the edge of the view frustum might be partially clipped because its outer edge extends beyond the calculated maxDist. The 30% padding is a good attempt to mitigate this, but a more precise calculation would be more robust.
private func autoFitCamera() {
guard !simulation.nodes.isEmpty else { return }
var maxDistPlusRadius: Float = 0
for node in simulation.nodes {
let dist = simd_length(node.position)
let radius = Float(nodeRadius(for: node))
if dist + radius > maxDistPlusRadius {
maxDistPlusRadius = dist + radius
}
}
// Camera needs to be far enough to see the outermost node's edge
// Account for field of view (60°) — distance = maxDist / tan(fov/2) + padding
let fovRadians: Float = 60.0 * Float.pi / 180.0
// A smaller padding is sufficient with the more accurate calculation
let minDistance = maxDistPlusRadius / tan(fovRadians / 2) * 1.1
let cameraZ = max(minDistance, 1200) // minimum distance for very small graphs
cameraNode.position = SCNVector3(0, 0, cameraZ)
}BasedHardware#4900) ## Summary - Streamline file indexing onboarding: remove consent screen, start scanning immediately on appear with loading animation and progress bar - Prevent duplicate file indexing by setting flag early and adding pipeline guard - Improve 3D knowledge graph: text labels, connection-based node sizing, colored edges, smooth glow halos, auto-fit camera - Adaptive physics for graph layout based on graph size (small/medium/large) ## Test plan - [ ] Fresh onboarding flow: verify step 4 shows loading animation immediately (no consent screen) - [ ] Verify skip button works during file scanning - [ ] Verify brain map appears after loading completes with 3D graph - [ ] Verify existing user sheet (DesktopHomeView) also shows the new flow - [ ] Verify no duplicate loading screens appear - [ ] Check knowledge graph page renders with text labels, sized nodes, and colored edges 🤖 Generated with [Claude Code](https://claude.com/claude-code)
BasedHardware#4900) ## Summary - Streamline file indexing onboarding: remove consent screen, start scanning immediately on appear with loading animation and progress bar - Prevent duplicate file indexing by setting flag early and adding pipeline guard - Improve 3D knowledge graph: text labels, connection-based node sizing, colored edges, smooth glow halos, auto-fit camera - Adaptive physics for graph layout based on graph size (small/medium/large) ## Test plan - [ ] Fresh onboarding flow: verify step 4 shows loading animation immediately (no consent screen) - [ ] Verify skip button works during file scanning - [ ] Verify brain map appears after loading completes with 3D graph - [ ] Verify existing user sheet (DesktopHomeView) also shows the new flow - [ ] Verify no duplicate loading screens appear - [ ] Check knowledge graph page renders with text labels, sized nodes, and colored edges 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
Test plan
🤖 Generated with Claude Code