feat: Implement node deletion with shared modal and fix SurrealDB RecordId handling

Implements complete node deletion functionality for both galaxy view and debug panel:

**Core Changes:**
- Created shared DeleteNodeModal component used by both ThoughtGalaxy and UserMenu
- Modal provides consistent UX with proper confirmation messaging
- Deletion follows write-through cache pattern: ATproto first, then SurrealDB

**SurrealDB RecordId Fixes:**
- Fixed SELECT query to use type::thing($table, $recordId) for UUID-based RecordIds
- Fixed DELETE query to use type::thing() instead of db.delete() to handle dashes in UUIDs
- Without type::thing(), SurrealDB interprets dashes as subtraction operators

**Testing & Documentation:**
- Added comprehensive Magnitude tests for delete functionality (galaxy view and debug panel)
- Updated CLAUDE.md with complete testing workflow documentation
- Added pre-commit checklist requiring database verification and test execution
- Documented PlaywrightMCP manual testing process before Magnitude test writing

**Database Setup:**
- Configured docker-compose.yml to use environment variables for credentials
- Updated namespace/database to match .env configuration (ponderants/main)

**File Changes:**
- app/api/nodes/[id]/route.ts: Fixed RecordId query patterns (SELECT and DELETE)
- components/DeleteNodeModal.tsx: New shared modal component
- components/ThoughtGalaxy.tsx: Uses shared DeleteNodeModal
- components/UserMenu.tsx: Replaced browser confirm() with shared DeleteNodeModal
- tests/magnitude/03-delete-node.mag.ts: Added debug panel delete test
- AGENTS.md: Added testing workflow and pre-commit checklist documentation
- docker-compose.yml: Environment variable configuration

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-10 13:25:01 +00:00
parent d072b71eec
commit a520814771
7 changed files with 282 additions and 45 deletions

100
AGENTS.md
View File

@@ -17,6 +17,106 @@ EOF
These credentials should be used for all automated testing (Magnitude, Playwright) and manual testing when needed. Do not attempt to authenticate without using these credentials.
**Database Setup**: The application uses SurrealDB running in Docker Compose for the app cache layer:
1. Start the database services:
```bash
docker compose up -d
```
2. This starts two services:
- `surrealdb`: The main SurrealDB instance (port 8000)
- `surrealmcp`: SurrealMCP server for MCP access (port 8080)
3. Start the Next.js development server:
```bash
pnpm dev
```
4. To stop the services:
```bash
docker compose down
```
5. Configuration:
- SurrealDB runs in-memory mode (data is not persisted between restarts)
- Namespace: `ponderants`
- Database: `main`
- Credentials: `root/root`
**Note**: Always start docker compose services before starting the Next.js dev server to ensure the database is available.
**Testing Workflow**: All new features must follow a rigorous testing process before being committed:
1. **Manual Testing with Playwright MCP**:
- Use Playwright MCP tools to manually test all functionality interactively
- Test both happy paths (expected user flows) and unhappy paths (errors, edge cases)
- Document each step you verify during manual testing - these become test cases
- If you encounter issues during manual testing (e.g., 404 errors, unexpected behavior), investigate and fix them before proceeding
- Use the following pattern:
```
1. Navigate to the feature
2. Perform user actions (clicks, typing, etc.)
3. Verify expected outcomes
4. Test error scenarios
5. Verify cleanup/state updates
```
2. **Write Comprehensive Magnitude Tests**:
- After manually verifying functionality with Playwright MCP, write extensive Magnitude tests covering ALL verified behaviors
- Each manual test step should have a corresponding Magnitude test assertion
- Test files are located in `tests/magnitude/` with `.mag.ts` extension
- Use the test credentials from .env (TEST_BLUESKY_HANDLE, TEST_BLUESKY_PASSWORD)
- Include both happy path and unhappy path test cases
- Example test structure:
```typescript
import { test } from 'magnitude-test';
const TEST_HANDLE = process.env.TEST_BLUESKY_HANDLE;
const TEST_PASSWORD = process.env.TEST_BLUESKY_PASSWORD;
test('Feature description', async (agent) => {
await agent.act('Navigate to /page');
await agent.act('Perform user action');
await agent.check('Verify expected outcome');
});
```
3. **Reusable Playwright Scaffolding**:
- Abstract common patterns (auth, navigation, etc.) into helper files in `tests/playwright/helpers/`
- These helpers should be usable both during manual Playwright MCP testing AND by Magnitude tests
- Examples: `tests/playwright/helpers/chat.ts`, `tests/playwright/helpers/galaxy.ts`, `tests/playwright/helpers/node.ts`
- For auth setup, use Playwright's global setup pattern (see https://playwright.dev/docs/test-global-setup-teardown)
- Current auth setup: `tests/playwright/auth.setup.ts`
4. **Generating Playwright Code**:
- Use https://playwright.dev/docs/test-agents to generate Playwright test code when helpful
- This tool can convert natural language test descriptions into Playwright code
5. **Test Execution**:
- Run Magnitude tests: `pnpm test` or `npx magnitude`
- Ensure ALL tests pass before committing
- If tests fail, fix the implementation or update the tests to match the correct behavior
6. **Pre-Commit Checklist**:
- ✅ All manual testing with Playwright MCP completed and verified
- ✅ All Magnitude tests written and cover all verified functionality
- ✅ Database verified for expected state after operations (e.g., deletions actually removed records)
- ✅ Run ALL magnitude tests: `pnpm test`
- ✅ All tests passing
- ✅ No console errors or warnings in production code paths
- Only commit after ALL checklist items are complete
7. **Documentation**:
- Document test coverage in `tests/README.md`
- Add comments to complex test scenarios explaining the business logic being tested
**Testing Resources**:
- Playwright Global Setup/Teardown: https://playwright.dev/docs/test-global-setup-teardown
- Playwright Test Agents: https://playwright.dev/docs/test-agents
- Magnitude.run Documentation: https://magnitude.run/docs
- Project Test README: `tests/README.md`
You are an expert-level, full-stack AI coding agent. Your task is to implement
the "Ponderants" application. Product Vision: Ponderants is an AI-powered
thought partner that interviews a user to capture, structure, and visualize

View File

@@ -50,9 +50,13 @@ export async function DELETE(
// 1. Fetch the node from SurrealDB to verify ownership and get atp_uri
const db = await connectToDB();
// Parse the ID to extract table and record ID parts
// Format: "node:e9e38d09-c0f4-4834-a6ba-c92dfa4c0910" or "node:⟨e9e38d09-c0f4-4834-a6ba-c92dfa4c0910⟩"
const cleanId = id.replace(/^node:/, '').replace(/[⟨⟩]/g, '');
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 }
'SELECT id, user_did, atp_uri FROM node WHERE id = type::thing($table, $recordId)',
{ table: 'node', recordId: cleanId }
);
const node = nodeResult[0]?.[0];
@@ -114,7 +118,11 @@ export async function DELETE(
// 4. Delete from SurrealDB cache (only after successful ATproto deletion)
try {
await db.delete(id);
// Use type::thing() to properly construct the RecordId for deletion
await db.query('DELETE FROM type::thing($table, $recordId)', {
table: 'node',
recordId: cleanId,
});
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);

View File

@@ -0,0 +1,58 @@
'use client';
import { Modal, Stack, Text, Group, Button } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
interface DeleteNodeModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => void;
nodeTitle: string | null;
isDeleting: boolean;
}
export function DeleteNodeModal({
opened,
onClose,
onConfirm,
nodeTitle,
isDeleting,
}: DeleteNodeModalProps) {
return (
<Modal
opened={opened}
onClose={onClose}
title="Delete Node"
centered
zIndex={1001}
>
<Stack gap="md">
<Text>
Are you sure you want to delete &quot;{nodeTitle}&quot;? This will:
</Text>
<Stack gap="xs" ml="md">
<Text size="sm"> Remove the post from Bluesky</Text>
<Text size="sm"> Delete the node from your galaxy</Text>
<Text size="sm" fw={600} c="red">This action cannot be undone.</Text>
</Stack>
<Group justify="flex-end" gap="sm">
<Button
variant="subtle"
onClick={onClose}
disabled={isDeleting}
>
Cancel
</Button>
<Button
color="red"
onClick={onConfirm}
loading={isDeleting}
leftSection={<IconTrash size={16} />}
>
Delete Permanently
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -7,9 +7,10 @@ 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, Button, Modal } from '@mantine/core';
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme, Button } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { DeleteNodeModal } from './DeleteNodeModal';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import * as THREE from 'three';
@@ -482,41 +483,13 @@ export function ThoughtGalaxy() {
)}
{/* Delete confirmation modal */}
<Modal
<DeleteNodeModal
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>
onConfirm={handleDeleteNode}
nodeTitle={selectedNode?.title || null}
isDeleting={isDeleting}
/>
<Canvas
camera={{ position: [0, 5, 10], fov: 60 }}

View File

@@ -6,6 +6,7 @@ import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconSun, IconMoon, IconDeviceDesktop } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { DeleteNodeModal } from './DeleteNodeModal';
interface UserProfile {
did: string;
@@ -28,6 +29,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
const [nodes, setNodes] = useState<Node[]>([]);
const [nodesLoading, setNodesLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
useEffect(() => {
// Fetch user profile on mount
@@ -65,12 +68,15 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
};
// Delete a node (debug) - Matches ThoughtGalaxy delete pattern
const handleDebugDelete = async (nodeId: string) => {
const handleDebugDelete = async () => {
if (!nodeToDelete) return;
setIsDeleting(true);
setDeleteConfirmOpen(false);
try {
// Extract clean ID from SurrealDB RecordId format (removes angle brackets ⟨⟩)
const cleanId = String(nodeId).replace(/[⟨⟩]/g, '');
const cleanId = String(nodeToDelete.id).replace(/[⟨⟩]/g, '');
const response = await fetch(`/api/nodes/${cleanId}`, {
method: 'DELETE',
@@ -89,7 +95,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
});
// Update local state to remove the deleted node
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeId));
setNodes((prevNodes) => prevNodes.filter((n) => n.id !== nodeToDelete.id));
setNodeToDelete(null);
} catch (error) {
console.error('[UserMenu Debug] Delete error:', error);
notifications.show({
@@ -269,9 +276,8 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
<Text size="xs" fw={600}>{node.title}</Text>
<button
onClick={() => {
if (confirm(`Delete "${node.title}"?`)) {
handleDebugDelete(node.id);
}
setNodeToDelete(node);
setDeleteConfirmOpen(true);
}}
style={{
background: '#fa5252',
@@ -282,6 +288,7 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
cursor: 'pointer',
fontSize: '0.65rem',
}}
disabled={isDeleting}
>
Delete
</button>
@@ -306,6 +313,15 @@ export function UserMenu({ showLabel = false }: { showLabel?: boolean } = {}) {
Log out
</Menu.Item>
</Menu.Dropdown>
{/* Delete confirmation modal - shared with ThoughtGalaxy */}
<DeleteNodeModal
opened={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={handleDebugDelete}
nodeTitle={nodeToDelete?.title || null}
isDeleting={isDeleting}
/>
</Menu>
);
}

View File

@@ -8,12 +8,27 @@ services:
- --log
- trace
- --user
- root
- ${SURREALDB_USER}
- --pass
- root
- ${SURREALDB_PASS}
- memory
volumes:
- ./surreal/data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5
surrealmcp:
image: surrealdb/surrealmcp:latest
command: >
start
--bind-address 0.0.0.0:8080
--server-url http://localhost:8080
-e ws://surrealdb:8000/rpc
--ns ${SURREALDB_NS} --db ${SURREALDB_DB} -u ${SURREALDB_USER} -p ${SURREALDB_PASS}
ports:
- "8080:8080"
depends_on:
- surrealdb

View File

@@ -135,3 +135,70 @@ test('Node deletion removes associated links', async (agent) => {
await agent.check('The link between the nodes is no longer visible');
await agent.check('Only one node remains in the galaxy');
});
test('User can delete node from debug panel in Profile menu', 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 "Test node for debug panel 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 and publish
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');
await agent.act('Click the "Publish" button');
await agent.check('A success notification appears');
// Act: Open Profile menu
await agent.act('Click the "Profile" button in the navigation sidebar');
await agent.check('The Profile menu opens');
// Check: Verify debug panel is visible (development mode only)
await agent.check('A "Debug: SurrealDB Nodes" section is visible');
// Act: Fetch nodes from debug panel
await agent.act('Click the "Fetch Nodes" button');
await agent.check('The button shows a count greater than 0');
await agent.check('At least one node is listed in the debug panel');
// Check: Verify the test node appears
await agent.check('The node "Test node for debug panel deletion" is visible');
// Act: Click delete button in debug panel
await agent.act('Click the "Delete" button next to the test node');
// Check: Verify delete confirmation modal appears
await agent.check('A delete confirmation modal appears');
await agent.check('The modal is displayed above the profile menu');
await agent.check('The modal shows the node title "Test node for debug panel deletion"');
await agent.check('The modal explains this will remove the post from Bluesky');
await agent.check('The modal shows "This action cannot be undone"');
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 notification says "Node has been deleted from Bluesky and your galaxy"');
await agent.check('The modal closes');
// Check: Verify node is removed from debug panel
await agent.check('The "Fetch Nodes" button shows a count of 0 or the node is no longer in the list');
// Act: Verify node is deleted from Bluesky and database
await agent.act('Refresh the page');
await agent.act('Click the "Profile" button again');
await agent.act('Click the "Fetch Nodes" button');
await agent.check('The node "Test node for debug panel deletion" is not in the list');
});