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

View File

@@ -1,208 +1,33 @@
# **File: COMMIT\_10\_LINKING.md**
# **File: COMMIT\_11\_VIZ.md**
## **Commit 10: Node Editor & AI-Powered Linking**
## **Commit 11: 3D "Thought Galaxy" Visualization**
### **Objective**
Build the node editor UI and the AI-powered "Find related" feature. This commit will:
Implement the 3D "Thought Galaxy" visualization using React Three Fiber (R3F). This commit addresses **Risk 3 (UMAP Projection)** by using the "Calculate My Graph" button strategy for the hackathon.
1. Create the editor page (/editor/\[id\]) that is pre-filled by the chat (Commit 07\) or loaded from the DB.
2. Implement the "Publish" button, which calls the /api/nodes route (from Commit 06).
3. Implement the "Find related" button, which calls a *new* /api/suggest-links route.
4. Implement the /api/suggest-links route, which generates an embedding for the current draft and uses SurrealDB's vector search to find similar nodes.15
1. Create an API route /api/calculate-graph that:
* Fetches all the user's node embeddings from SurrealDB.
* Uses umap-js to run dimensionality reduction from 1536-D down to 3-D.26
* Updates the coords\_3d field for each node in SurrealDB.
2. Create a client-side R3F component (/app/galaxy) that:
* Fetches all nodes *with* coords\_3d coordinates.
* Renders each node as a \<mesh\>.
* Renders links as \<Line\>.
* Uses \<CameraControls\> for smooth onClick interaction.
### **Implementation Specification**
**1\. Create Editor Page (app/editor/\[id\]/page.tsx)**
**1\. Create Graph Calculation API (app/api/calculate-graph/route.ts)**
Create a file at /app/editor/\[id\]/page.tsx:
TypeScript
'use client';
import {
Container,
Title,
TextInput,
Textarea,
Button,
Stack,
Paper,
Text,
LoadingOverlay,
Group,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useSearchParams, useRouter, useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
// Define the shape of a suggested link
interface SuggestedNode {
id: string;
title: string;
body: string;
score: number;
}
export default function EditorPage() {
const router \= useRouter();
const params \= useParams();
const searchParams \= useSearchParams();
const \[isPublishing, setIsPublishing\] \= useState(false);
const \[isFinding, setIsFinding\] \= useState(false);
const \= useState\<SuggestedNode\>();
const form \= useForm({
initialValues: {
title: '',
body: '',
links: as string, // Array of at-uri strings
},
});
// Pre-fill form from search params (from AI chat redirect)
useEffect(() \=\> {
if (params.id \=== 'new') {
const title \= searchParams.get('title') |
| '';
const body \= searchParams.get('body') |
| '';
form.setValues({ title, body });
} else {
// TODO: Load existing node from /api/nodes/\[id\]
}
}, \[params.id, searchParams\]);
// Handler for the "Publish" button (calls Commit 06 API)
const handlePublish \= async (values: typeof form.values) \=\> {
setIsPublishing(true);
try {
const response \= await fetch('/api/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (\!response.ok) {
throw new Error('Failed to publish node');
}
const newNode \= await response.json();
// On success, go to the graph
router.push('/galaxy');
} catch (error) {
console.error(error);
// TODO: Show notification
} finally {
setIsPublishing(false);
}
};
// Handler for the "Find related" button
const handleFindRelated \= async () \=\> {
setIsFinding(true);
setSuggestions();
try {
const response \= await fetch('/api/suggest-links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: form.values.body }),
});
if (\!response.ok) {
throw new Error('Failed to find links');
}
const relatedNodes \= await response.json();
setSuggestions(relatedNodes);
} catch (error) {
console.error(error);
// TODO: Show notification
} finally {
setIsFinding(false);
}
};
return (
\<Container size="md" py="xl"\>
\<form onSubmit={form.onSubmit(handlePublish)}\>
\<Stack gap="md"\>
\<Title order={2}\>
{params.id \=== 'new'? 'Create New Node' : 'Edit Node'}
\</Title\>
\<TextInput
label="Title"
placeholder="Your node title"
required
{...form.getInputProps('title')}
/\>
\<Textarea
label="Body"
placeholder="Your node content..."
required
minRows={10}
autosize
{...form.getInputProps('body')}
/\>
\<Group\>
\<Button
type="button"
variant="outline"
onClick={handleFindRelated}
loading={isFinding}
\>
Find Related
\</Button\>
\<Button type="submit" loading={isPublishing}\>
Publish Node
\</Button\>
\</Group\>
{/\* Related Links Section \*/}
\<Stack\>
{isFinding && \<LoadingOverlay visible /\>}
{suggestions.length \> 0 && \<Title order={4}\>Suggested Links\</Title\>}
{suggestions.map((node) \=\> (
\<Paper key={node.id} withBorder p="sm"\>
\<Text fw={700}\>{node.title}\</Text\>
\<Text size="sm" lineClamp={2}\>{node.body}\</Text\>
\<Text size="xs" c="dimmed"\>Similarity: {(node.score \* 100).toFixed(0)}%\</Text\>
\</Paper\>
))}
{\!isFinding && suggestions.length \=== 0 && (
\<Text size="sm" c="dimmed"\>
{/\* Placeholder text \*/}
\</Text\>
)}
\</Stack\>
\</Stack\>
\</form\>
\</Container\>
);
}
**2\. Create Link Suggestion API (app/api/suggest-links/route.ts)**
Create a file at /app/api/suggest-links/route.ts:
Create a file at /app/api/calculate-graph/route.ts:
TypeScript
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { connectToDB } from '@/lib/db';
import { generateEmbedding } from '@/lib/ai';
import { UMAP } from 'umap-js';
export async function POST(request: NextRequest) {
const surrealJwt \= cookies().get('ponderants-auth')?.value;
@@ -210,137 +35,329 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { body } \= await request.json();
if (\!body) {
return NextResponse.json({ error: 'Body text is required' }, { status: 400 });
}
try {
// 1\. Generate embedding for the current draft
const draftEmbedding \= await generateEmbedding(body);
// 2\. Connect to DB (as the user)
const db \= await connectToDB(surrealJwt);
// 3\. Run the vector similarity search query
// This query finds the 5 closest nodes in the 'node' table
// using cosine similarity on the 'embedding' field.
// It only searches nodes WHERE user\_did \= $token.did,
// which is enforced by the table's PERMISSIONS.
const query \= \`
SELECT
id,
title,
body,
atp\_uri,
vector::similarity::cosine(embedding, $draft\_embedding) AS score
FROM node
ORDER BY score DESC
LIMIT 5;
\`;
// 1\. Fetch all nodes that have an embedding but no coords
const query \= \`SELECT id, embedding FROM node WHERE embedding\!= NONE AND coords\_3d \= NONE\`;
const results \= await db.query(query);
const results \= await db.query(query, {
draft\_embedding: draftEmbedding,
const nodes \= results.result as { id: string; embedding: number };
if (nodes.length \< 3) {
// UMAP needs a few points to work well
return NextResponse.json({ message: 'Not enough nodes to map.' });
}
// 2\. Prepare data for UMAP
const embeddings \= nodes.map(n \=\> n.embedding);
// 3\. Run UMAP to reduce 1536-D to 3-D \[26\]
const umap \= new UMAP({
nComponents: 3,
nNeighbors: Math.min(15, nodes.length \- 1), // nNeighbors must be \< sample size
minDist: 0.1,
spread: 1.0,
});
// The query returns an array of result sets. We want the first one.
return NextResponse.json(results.result);
const coords\_3d\_array \= umap.fit(embeddings);
// 4\. Update nodes in SurrealDB with their new 3D coords
// This is run in a transaction for speed.
let transaction \= 'BEGIN TRANSACTION;';
nodes.forEach((node, index) \=\> {
const coords \= coords\_3d\_array\[index\];
transaction \+= \`UPDATE ${node.id} SET coords\_3d \= \[${coords}, ${coords}, ${coords}\];\`;
});
transaction \+= 'COMMIT TRANSACTION;';
await db.query(transaction);
return NextResponse.json({ success: true, nodes\_mapped: nodes.length });
} catch (error) {
console.error('Link suggestion error:', error);
console.error('Graph calculation error:', error);
return NextResponse.json(
{ error: 'Failed to suggest links' },
{ error: 'Failed to calculate graph' },
{ status: 500 }
);
}
}
**2\. Create Galaxy Page (app/galaxy/page.tsx)**
Create a file at /app/galaxy/page.tsx:
TypeScript
'use client';
import { Button, Box } from '@mantine/core';
import { Suspense, useState } from 'react';
import { ThoughtGalaxy } from '@/components/ThoughtGalaxy';
export default function GalaxyPage() {
const \[isCalculating, setIsCalculating\] \= useState(false);
// This key forces a re-render of the galaxy component
const \[galaxyKey, setGalaxyKey\] \= useState(Date.now());
const handleCalculateGraph \= async () \=\> {
setIsCalculating(true);
try {
await fetch('/api/calculate-graph', { method: 'POST' });
// Refresh the galaxy component by changing its key
setGalaxyKey(Date.now());
} catch (error) {
console.error(error);
// TODO: Show notification
} finally {
setIsCalculating(false);
}
};
return (
\<Box style={{ height: '100vh', width: '100vw', position: 'relative' }}\>
\<Button
onClick={handleCalculateGraph}
loading={isCalculating}
style={{ position: 'absolute', top: 20, left: 20, zIndex: 10 }}
\>
Calculate My Graph
\</Button\>
{/\* R3F Canvas for the 3D visualization \*/}
\<Suspense fallback={\<Box\>Loading 3D Scene...\</Box\>}\>
\<ThoughtGalaxy key={galaxyKey} /\>
\</SuspE\>
\</Box\>
);
}
**3\. Create 3D Component (components/ThoughtGalaxy.tsx)**
Create a file at /components/ThoughtGalaxy.tsx:
TypeScript
'use client';
import { Canvas, useLoader } from '@react-three/fiber';
import {
CameraControls,
Line,
Text,
useCursor,
} from '@react-three/drei';
import { Suspense, useEffect, useRef, useState } from 'react';
import \* as THREE from 'three';
import { Surreal } from 'surrealdb.js';
// Define the shape of nodes and links from DB
interface NodeData {
id: string;
title: string;
coords\_3d: \[number, number, number\];
}
interface LinkData {
in: string; // from node id
out: string; // to node id
}
// 1\. The 3D Node Component
function Node({ node, onNodeClick }) {
const \[hovered, setHovered\] \= useState(false);
const \[clicked, setClicked\] \= useState(false);
useCursor(hovered);
return (
\<mesh
position={node.coords\_3d}
onClick={(e) \=\> {
e.stopPropagation();
onNodeClick(node);
setClicked(\!clicked);
}}
onPointerOver={(e) \=\> {
e.stopPropagation();
setHovered(true);
}}
onPointerOut={() \=\> setHovered(false)}
\>
\<sphereGeometry args={\[0.1, 32, 32\]} /\>
\<meshStandardMaterial
color={hovered? '\#90c0ff' : '\#e9ecef'}
emissive={hovered? '\#90c0ff' : '\#e9ecef'}
emissiveIntensity={hovered? 0.5 : 0.1}
/\>
{/\* Show title on hover or click \*/}
{(hovered |
| clicked) && (
\<Text
position={\[0, 0.2, 0\]}
fontSize={0.1}
color="white"
anchorX="center"
anchorY="middle"
\>
{node.title}
\</Text\>
)}
\</mesh\>
);
}
// 2\. The Main Scene Component
export function ThoughtGalaxy() {
const \[nodes, setNodes\] \= useState\<NodeData\>();
const \[links, setLinks\] \= useState\<LinkData\>();
const cameraControlsRef \= useRef\<CameraControls\>(null);
// Fetch data from SurrealDB on mount
useEffect(() \=\> {
async function fetchData() {
// Client-side connection
const db \= new Surreal();
await db.connect(process.env.NEXT\_PUBLIC\_SURREALDB\_WSS\_URL\!);
// Get the token from the cookie (this is a hack,
// proper way is to use an API route)
const token \= document.cookie
.split('; ')
.find(row \=\> row.startsWith('ponderants-auth='))
?.split('=');
if (\!token) return;
await db.authenticate(token);
// Fetch nodes that have coordinates
const nodeResults \= await db.query(
'SELECT id, title, coords\_3d FROM node WHERE coords\_3d\!= NONE'
);
setNodes((nodeResults.result as NodeData) ||);
// Fetch links
const linkResults \= await db.query('SELECT in, out FROM links\_to');
setLinks((linkResults.result as LinkData) ||);
}
fetchData();
},);
// Map links to node positions
const linkLines \= links
.map((link) \=\> {
const startNode \= nodes.find((n) \=\> n.id \=== link.in);
const endNode \= nodes.find((n) \=\> n.id \=== link.out);
if (startNode && endNode) {
return {
start: startNode.coords\_3d,
end: endNode.coords\_3d,
};
}
return null;
})
.filter(Boolean) as { start: \[number, number, number\]; end: \[number, number, number\] };
// Camera animation
const handleNodeClick \= (node: NodeData) \=\> {
cameraControlsRef.current?.smoothTime \= 0.8;
cameraControlsRef.current?.setLookAt(
node.coords\_3d \+ 1,
node.coords\_3d \+ 1,
node.coords\_3d \+ 1,
...node.coords\_3d,
true // Enable smooth transition
);
};
return (
\<Canvas camera={{ position: , fov: 60 }}\>
\<ambientLight intensity={0.5} /\>
\<pointLight position={} intensity={1} /\>
\<CameraControls ref={cameraControlsRef} /\>
\<Suspense fallback={null}\>
\<group\>
{/\* Render all nodes \*/}
{nodes.map((node) \=\> (
\<Node
key={node.id}
node={node}
onNodeClick={handleNodeClick}
/\>
))}
{/\* Render all links \*/}
{linkLines.map((line, i) \=\> (
\<Line
key={i}
points={\[line.start, line.end\]}
color="\#495057" // gray
lineWidth={1}
/\>
))}
\</group\>
\</Suspense\>
\</Canvas\>
);
}
### **Test Specification**
**1\. Create Test File (tests/magnitude/10-linking.mag.ts)**
**1\. Create Test File (tests/magnitude/11-viz.mag.ts)**
Create a file at /tests/magnitude/10-linking.mag.ts:
Create a file at /tests/magnitude/11-viz.mag.ts:
TypeScript
import { test } from 'magnitude-test';
// Helper function to seed the database for this test
// Helper function to seed the database
async function seedDatabase(agent) {
// This would use a custom magnitude.run command or API
// to pre-populate the SurrealDB instance with mock nodes.
await agent.act('Seed the database with 3 nodes: "Node A", "Node B", "Node C"');
// "Node A" is about "dogs and cats"
// "Node B" is about "vector databases"
// "Node C" is about "ATproto"
await agent.act('Seed the database with 5 nodes (A, B, C, D, E) that have embeddings but NO coordinates');
}
test('\[Happy Path\] User can find related links for a draft', async (agent) \=\> {
test('\[Happy Path\] User can calculate and view 3D graph', async (agent) \=\> {
// Setup: Seed the DB
await seedDatabase(agent);
// Act: Navigate to the editor
await agent.act('Navigate to /editor/new');
// Act: Go to galaxy page
await agent.act('Navigate to /galaxy');
// Act: Fill out the form with a related idea
await agent.act(
'Enter "My New Post" into the "Title" input'
);
await agent.act(
'Enter "This idea is about vectors and databases, and how they work." into the "Body" textarea'
);
// Check: Canvas is empty (no nodes have coords yet)
await agent.check('The 3D canvas is visible');
await agent.check('The 3D canvas contains 0 node meshes');
// Act: Click the find related button
// (Mock the /api/suggest-links route to return "Node B")
await agent.act('Click the "Find Related" button');
// Act: Click the calculate button
// (Mock the /api/calculate-graph route to return success
// and trigger the component re-render)
await agent.act('Click the "Calculate My Graph" button');
// Check: The related node appears in the suggestions
await agent.check('A list of suggested links appears');
await agent.check('The suggested node "Node B" is visible in the list');
await agent.check('The suggested node "Node A" is NOT visible in the list');
});
test('\[Unhappy Path\] User sees empty state when no links found', async (agent) \=\> {
// Setup: Seed the DB
await seedDatabase(agent);
// Act: Navigate to the editor
await agent.act('Navigate to /editor/new');
// Act: Fill out the form with an unrelated idea
await agent.act(
'Enter "Zebras" into the "Title" input'
);
await agent.act(
'Enter "Zebras are striped equines." into the "Body" textarea'
);
// Act: Click the find related button
// (Mock the /api/suggest-links route to return an empty array)
await agent.act('Click the "Find Related" button');
// Check: An empty state is shown
await agent.check('The text "No related nodes found" is visible');
});
test('\[Happy Path\] User can publish a new node', async (agent) \=\> {
// Act: Navigate to the editor
await agent.act('Navigate to /editor/new');
// Act: Fill out the form
await agent.act(
'Enter "My First Published Node" into the "Title" input'
);
await agent.act(
'Enter "This is the body of my first node." into the "Body" textarea'
);
// Check: Loading state appears
await agent.check('The "Calculate My Graph" button shows a loading spinner');
// Act: Click Publish
// (Mock the /api/nodes route (Commit 06\) to return success)
await agent.act('Click the "Publish Node" button');
// Check: User is redirected to the galaxy
await agent.check(
'The browser URL is now "http://localhost:3000/galaxy"'
);
// (After mock API returns and component re-fetches)
// Check: The canvas now has nodes
await agent.check('The 3D canvas now contains 5 node meshes');
});
test('\[Interaction\] User can click on a node to focus', async (agent) \=\> {
// Setup: Seed the DB and pre-calculate the graph
await seedDatabase(agent);
await agent.act('Navigate to /galaxy');
await agent.act('Click the "Calculate My Graph" button');
await agent.check('The 3D canvas now contains 5 node meshes');
// Act: Click on a node
// (Magnitude can target R3F meshes by their properties)
await agent.act('Click on the 3D node mesh corresponding to "Node A"');
// Check: Camera moves
// (This is hard to check directly, but we can check
// for the side-effect: the text label appearing)
await agent.check('The camera animates and moves closer to the node');
await agent.check('A 3D text label "Node A" is visible');
});