fix: Fix galaxy node clicking and navigation bugs
This commit fixes two critical bugs in the galaxy navigation: **Bug #1: Direct node URLs redirected to /chat** - Updated AppStateMachine to recognize /galaxy/* paths (including query params) as galaxy state - Changed line 55 from `pathname === '/galaxy'` to `pathname === '/galaxy' || pathname.startsWith('/galaxy/')` - Changed line 89 to compare pathname instead of lastNavigatedPathRef to preserve query params **Bug #2: Modal closed when clicking nodes** - Refactored ThoughtGalaxy to use URL query params (?node=xxx) instead of route params (/galaxy/node:xxx) - This prevents component unmounting when switching between nodes - Deleted app/galaxy/[node-id]/page.tsx (no longer needed) - Updated app/galaxy/page.tsx with documentation comment - Modified ThoughtGalaxy to: - Use useSearchParams() hook - Get selectedNodeId from query params - Update URL with query params on node click (doesn't cause remount) - Clear query params when modal closes **Testing:** - Verified manually with Playwright MCP that /galaxy?node=xxx preserves query params - Verified state machine correctly recognizes galaxy state - Created comprehensive Playwright test suite in tests/playwright/galaxy.spec.ts **Files changed:** - components/AppStateMachine.tsx: Fixed state machine to handle /galaxy/* paths and preserve query params - components/ThoughtGalaxy.tsx: Refactored to use query params instead of route params - app/galaxy/page.tsx: Added documentation - app/galaxy/[node-id]/page.tsx: Deleted (replaced with query param approach) - tests/playwright/galaxy.spec.ts: Added comprehensive test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
} from '@react-three/drei';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor } from '@mantine/core';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Define the shape of nodes and links from API
|
||||
@@ -78,9 +78,10 @@ function Node({
|
||||
}
|
||||
|
||||
// 2. The Main Scene Component
|
||||
export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
export function ThoughtGalaxy() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [nodes, setNodes] = useState<NodeData[]>([]);
|
||||
const [links, setLinks] = useState<LinkData[]>([]);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
||||
@@ -88,6 +89,9 @@ export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
const hasFitCamera = useRef(false);
|
||||
const hasFocusedNode = useRef<string | null>(null);
|
||||
|
||||
// Get selectedNodeId from URL query params
|
||||
const selectedNodeId = searchParams.get('node');
|
||||
|
||||
// Fetch data from API on mount and poll for updates
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
@@ -166,7 +170,7 @@ export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
|
||||
// Fit camera when nodes change and we haven't fitted yet
|
||||
useEffect(() => {
|
||||
if (!hasFitCamera.current && nodes.length > 0 && !focusNodeId) {
|
||||
if (!hasFitCamera.current && nodes.length > 0 && !selectedNodeId) {
|
||||
// Only auto-fit if we're not focusing on a specific node
|
||||
// Try to fit after a short delay to ensure Canvas is ready
|
||||
const timer = setTimeout(() => {
|
||||
@@ -174,20 +178,20 @@ export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [nodes, focusNodeId]);
|
||||
}, [nodes, selectedNodeId]);
|
||||
|
||||
// Auto-focus on specific node if focusNodeId is provided
|
||||
// Auto-focus on specific node if selectedNodeId is provided via query params
|
||||
useEffect(() => {
|
||||
if (focusNodeId && nodes.length > 0) {
|
||||
const focusNode = nodes.find((n) => n.id === focusNodeId);
|
||||
if (selectedNodeId && nodes.length > 0) {
|
||||
const focusNode = nodes.find((n) => n.id === selectedNodeId);
|
||||
if (focusNode) {
|
||||
console.log('[ThoughtGalaxy] Focusing on node:', focusNodeId);
|
||||
console.log('[ThoughtGalaxy] Focusing on node:', selectedNodeId);
|
||||
|
||||
// Always update selected node when focusNodeId changes (don't wait for camera ref)
|
||||
// Always update selected node when selectedNodeId changes (don't wait for camera ref)
|
||||
setSelectedNode(focusNode);
|
||||
|
||||
// Move camera if ref is available and we haven't focused this specific node yet
|
||||
if (cameraControlsRef.current && (!hasFocusedNode.current || hasFocusedNode.current !== focusNodeId)) {
|
||||
if (cameraControlsRef.current && (!hasFocusedNode.current || hasFocusedNode.current !== selectedNodeId)) {
|
||||
cameraControlsRef.current.setLookAt(
|
||||
focusNode.coords_3d[0],
|
||||
focusNode.coords_3d[1],
|
||||
@@ -197,11 +201,18 @@ export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
focusNode.coords_3d[2],
|
||||
hasFocusedNode.current ? true : false // Animate if not initial load
|
||||
);
|
||||
hasFocusedNode.current = focusNodeId;
|
||||
hasFocusedNode.current = selectedNodeId;
|
||||
}
|
||||
} else {
|
||||
// Node ID in URL doesn't exist in data, clear it
|
||||
setSelectedNode(null);
|
||||
}
|
||||
} else if (!selectedNodeId && selectedNode) {
|
||||
// Query param was cleared but we still have a selected node in state, clear it
|
||||
setSelectedNode(null);
|
||||
hasFocusedNode.current = null;
|
||||
}
|
||||
}, [focusNodeId, nodes]);
|
||||
}, [selectedNodeId, nodes, selectedNode]);
|
||||
|
||||
// Map links to node positions
|
||||
const linkLines = links
|
||||
@@ -220,33 +231,44 @@ export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
|
||||
// Camera animation on node click
|
||||
const handleNodeClick = (node: NodeData) => {
|
||||
const targetPath = `/galaxy/${encodeURIComponent(node.id)}`;
|
||||
console.log('[ThoughtGalaxy] Node clicked:', node.id);
|
||||
|
||||
// Set selected node immediately for responsive UI
|
||||
setSelectedNode(node);
|
||||
|
||||
// Only navigate if we're not already on this node's page
|
||||
if (pathname !== targetPath) {
|
||||
// Clear the focused node ref to ensure camera animates on next render
|
||||
// Update URL with query param (this won't cause remount)
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set('node', node.id);
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
|
||||
// Animate camera to node
|
||||
if (cameraControlsRef.current) {
|
||||
// Clear the focused node ref to ensure camera animates
|
||||
hasFocusedNode.current = null;
|
||||
// Use replace instead of push to avoid page reload issues
|
||||
router.replace(targetPath);
|
||||
} else {
|
||||
// Already on this page, just animate camera to node
|
||||
if (cameraControlsRef.current) {
|
||||
cameraControlsRef.current.setLookAt(
|
||||
node.coords_3d[0],
|
||||
node.coords_3d[1],
|
||||
node.coords_3d[2] + 2,
|
||||
node.coords_3d[0],
|
||||
node.coords_3d[1],
|
||||
node.coords_3d[2],
|
||||
true // Animate
|
||||
);
|
||||
}
|
||||
cameraControlsRef.current.setLookAt(
|
||||
node.coords_3d[0],
|
||||
node.coords_3d[1],
|
||||
node.coords_3d[2] + 2,
|
||||
node.coords_3d[0],
|
||||
node.coords_3d[1],
|
||||
node.coords_3d[2],
|
||||
true // Animate
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle closing the modal
|
||||
const handleCloseModal = () => {
|
||||
console.log('[ThoughtGalaxy] Closing modal');
|
||||
setSelectedNode(null);
|
||||
|
||||
// Remove node query param from URL
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.delete('node');
|
||||
const newSearch = params.toString();
|
||||
router.replace(`${pathname}${newSearch ? `?${newSearch}` : ''}`, { scroll: false });
|
||||
};
|
||||
|
||||
console.log('[ThoughtGalaxy] Rendering with', nodes.length, 'nodes and', linkLines.length, 'link lines');
|
||||
|
||||
// Show message if no nodes are ready yet
|
||||
@@ -297,7 +319,7 @@ export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
</Box>
|
||||
<CloseButton
|
||||
size="lg"
|
||||
onClick={() => setSelectedNode(null)}
|
||||
onClick={handleCloseModal}
|
||||
aria-label="Close node details"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
@@ -342,7 +364,7 @@ export function ThoughtGalaxy({ focusNodeId }: { focusNodeId?: string } = {}) {
|
||||
<Node
|
||||
key={node.id}
|
||||
node={node}
|
||||
isFocused={focusNodeId === node.id || selectedNode?.id === node.id}
|
||||
isFocused={selectedNodeId === node.id || selectedNode?.id === node.id}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user