Files
app/components/AppStateMachine.tsx
Albert cde66978cd 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>
2025-11-09 21:28:07 +00:00

110 lines
3.6 KiB
TypeScript

'use client';
/**
* AppStateMachine Provider
*
* Wraps the application with the app-level state machine.
* Provides state and send function to all child components via context.
* Also handles responsive mode detection and route synchronization.
*/
import { useEffect, useRef } from 'react';
import { useSelector } from '@xstate/react';
import { createActor } from 'xstate';
import { usePathname, useRouter } from 'next/navigation';
import { useMediaQuery } from '@mantine/hooks';
import { appMachine } from '@/lib/app-machine';
import { AppMachineContext } from '@/hooks/useAppMachine';
// Create the actor singleton outside the component to persist state
const appActor = createActor(appMachine);
appActor.start();
export function AppStateMachineProvider({ children }: { children: React.ReactNode }) {
const state = useSelector(appActor, (state) => state);
const send = appActor.send;
const pathname = usePathname();
const router = useRouter();
// Track if this is the initial mount
const isInitializedRef = useRef(false);
// Track the last path we navigated to, to prevent loops
const lastNavigatedPathRef = useRef<string | null>(null);
// Detect mobile vs desktop
const isMobile = useMediaQuery('(max-width: 768px)');
// Update mode in state machine
useEffect(() => {
send({ type: 'SET_MODE', mode: isMobile ? 'mobile' : 'desktop' });
}, [isMobile, send]);
// Initialize state machine from URL on first mount ONLY
useEffect(() => {
if (isInitializedRef.current) return;
console.log('[App Provider] Initializing state from URL:', pathname);
// Determine which state the current path corresponds to
let initialEvent: string | null = null;
if (pathname === '/chat') {
initialEvent = 'NAVIGATE_TO_CONVO';
} else if (pathname === '/edit') {
initialEvent = 'NAVIGATE_TO_EDIT';
} else if (pathname === '/galaxy' || pathname.startsWith('/galaxy/')) {
initialEvent = 'NAVIGATE_TO_GALAXY';
}
// Send the event to initialize state from URL
if (initialEvent) {
console.log('[App Provider] Setting initial state:', initialEvent);
send({ type: initialEvent as any });
}
// Mark as initialized AFTER sending the event
isInitializedRef.current = true;
}, [pathname, send]); // Remove 'state' from dependencies!
// State machine is source of truth: sync state → URL only
// This effect ONLY runs when state changes, not when pathname changes
useEffect(() => {
// Don't navigate until initialized
if (!isInitializedRef.current) {
return;
}
let targetPath: string | null = null;
if (state.matches('convo')) {
targetPath = '/chat';
} else if (state.matches('edit')) {
targetPath = '/edit';
} else if (state.matches('galaxy')) {
targetPath = '/galaxy';
}
// ONLY navigate if we need to change the pathname (not the whole URL)
// This preserves query params and prevents unnecessary navigation
if (targetPath && pathname !== targetPath) {
console.log('[App Provider] State machine navigating to:', targetPath);
lastNavigatedPathRef.current = targetPath;
router.push(targetPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.value]); // ONLY depend on state.value, NOT pathname or router!
// Log state changes
useEffect(() => {
console.log('[App Provider] State:', state.value);
console.log('[App Provider] Tags:', Array.from(state.tags));
console.log('[App Provider] Context:', state.context);
}, [state]);
return (
<AppMachineContext.Provider value={appActor}>
{children}
</AppMachineContext.Provider>
);
}