Compare commits

...

4 Commits

Author SHA1 Message Date
d072b71eec refactor: Improve debug panel delete handler and add debug endpoint
- Refactored UserMenu debug panel delete handler to match ThoughtGalaxy pattern
- Added proper error handling with Mantine notifications
- Added loading state management during delete operations
- Created /api/nodes/debug endpoint for development debugging
- Cleaned up debug logging from DELETE endpoint

The debug panel now uses the same delete pattern as ThoughtGalaxy for consistency,
with proper error notifications and state updates.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 02:32:34 +00:00
63c955c848 feat: Add delete functionality for user-authored nodes
- Add DELETE /api/nodes/[id] endpoint for deleting nodes
- Verify user authentication and ownership before deletion
- Delete from ATproto (source of truth) first, then SurrealDB cache
- Add delete button in ThoughtGalaxy component for user's own nodes
- Add confirmation modal before deletion
- Fix Modal z-index to appear above node detail panel (zIndex: 1001)
- Fix RecordId encoding issue (strip angle brackets ⟨⟩ from IDs)
- Remove deleted node and associated links from local state
- Add comprehensive Magnitude tests for delete functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 01:49:48 +00:00
a4739bddc1 test: Update theme tests for SegmentedControl in profile dropdown
Changes:
- Updated all theme tests to reflect new UI where theme selector is in profile dropdown
- Tests now use three-option SegmentedControl (light/dark/auto) instead of toggle button
- Added authentication flow to tests since profile dropdown requires login
- Updated test assertions to check for icon-based selection (sun, moon, desktop)
- Tests cover all three modes: light, dark, and auto/system preference
- Verify selected state indication in SegmentedControl
- Updated persistence tests to work with new UI flow

All theme tests now accurately test the current implementation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 01:30:11 +00:00
57d5405c41 feat: Move theme toggle to profile dropdown with icon-only SegmentedControl
Changes:
- Moved theme toggle from separate DesktopSidebar component into UserMenu dropdown
- Replaced simple light/dark toggle with SegmentedControl offering three options:
  - Light (sun icon)
  - Dark (moon icon)
  - System/Auto (desktop icon)
- Uses icon-only labels for compact display in dropdown menu
- Defaults to 'auto' mode (respects system preference) as configured in layout.tsx
- Removed standalone ThemeToggle component from DesktopSidebar

Benefits:
- Cleaner navigation UI with one less separate control
- Better UX with system preference option
- More compact dropdown menu layout

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 01:26:33 +00:00
8 changed files with 792 additions and 73 deletions

132
app/api/nodes/[id]/route.ts Normal file
View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { Agent } from '@atproto/api';
import { connectToDB } from '@/lib/db';
import { verifySurrealJwt } from '@/lib/auth/jwt';
import { getOAuthClient } from '@/lib/auth/oauth-client';
/**
* DELETE /api/nodes/[id]
*
* Deletes a node from both ATproto (source of truth) and SurrealDB (cache).
*
* Process:
* 1. Verify user authentication and ownership
* 2. Fetch node from SurrealDB to get atp_uri
* 3. Delete post(s) from ATproto/Bluesky
* 4. Delete node from SurrealDB cache
*
* Note: ATproto is the source of truth. If ATproto deletion fails, we don't delete from cache.
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const cookieStore = await cookies();
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
console.log('[DELETE /api/nodes/[id]] Auth check:', {
hasSurrealJwt: !!surrealJwt,
});
if (!surrealJwt) {
console.error('[DELETE /api/nodes/[id]] Missing auth cookie');
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Verify the JWT and extract user info
const userSession = verifySurrealJwt(surrealJwt);
if (!userSession) {
console.error('[DELETE /api/nodes/[id]] Invalid JWT');
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
}
const { did: userDid } = userSession;
const { id } = await params;
console.log('[DELETE /api/nodes/[id]] Deleting node:', { nodeId: id, userDid });
try {
// 1. Fetch the node from SurrealDB to verify ownership and get atp_uri
const db = await connectToDB();
const nodeResult = await db.query<[Array<{ id: string; user_did: string; atp_uri: string }>]>(
'SELECT id, user_did, atp_uri FROM node WHERE id = $nodeId',
{ nodeId: id }
);
const node = nodeResult[0]?.[0];
if (!node) {
console.error('[DELETE /api/nodes/[id]] Node not found:', id);
return NextResponse.json({ error: 'Node not found' }, { status: 404 });
}
// 2. Verify ownership
if (node.user_did !== userDid) {
console.error('[DELETE /api/nodes/[id]] Unauthorized: user does not own node');
return NextResponse.json(
{ error: 'You do not have permission to delete this node' },
{ status: 403 }
);
}
// 3. Delete from ATproto (source of truth)
try {
const client = await getOAuthClient();
console.log('[DELETE /api/nodes/[id]] Got OAuth client, restoring session for DID:', userDid);
const session = await client.restore(userDid);
const agent = new Agent(session);
console.log('[DELETE /api/nodes/[id]] Successfully restored OAuth session');
// Parse the atp_uri to get repo and rkey
// Format: at://did:plc:xxx/app.bsky.feed.post/xxxxx
const atUriMatch = node.atp_uri.match(/at:\/\/([^/]+)\/([^/]+)\/(.+)/);
if (!atUriMatch) {
throw new Error(`Invalid atp_uri format: ${node.atp_uri}`);
}
const [, repo, collection, rkey] = atUriMatch;
console.log('[DELETE /api/nodes/[id]] Deleting ATproto record:', {
repo,
collection,
rkey,
});
// Delete the post from ATproto
await agent.api.com.atproto.repo.deleteRecord({
repo,
collection,
rkey,
});
console.log('[DELETE /api/nodes/[id]] ✓ Deleted post from ATproto');
} catch (error) {
console.error('[DELETE /api/nodes/[id]] ATproto deletion error:', error);
return NextResponse.json(
{ error: 'Failed to delete post from Bluesky. Node not deleted.' },
{ status: 500 }
);
}
// 4. Delete from SurrealDB cache (only after successful ATproto deletion)
try {
await db.delete(id);
console.log('[DELETE /api/nodes/[id]] ✓ Deleted node from SurrealDB cache');
} catch (error) {
console.warn('[DELETE /api/nodes/[id]] ⚠ SurrealDB cache deletion failed (non-critical):', error);
// This is non-critical since ATproto (source of truth) was successfully deleted
}
return NextResponse.json({ success: true, nodeId: id });
} catch (error) {
console.error('[DELETE /api/nodes/[id]] Error:', error);
return NextResponse.json(
{ error: 'Failed to delete node' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { connectToDB } from '@/lib/db';
import { verifySurrealJwt } from '@/lib/auth/jwt';
/**
* GET /api/nodes/debug
*
* Debug endpoint to list all nodes for the current user in SurrealDB.
* Only available in development mode.
*/
export async function GET() {
// Only allow in development
if (process.env.NODE_ENV !== 'development') {
return NextResponse.json({ error: 'Not available in production' }, { status: 403 });
}
const cookieStore = await cookies();
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
console.log('[DEBUG /api/nodes/debug] Auth check:', {
hasSurrealJwt: !!surrealJwt,
});
if (!surrealJwt) {
console.error('[DEBUG /api/nodes/debug] Missing auth cookie');
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// Verify the JWT and extract user info
const userSession = verifySurrealJwt(surrealJwt);
if (!userSession) {
console.error('[DEBUG /api/nodes/debug] Invalid JWT');
return NextResponse.json({ error: 'Invalid auth token' }, { status: 401 });
}
const { did: userDid } = userSession;
try {
const db = await connectToDB();
// Fetch ALL nodes for this user (no filters)
const nodesResult = await db.query<
[Array<{ id: string; title: string; body: string; user_did: string; atp_uri: string }>]
>('SELECT id, title, body, user_did, atp_uri FROM node WHERE user_did = $userDid', {
userDid,
});
const nodes = nodesResult[0] || [];
console.log('[DEBUG /api/nodes/debug] Found nodes:', {
count: nodes.length,
userDid,
nodeIds: nodes.map((n) => n.id),
});
return NextResponse.json({
nodes,
userDid,
count: nodes.length,
});
} catch (error) {
console.error('[DEBUG /api/nodes/debug] Error:', error);
return NextResponse.json({ error: 'Failed to fetch nodes' }, { status: 500 });
}
}

View File

@@ -13,7 +13,6 @@ import { IconMessageCircle, IconEdit, IconChartBubbleFilled } from '@tabler/icon
import { useSelector } from '@xstate/react';
import { useAppMachine } from '@/hooks/useAppMachine';
import { UserMenu } from '@/components/UserMenu';
import { ThemeToggle } from '@/components/ThemeToggle';
import styles from './DesktopSidebar.module.css';
export function DesktopSidebar() {
@@ -106,10 +105,7 @@ export function DesktopSidebar() {
<Divider my="md" />
{/* Theme Toggle */}
<ThemeToggle />
{/* User Menu - styled like other nav items */}
{/* User Menu - styled like other nav items, now includes theme toggle */}
<UserMenu showLabel={true} />
{/* Development state panel */}

View File

@@ -7,7 +7,9 @@ import {
Text,
} from '@react-three/drei';
import { Suspense, useEffect, useRef, useState } from 'react';
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme } from '@mantine/core';
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button, Modal } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import * as THREE from 'three';
@@ -96,6 +98,9 @@ export function ThoughtGalaxy() {
const [links, setLinks] = useState<LinkData[]>([]);
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
const [emptyMessage, setEmptyMessage] = useState<string | null>(null);
const [currentUserDid, setCurrentUserDid] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const cameraControlsRef = useRef<CameraControls>(null);
const hasFitCamera = useRef(false);
const hasFocusedNode = useRef<string | null>(null);
@@ -104,6 +109,28 @@ export function ThoughtGalaxy() {
const selectedNodeId = searchParams.get('node');
const targetUserDid = searchParams.get('user'); // For viewing someone else's galaxy
// Fetch current user's profile to get their DID
useEffect(() => {
async function fetchCurrentUser() {
try {
const response = await fetch('/api/user/profile', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setCurrentUserDid(data.did);
}
} catch (error) {
console.error('[ThoughtGalaxy] Error fetching current user:', error);
}
}
// Only fetch current user if we're viewing our own galaxy
if (!targetUserDid) {
fetchCurrentUser();
}
}, [targetUserDid]);
// Fetch data from API on mount and poll for updates
useEffect(() => {
async function fetchData() {
@@ -290,6 +317,51 @@ export function ThoughtGalaxy() {
router.replace(`${pathname}${newSearch ? `?${newSearch}` : ''}`, { scroll: false });
};
// Handle deleting a node
const handleDeleteNode = async () => {
if (!selectedNode) return;
setIsDeleting(true);
setDeleteConfirmOpen(false);
try {
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
const cleanId = String(selectedNode.id).replace(/[⟨⟩]/g, '');
const response = await fetch(`/api/nodes/${cleanId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete node');
}
notifications.show({
title: 'Node deleted',
message: 'Node has been deleted from Bluesky and your galaxy',
color: 'green',
});
// Remove the node from local state
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== selectedNode.id));
setLinks((prevLinks) => prevLinks.filter((l) => l.in !== selectedNode.id && l.out !== selectedNode.id));
// Close the modal
handleCloseModal();
} catch (error) {
console.error('[ThoughtGalaxy] Delete error:', error);
notifications.show({
title: 'Delete failed',
message: error instanceof Error ? error.message : 'Failed to delete node',
color: 'red',
});
} finally {
setIsDeleting(false);
}
};
console.log('[ThoughtGalaxy] Rendering with', nodes.length, 'nodes and', linkLines.length, 'link lines');
// Show message if no nodes are ready yet
@@ -360,15 +432,30 @@ export function ThoughtGalaxy() {
<Title order={2} style={{ margin: 0, marginBottom: '0.25rem' }}>
{selectedNode.title}
</Title>
<Anchor
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
target="_blank"
rel="noopener noreferrer"
size="sm"
c="dimmed"
>
View on Bluesky
</Anchor>
<Group gap="sm" mt="xs">
<Anchor
href={`https://bsky.app/profile/${selectedNode.user_did}/post/${selectedNode.atp_uri.split('/').pop()}`}
target="_blank"
rel="noopener noreferrer"
size="sm"
c="dimmed"
>
View on Bluesky
</Anchor>
{/* Show delete button only for user's own nodes */}
{currentUserDid && selectedNode.user_did === currentUserDid && (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => setDeleteConfirmOpen(true)}
loading={isDeleting}
>
Delete
</Button>
)}
</Group>
</Box>
<CloseButton
size="lg"
@@ -394,6 +481,43 @@ export function ThoughtGalaxy() {
</Box>
)}
{/* Delete confirmation modal */}
<Modal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
title="Delete Node"
centered
zIndex={1001}
>
<Stack gap="md">
<MantineText>
Are you sure you want to delete this node? This will:
</MantineText>
<Stack gap="xs" ml="md">
<MantineText size="sm"> Remove the post from Bluesky</MantineText>
<MantineText size="sm"> Delete the node from your galaxy</MantineText>
<MantineText size="sm" fw={600} c="red">This action cannot be undone.</MantineText>
</Stack>
<Group justify="flex-end" gap="sm">
<Button
variant="subtle"
onClick={() => setDeleteConfirmOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
color="red"
onClick={handleDeleteNode}
loading={isDeleting}
leftSection={<IconTrash size={16} />}
>
Delete Permanently
</Button>
</Group>
</Stack>
</Modal>
<Canvas
camera={{ position: [0, 5, 10], fov: 60 }}
style={{ width: '100%', height: '100%' }}

View File

@@ -1,7 +1,10 @@
'use client';
import { useState, useEffect } from 'react';
import { Menu, Avatar, NavLink, ActionIcon } from '@mantine/core';
import { Menu, Avatar, NavLink, ActionIcon, SegmentedControl, Text, Stack, ScrollArea, Code } from '@mantine/core';
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
interface UserProfile {
@@ -11,10 +14,20 @@ interface UserProfile {
avatar: string | null;
}
interface Node {
id: string;
title: string;
user_did: string;
}
export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
const router = useRouter();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [nodes, setNodes] = useState<Node[]>([]);
const [nodesLoading, setNodesLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
// Fetch user profile on mount
@@ -33,6 +46,62 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
});
}, []);
// Fetch user's nodes for debugging
const fetchNodes = async () => {
setNodesLoading(true);
try {
const response = await fetch('/api/nodes/debug', {
credentials: 'include',
});
const data = await response.json();
if (!data.error) {
setNodes(data.nodes || []);
}
} catch (error) {
console.error('Failed to fetch nodes:', error);
} finally {
setNodesLoading(false);
}
};
// Delete a node (debug) - Matches ThoughtGalaxy delete pattern
const handleDebugDelete = async (nodeId: string) => {
setIsDeleting(true);
try {
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
const cleanId = String(nodeId).replace(/[⟨⟩]/g, '');
const response = await fetch(`/api/nodes/${cleanId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete node');
}
notifications.show({
title: 'Node deleted',
message: 'Node has been deleted from Bluesky and your galaxy',
color: 'green',
});
// Update local state to remove the deleted node
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeId));
} catch (error) {
console.error('[UserMenu Debug] Delete error:', error);
notifications.show({
title: 'Delete failed',
message: error instanceof Error ? error.message : 'Failed to delete node',
color: 'red',
});
} finally {
setIsDeleting(false);
}
};
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
@@ -129,6 +198,109 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
@{profile.handle}
</span>
</Menu.Label>
<Menu.Divider />
{/* Theme Selection */}
<div style={{ padding: '8px 12px' }}>
<Text size="xs" fw={500} c="dimmed" mb={8}>
Theme
</Text>
<SegmentedControl
value={colorScheme}
onChange={(value) => setColorScheme(value as 'light' | 'dark' | 'auto')}
data={[
{
value: 'light',
label: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<IconSun size={16} />
</div>
),
},
{
value: 'dark',
label: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<IconMoon size={16} />
</div>
),
},
{
value: 'auto',
label: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<IconDeviceDesktop size={16} />
</div>
),
},
]}
fullWidth
size="xs"
/>
</div>
{/* Debug: Show all nodes */}
{process.env.NODE_ENV === 'development' && (
<>
<Menu.Divider />
<Menu.Label>Debug: SurrealDB Nodes</Menu.Label>
<div style={{ padding: '8px 12px' }}>
<Text size="xs" fw={500} c="dimmed" mb={8}>
<button
onClick={fetchNodes}
style={{
background: 'none',
border: 'none',
color: 'inherit',
textDecoration: 'underline',
cursor: 'pointer',
padding: 0,
}}
>
{nodesLoading ? 'Loading...' : `Fetch Nodes (${nodes.length})`}
</button>
</Text>
{nodes.length > 0 && (
<ScrollArea h={200}>
<Stack gap="xs">
{nodes.map((node) => (
<div key={node.id} style={{ fontSize: '0.7rem', marginBottom: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<Text size="xs" fw={600}>{node.title}</Text>
<button
onClick={() => {
if (confirm(`Delete "${node.title}"?`)) {
handleDebugDelete(node.id);
}
}}
style={{
background: '#fa5252',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '2px 6px',
cursor: 'pointer',
fontSize: '0.65rem',
}}
>
Delete
</button>
</div>
<Code block style={{ fontSize: '0.65rem' }}>{node.id}</Code>
</div>
))}
</Stack>
</ScrollArea>
)}
{nodes.length === 0 && !nodesLoading && (
<Text size="xs" c="red">
No nodes found in SurrealDB
</Text>
)}
</div>
</>
)}
<Menu.Divider />
<Menu.Item onClick={handleLogout} c="red">
Log out

View File

@@ -1,38 +1,86 @@
import { test } from 'magnitude-test';
test('Theme toggle switches between light and dark modes', async (agent) => {
// Act: Navigate to the homepage
await agent.act('Navigate to the homepage');
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
// Check: Verify the page loads with a theme
await agent.check('The page has either a light or dark background');
if (!TEST_HANDLE || !TEST_PASSWORD) {
throw new Error('TEST_BLUESKY_HANDLE and TEST_BLUESKY_PASSWORD must be set in .env');
}
// Act: Click the theme toggle button
await agent.act('Click the theme toggle button');
test('Theme selector in profile dropdown has three options', async (agent) => {
// Act: Log in first (theme selector is in authenticated area)
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Wait for theme transition
await agent.act('Wait for 1 second');
// Act: Open the profile dropdown menu
await agent.act('Click on the profile avatar or "Profile" button');
// Check: Verify the theme has changed
await agent.check('The background color has changed to the opposite theme');
// Check: Verify the theme selector is visible with three options
await agent.check('A "Theme" label is visible in the dropdown menu');
await agent.check('A segmented control with three icon buttons is visible');
await agent.check('A sun icon button is visible (for light mode)');
await agent.check('A moon icon button is visible (for dark mode)');
await agent.check('A desktop/monitor icon button is visible (for system/auto mode)');
});
// Act: Click the theme toggle button again
await agent.act('Click the theme toggle button');
test('Theme can be changed between light, dark, and auto modes', async (agent) => {
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Wait for theme transition
await agent.act('Wait for 1 second');
// Act: Open profile dropdown and select light mode
await agent.act('Click on the profile avatar');
await agent.act('Click the sun icon in the theme selector');
// Check: Verify the theme has changed back
await agent.check('The background color has changed back to the original theme');
// Check: Verify light mode is active
await agent.check('The page has a light background color');
await agent.check('The sun icon button appears selected/highlighted');
// Act: Switch to dark mode
await agent.act('Click on the profile avatar');
await agent.act('Click the moon icon in the theme selector');
await agent.act('Wait for 500 milliseconds');
// Check: Verify dark mode is active
await agent.check('The page has a dark background color');
await agent.check('The moon icon button appears selected/highlighted');
// Act: Switch to auto/system mode
await agent.act('Click on the profile avatar');
await agent.act('Click the desktop icon in the theme selector');
await agent.act('Wait for 500 milliseconds');
// Check: Verify auto mode is selected
await agent.check('The desktop icon button appears selected/highlighted');
});
test('Light mode displays correct colors', async (agent) => {
// Act: Navigate to the homepage
await agent.act('Navigate to the homepage');
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Act: Ensure we're in light mode by toggling if needed
await agent.act('If the background is dark, click the theme toggle button');
await agent.act('Wait for 1 second');
// Act: Set to light mode via profile dropdown
await agent.act('Click on the profile avatar');
await agent.act('Click the sun icon in the theme selector');
await agent.act('Wait for 500 milliseconds');
// Check: Verify light mode colors
await agent.check('The page has a light background color');
@@ -42,12 +90,20 @@ test('Light mode displays correct colors', async (agent) => {
});
test('Dark mode displays correct colors', async (agent) => {
// Act: Navigate to the homepage
await agent.act('Navigate to the homepage');
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Act: Ensure we're in dark mode by toggling if needed
await agent.act('If the background is light, click the theme toggle button');
await agent.act('Wait for 1 second');
// Act: Set to dark mode via profile dropdown
await agent.act('Click on the profile avatar');
await agent.act('Click the moon icon in the theme selector');
await agent.act('Wait for 500 milliseconds');
// Check: Verify dark mode colors
await agent.check('The page has a dark background color');
@@ -57,12 +113,20 @@ test('Dark mode displays correct colors', async (agent) => {
});
test('Theme persists across page refreshes', async (agent) => {
// Act: Navigate to the homepage
await agent.act('Navigate to the homepage');
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Act: Set to light mode
await agent.act('If the background is dark, click the theme toggle button');
await agent.act('Wait for 1 second');
await agent.act('Click on the profile avatar');
await agent.act('Click the sun icon in the theme selector');
await agent.act('Wait for 500 milliseconds');
// Act: Refresh the page
await agent.act('Refresh the page');
@@ -72,8 +136,9 @@ test('Theme persists across page refreshes', async (agent) => {
await agent.check('The page still has a light background color');
// Act: Switch to dark mode
await agent.act('Click the theme toggle button');
await agent.act('Wait for 1 second');
await agent.act('Click on the profile avatar');
await agent.act('Click the moon icon in the theme selector');
await agent.act('Wait for 500 milliseconds');
// Act: Refresh the page again
await agent.act('Refresh the page');
@@ -84,12 +149,20 @@ test('Theme persists across page refreshes', async (agent) => {
});
test('Theme affects all UI components', async (agent) => {
// Act: Navigate to the homepage
await agent.act('Navigate to the homepage');
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Act: Ensure light mode
await agent.act('If the background is dark, click the theme toggle button');
await agent.act('Wait for 1 second');
// Act: Set to light mode
await agent.act('Click on the profile avatar');
await agent.act('Click the sun icon in the theme selector');
await agent.act('Wait for 500 milliseconds');
// Check: Verify all components use light theme
await agent.check('The navigation sidebar uses light colors');
@@ -97,8 +170,9 @@ test('Theme affects all UI components', async (agent) => {
await agent.check('All buttons and inputs use light theme styling');
// Act: Switch to dark mode
await agent.act('Click the theme toggle button');
await agent.act('Wait for 1 second');
await agent.act('Click on the profile avatar');
await agent.act('Click the moon icon in the theme selector');
await agent.act('Wait for 500 milliseconds');
// Check: Verify all components use dark theme
await agent.check('The navigation sidebar uses dark colors');
@@ -106,21 +180,35 @@ test('Theme affects all UI components', async (agent) => {
await agent.check('All buttons and inputs use dark theme styling');
});
test('Theme toggle icon changes based on current theme', async (agent) => {
// Act: Navigate to the homepage
await agent.act('Navigate to the homepage');
test('Theme selector correctly indicates selected theme', async (agent) => {
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Act: Ensure light mode
await agent.act('If the background is dark, click the theme toggle button');
await agent.act('Wait for 1 second');
// Act: Set to light mode
await agent.act('Click on the profile avatar');
await agent.act('Click the sun icon in the theme selector');
// Check: Verify icon shows moon (indicating can switch to dark)
await agent.check('The theme toggle button shows a moon icon');
// Check: Verify sun icon is highlighted/selected
await agent.check('The sun icon button appears selected or highlighted');
// Act: Switch to dark mode
await agent.act('Click the theme toggle button');
await agent.act('Wait for 1 second');
await agent.act('Click on the profile avatar');
await agent.act('Click the moon icon in the theme selector');
// Check: Verify icon shows sun (indicating can switch to light)
await agent.check('The theme toggle button shows a sun icon');
// Check: Verify moon icon is highlighted/selected
await agent.check('The moon icon button appears selected or highlighted');
// Act: Switch to auto mode
await agent.act('Click on the profile avatar');
await agent.act('Click the desktop icon in the theme selector');
// Check: Verify desktop icon is highlighted/selected
await agent.check('The desktop icon button appears selected or highlighted');
});

View File

@@ -0,0 +1,137 @@
import { test } from 'magnitude-test';
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
if (!TEST_HANDLE || !TEST_PASSWORD) {
throw new Error('TEST_BLUESKY_HANDLE and TEST_BLUESKY_PASSWORD must be set in .env');
}
test('User can delete their own node from galaxy view', async (agent) => {
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Act: Create a test node via chat
await agent.act('Type "This is a test node for deletion" into the chat input');
await agent.act('Press Enter or click send');
await agent.check('AI responds with a message');
// Act: Trigger node creation
await agent.act('Wait for the AI to suggest creating a node or manually trigger node creation');
await agent.check('A node draft is created in the editor');
// Act: Publish the node
await agent.act('Click the "Publish" button');
await agent.check('A success notification appears');
await agent.check('The node is published to Bluesky');
// Act: Navigate to Galaxy view
await agent.act('Click the "Galaxy" navigation link');
await agent.check('The galaxy visualization loads');
await agent.check('At least one node is visible in the 3D galaxy view');
// Act: Click on the newly created node
await agent.act('Click on the test node in the galaxy view');
await agent.check('A node detail panel opens showing the node title and body');
await agent.check('The node detail panel shows "This is a test node for deletion"');
// Check: Verify delete button is visible (only for user\'s own nodes)
await agent.check('A "Delete" button is visible in the node detail panel');
// Act: Click the delete button
await agent.act('Click the "Delete" button');
// Check: Verify delete confirmation modal appears
await agent.check('A delete confirmation modal appears');
await agent.check('The modal is displayed above the node detail panel');
await agent.check('The modal shows "Are you sure you want to delete this node?"');
await agent.check('The modal explains this will remove the post from Bluesky');
await agent.check('The modal has a "Delete Permanently" button');
await agent.check('The modal has a "Cancel" button');
// Act: Confirm deletion
await agent.act('Click the "Delete Permanently" button');
// Check: Verify deletion succeeded
await agent.check('A success notification appears saying "Node deleted"');
await agent.check('The node detail panel closes');
await agent.check('The node is no longer visible in the galaxy view');
// Act: Verify node is deleted from Bluesky
await agent.act('Navigate to the user\'s Bluesky profile');
await agent.check('The test node "This is a test node for deletion" is not visible on Bluesky');
});
test('Delete button is not shown for other users\' nodes', async (agent) => {
// This test would require viewing another user's public galaxy
// Skipping for now as it requires a second test account
await agent.act('Skip this test - requires second test account');
});
test('Cancel button closes delete confirmation without deleting', async (agent) => {
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Act: Navigate to Galaxy view
await agent.act('Click the "Galaxy" navigation link');
await agent.check('The galaxy visualization loads');
// Act: Click on any existing node
await agent.act('Click on any node in the galaxy view');
await agent.check('A node detail panel opens');
// Act: Click the delete button
await agent.act('Click the "Delete" button');
await agent.check('A delete confirmation modal appears');
// Act: Click cancel
await agent.act('Click the "Cancel" button');
// Check: Verify modal closes and node is still there
await agent.check('The delete confirmation modal closes');
await agent.check('The node detail panel is still open');
await agent.check('The node is still visible in the galaxy view');
});
test('Node deletion removes associated links', async (agent) => {
// Act: Log in
await agent.act('Navigate to /login');
await agent.act(`Type "${TEST_HANDLE}" into the "Your Handle" input field`);
await agent.act('Click the "Log in with Bluesky" button');
await agent.check('The page URL contains "bsky.social"');
await agent.act(`Type "${TEST_HANDLE}" into the username/identifier field`);
await agent.act(`Type "${TEST_PASSWORD}" into the password field`);
await agent.act('Click the submit/authorize button');
await agent.check('The page URL contains "/chat"');
// Act: Create two linked nodes
await agent.act('Create a first test node via chat');
await agent.act('Create a second test node that links to the first');
// Act: Navigate to Galaxy view
await agent.act('Click the "Galaxy" navigation link');
await agent.check('The galaxy visualization shows two nodes with a link between them');
// Act: Delete one of the nodes
await agent.act('Click on the first test node');
await agent.act('Click the "Delete" button');
await agent.act('Click "Delete Permanently"');
// Check: Verify the link is also removed
await agent.check('The link between the nodes is no longer visible');
await agent.check('Only one node remains in the galaxy');
});

14
todo.md
View File

@@ -6,11 +6,15 @@ Upcoming items that should be implemented (time-permitting):
playwright mcp testing as well as that of magnitude
- ADD MAGNITUDE TESTS FOR EVERYTHING, both existing and new additions
- stream the AI output to deepgram for faster synthesis
- fix the freaking galaxy node clicking -- when going directly to a node ID
link, it redirects to /chat; when clicking on a node in /galaxy (either
general or on a specific node ID url there), it closes the modal automatically
- dark mode/light mode favicon and overall app theme
- dark mode/light mode favicon
- fix the double border on desktop between sidebar and conversation actions UI
- delete "backup"/"old" page.tsx files
- allow ai to transition to edit in chat
- why wait for three nodes before umap?
- fix creation/display of node links
- render markdown
- fix the "new tables being created instead of adding to the proper table"
issues we're having with the other tables like we were having with the node
table and we're now having with at least the oauth session, oauth state, and
user tabless; it's probably happening with the link_to table as well but that
one doesn't have data because it seems like link creation is broken (see task
above to fix)