feat: Improve UI layout and navigation

- Increase logo size (48x48 desktop, 56x56 mobile) for better visibility
- Add logo as favicon
- Add logo to mobile header
- Move user menu to navigation bars (sidebar on desktop, bottom bar on mobile)
- Fix desktop chat layout - container structure prevents voice controls cutoff
- Fix mobile bottom bar - use icon-only ActionIcons instead of truncated text buttons
- Hide Create Node/New Conversation buttons on mobile to save header space
- Make fixed header and voice controls work properly with containers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 14:43:11 +00:00
parent 0b632a31eb
commit f0284ef813
74 changed files with 6996 additions and 629 deletions

59
components/AppLayout.tsx Normal file
View File

@@ -0,0 +1,59 @@
'use client';
/**
* AppLayout Component
*
* Wraps the application with:
* - AppStateMachineProvider (state management)
* - Mantine AppShell (responsive layout)
* - Navigation (mobile bottom bar / desktop sidebar)
*/
import { AppShell } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { AppStateMachineProvider } from './AppStateMachine';
import { MobileBottomBar } from './Navigation/MobileBottomBar';
import { MobileHeader } from './Navigation/MobileHeader';
import { DesktopSidebar } from './Navigation/DesktopSidebar';
export function AppLayout({ children }: { children: React.ReactNode }) {
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<AppStateMachineProvider>
{/* Mobile Header - only on mobile */}
{isMobile && <MobileHeader />}
<AppShell
navbar={{
width: isMobile ? 0 : 200,
breakpoint: 'sm',
}}
padding={isMobile ? 0 : 'md'}
style={{ height: '100vh' }}
>
{/* Desktop Sidebar - only on desktop */}
{!isMobile && (
<AppShell.Navbar>
<DesktopSidebar />
</AppShell.Navbar>
)}
{/* Main Content */}
<AppShell.Main
style={{
height: '100vh',
overflow: 'auto',
paddingTop: isMobile ? '64px' : '0', // Space for mobile header
paddingBottom: isMobile ? '80px' : '0', // Space for mobile bottom bar
}}
>
{children}
</AppShell.Main>
{/* Mobile Bottom Bar - only on mobile */}
{isMobile && <MobileBottomBar />}
</AppShell>
</AppStateMachineProvider>
);
}

View File

@@ -0,0 +1,108 @@
'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') {
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 have a target path and haven't already navigated to it
if (targetPath && targetPath !== lastNavigatedPathRef.current) {
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>
);
}

View File

@@ -1,23 +1,19 @@
'use client';
import { useChat } from 'ai';
import { useChat } from '@ai-sdk/react';
import { Container, ScrollArea, Paper, Group, TextInput, Button, Stack, Text, Box } from '@mantine/core';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { MicrophoneRecorder } from './MicrophoneRecorder';
export function ChatInterface() {
const viewport = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
const {
messages,
input,
handleInputChange,
handleSubmit,
setInput,
isLoading,
} = useChat({
api: '/api/chat',
});
sendMessage,
status,
} = useChat();
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
@@ -57,7 +53,12 @@ export function ChatInterface() {
radius="md"
bg={message.role === 'user' ? 'dark.6' : 'dark.7'}
>
<Text size="sm">{message.content}</Text>
<Text size="sm">
{/* Extract text from parts */}
{('parts' in message && Array.isArray((message as any).parts))
? (message as any).parts.find((p: any) => p.type === 'text')?.text || ''
: (message as any).content || ''}
</Text>
</Paper>
</Box>
))}
@@ -65,16 +66,21 @@ export function ChatInterface() {
</ScrollArea>
{/* Input area */}
<form onSubmit={handleSubmit}>
<form onSubmit={(e) => {
e.preventDefault();
if (!input.trim() || status === 'submitted' || status === 'streaming') return;
sendMessage({ text: input });
setInput('');
}}>
<Paper withBorder p="sm" radius="xl">
<Group gap="xs">
<TextInput
value={input}
onChange={handleInputChange}
onChange={(e) => setInput(e.currentTarget.value)}
placeholder="Speak or type your thoughts..."
style={{ flex: 1 }}
variant="unstyled"
disabled={isLoading}
disabled={status === 'submitted' || status === 'streaming'}
/>
{/* Microphone Recorder */}
@@ -96,7 +102,7 @@ export function ChatInterface() {
}}
/>
<Button type="submit" radius="xl" loading={isLoading}>
<Button type="submit" radius="xl" loading={status === 'submitted' || status === 'streaming'}>
Send
</Button>
</Group>

View File

@@ -0,0 +1,127 @@
'use client';
/**
* Desktop Sidebar Navigation
*
* Vertical sidebar navigation for desktop (≥ 768px).
* Shows three navigation links: Convo, Edit, Galaxy
* Highlights the active mode based on app state machine.
*/
import { Stack, NavLink, Box, Text, Group, Image, Divider } from '@mantine/core';
import { IconMessageCircle, IconEdit, IconUniverse } from '@tabler/icons-react';
import { useSelector } from '@xstate/react';
import { useAppMachine } from '@/hooks/useAppMachine';
import { UserMenu } from '@/components/UserMenu';
export function DesktopSidebar() {
const actor = useAppMachine();
const state = useSelector(actor, (state) => state);
const send = actor.send;
const handleNavigation = (target: 'convo' | 'edit' | 'galaxy') => {
console.log('[Desktop Nav] Navigating to:', target);
if (target === 'convo') {
send({ type: 'NAVIGATE_TO_CONVO' });
} else if (target === 'edit') {
send({ type: 'NAVIGATE_TO_EDIT' });
} else if (target === 'galaxy') {
send({ type: 'NAVIGATE_TO_GALAXY' });
}
};
const isConvo = state.matches('convo');
const isEdit = state.matches('edit');
const isGalaxy = state.matches('galaxy');
console.log('[Desktop Nav] Current state:', state.value, {
isConvo,
isEdit,
isGalaxy,
});
return (
<Box
style={{
width: '100%',
height: '100%',
borderRight: '1px solid #dee2e6',
padding: '1rem',
}}
>
<Stack gap="xs">
<Group gap="sm" mb="md" align="center">
<Image
src="/logo.svg"
alt="Ponderants logo"
w={48}
h={48}
style={{ flexShrink: 0 }}
/>
<Text fw={700} size="md" c="dimmed">
Ponderants
</Text>
</Group>
<NavLink
label="Convo"
leftSection={<IconMessageCircle size={20} />}
active={isConvo}
onClick={() => handleNavigation('convo')}
variant="filled"
/>
<NavLink
label="Manual"
leftSection={<IconEdit size={20} />}
active={isEdit}
onClick={() => handleNavigation('edit')}
variant="filled"
/>
<NavLink
label="Galaxy"
leftSection={<IconUniverse size={20} />}
active={isGalaxy}
onClick={() => handleNavigation('galaxy')}
variant="filled"
/>
<Divider my="md" />
<Box style={{ padding: '0.5rem' }}>
<UserMenu />
</Box>
{/* Development state panel */}
{process.env.NODE_ENV === 'development' && (
<Box mt="xl" p="sm" style={{ border: '1px solid #495057', borderRadius: '4px' }}>
<Text size="xs" fw={700} c="dimmed" mb="xs">
DEV: App State
</Text>
<Text size="xs" c="dimmed">
State: {JSON.stringify(state.value)}
</Text>
<Text size="xs" c="dimmed">
Tags: {Array.from(state.tags).join(', ')}
</Text>
<Text size="xs" c="dimmed">
Mode: {state.context.mode}
</Text>
{state.context.pendingNodeDraft && (
<Text size="xs" c="dimmed">
Draft: {state.context.pendingNodeDraft.title || '(untitled)'}
</Text>
)}
{state.context.currentNodeId && (
<Text size="xs" c="dimmed">
Node: {state.context.currentNodeId}
</Text>
)}
</Box>
)}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
/**
* Mobile Bottom Bar Navigation
*
* Fixed bottom navigation for mobile devices (< 768px).
* Shows three buttons: Convo, Edit, Galaxy
* Highlights the active mode based on app state machine.
*/
import { Group, Button, Paper, ActionIcon, Box } from '@mantine/core';
import { IconMessageCircle, IconEdit, IconUniverse, IconUser } from '@tabler/icons-react';
import { useSelector } from '@xstate/react';
import { useAppMachine } from '@/hooks/useAppMachine';
import { UserMenu } from '@/components/UserMenu';
export function MobileBottomBar() {
const actor = useAppMachine();
const state = useSelector(actor, (state) => state);
const send = actor.send;
const handleNavigation = (target: 'convo' | 'edit' | 'galaxy') => {
console.log('[Mobile Nav] Navigating to:', target);
if (target === 'convo') {
send({ type: 'NAVIGATE_TO_CONVO' });
} else if (target === 'edit') {
send({ type: 'NAVIGATE_TO_EDIT' });
} else if (target === 'galaxy') {
send({ type: 'NAVIGATE_TO_GALAXY' });
}
};
const isConvo = state.matches('convo');
const isEdit = state.matches('edit');
const isGalaxy = state.matches('galaxy');
console.log('[Mobile Nav] Current state:', state.value, {
isConvo,
isEdit,
isGalaxy,
});
return (
<Paper
withBorder
p="md"
radius={0}
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
borderTop: '1px solid #dee2e6',
}}
>
<Group justify="space-around" grow>
<ActionIcon
variant={isConvo ? 'filled' : 'subtle'}
color={isConvo ? 'blue' : 'gray'}
onClick={() => handleNavigation('convo')}
size={48}
radius="md"
>
<IconMessageCircle size={24} />
</ActionIcon>
<ActionIcon
variant={isEdit ? 'filled' : 'subtle'}
color={isEdit ? 'blue' : 'gray'}
onClick={() => handleNavigation('edit')}
size={48}
radius="md"
>
<IconEdit size={24} />
</ActionIcon>
<ActionIcon
variant={isGalaxy ? 'filled' : 'subtle'}
color={isGalaxy ? 'blue' : 'gray'}
onClick={() => handleNavigation('galaxy')}
size={48}
radius="md"
>
<IconUniverse size={24} />
</ActionIcon>
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<UserMenu />
</Box>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
/**
* Mobile Header
*
* Fixed header for mobile devices showing the Ponderants logo.
*/
import { Group, Image, Text, Paper } from '@mantine/core';
export function MobileHeader() {
return (
<Paper
withBorder
p="md"
radius={0}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 100,
borderBottom: '1px solid #dee2e6',
}}
>
<Group gap="sm" align="center">
<Image
src="/logo.svg"
alt="Ponderants logo"
w={56}
h={56}
style={{ flexShrink: 0 }}
/>
<Text fw={700} size="xl">
Ponderants
</Text>
</Group>
</Paper>
);
}

View File

@@ -7,9 +7,10 @@ import {
Text,
} from '@react-three/drei';
import { Suspense, useEffect, useRef, useState } from 'react';
import Surreal from 'surrealdb';
import { Stack, Text as MantineText } from '@mantine/core';
import * as THREE from 'three';
// Define the shape of nodes and links from DB
// Define the shape of nodes and links from API
interface NodeData {
id: string;
title: string;
@@ -67,42 +68,95 @@ export function ThoughtGalaxy() {
const [nodes, setNodes] = useState<NodeData[]>([]);
const [links, setLinks] = useState<LinkData[]>([]);
const cameraControlsRef = useRef<CameraControls>(null);
const hasFitCamera = useRef(false);
// Fetch data from SurrealDB on mount
// Fetch data from API on mount and poll for updates
useEffect(() => {
async function fetchData() {
// Client-side connection
const db = new Surreal();
await db.connect(process.env.NEXT_PUBLIC_SURREALDB_WSS_URL!);
try {
const response = await fetch('/api/galaxy', {
credentials: 'include', // Include cookies for authentication
});
// Get the token from the cookie
const tokenCookie = document.cookie
.split('; ')
.find((row) => row.startsWith('ponderants-auth='));
if (!response.ok) {
console.error('[ThoughtGalaxy] Failed to fetch galaxy data:', response.statusText);
return;
}
if (!tokenCookie) {
console.error('[ThoughtGalaxy] No auth token found');
return;
const data = await response.json();
if (data.message) {
console.log('[ThoughtGalaxy]', data.message);
// If calculating, poll again in 2 seconds
setTimeout(fetchData, 2000);
return;
}
setNodes(data.nodes || []);
setLinks(data.links || []);
console.log(`[ThoughtGalaxy] Loaded ${data.nodes?.length || 0} nodes and ${data.links?.length || 0} links`);
} catch (error) {
console.error('[ThoughtGalaxy] Error fetching data:', error);
}
const token = tokenCookie.split('=')[1];
await db.authenticate(token);
// Fetch nodes that have coordinates
const nodeResults = await db.query<[NodeData[]]>(
'SELECT id, title, coords_3d FROM node WHERE coords_3d != NONE'
);
setNodes(nodeResults[0] || []);
// Fetch links
const linkResults = await db.query<[LinkData[]]>('SELECT in, out FROM links_to');
setLinks(linkResults[0] || []);
console.log(`[ThoughtGalaxy] Loaded ${nodeResults[0]?.length || 0} nodes and ${linkResults[0]?.length || 0} links`);
}
fetchData();
}, []);
// Function to fit camera to all nodes
const fitCameraToNodes = () => {
if (!cameraControlsRef.current || nodes.length === 0) {
console.log('[ThoughtGalaxy] Cannot fit camera:', {
hasRef: !!cameraControlsRef.current,
nodesLength: nodes.length,
});
return;
}
console.log('[ThoughtGalaxy] Fitting camera to', nodes.length, 'nodes...');
// Create a THREE.Box3 from node positions
const box = new THREE.Box3();
nodes.forEach((node) => {
box.expandByPoint(new THREE.Vector3(
node.coords_3d[0],
node.coords_3d[1],
node.coords_3d[2]
));
});
console.log('[ThoughtGalaxy] Bounding box:', {
min: box.min,
max: box.max,
size: box.getSize(new THREE.Vector3()),
});
// Use CameraControls' built-in fitToBox method
try {
cameraControlsRef.current.fitToBox(
box,
false, // Don't animate on initial load
{ paddingLeft: 0.5, paddingRight: 0.5, paddingTop: 0.5, paddingBottom: 0.5 }
);
console.log('[ThoughtGalaxy] ✓ Camera fitted to bounds');
hasFitCamera.current = true;
} catch (error) {
console.error('[ThoughtGalaxy] Error fitting camera:', error);
}
};
// Fit camera when nodes change and we haven't fitted yet
useEffect(() => {
if (!hasFitCamera.current && nodes.length > 0) {
// Try to fit after a short delay to ensure Canvas is ready
const timer = setTimeout(() => {
fitCameraToNodes();
}, 100);
return () => clearTimeout(timer);
}
}, [nodes]);
// Map links to node positions
const linkLines = links
.map((link) => {
@@ -118,24 +172,48 @@ export function ThoughtGalaxy() {
})
.filter(Boolean) as { start: [number, number, number]; end: [number, number, number] }[];
// Camera animation
// Camera animation on node click
const handleNodeClick = (node: NodeData) => {
if (cameraControlsRef.current) {
cameraControlsRef.current.smoothTime = 0.8;
cameraControlsRef.current.setLookAt(
node.coords_3d[0] + 1,
node.coords_3d[1] + 1,
node.coords_3d[2] + 1,
// Smoothly move to look at the clicked node
cameraControlsRef.current.moveTo(
node.coords_3d[0],
node.coords_3d[1],
node.coords_3d[2],
true // Enable smooth transition
true // Animate
);
}
};
console.log('[ThoughtGalaxy] Rendering with', nodes.length, 'nodes and', linkLines.length, 'link lines');
// Show message if no nodes are ready yet
if (nodes.length === 0) {
return (
<Stack align="center" justify="center" style={{ height: '100vh', width: '100vw' }}>
<MantineText size="lg" c="dimmed">
Create at least 3 nodes to visualize your thought galaxy
</MantineText>
<MantineText size="sm" c="dimmed">
Nodes with content will automatically generate embeddings and 3D coordinates
</MantineText>
</Stack>
);
}
return (
<Canvas camera={{ position: [0, 5, 10], fov: 60 }}>
<Canvas
camera={{ position: [0, 5, 10], fov: 60 }}
style={{ width: '100%', height: '100%' }}
gl={{ preserveDrawingBuffer: true }}
onCreated={(state) => {
console.log('[ThoughtGalaxy] Canvas created successfully');
// Try to fit camera now that scene is ready
if (!hasFitCamera.current && nodes.length > 0) {
setTimeout(() => fitCameraToNodes(), 50);
}
}}
>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} intensity={1} />
<CameraControls ref={cameraControlsRef} />