docs: Add comprehensive implementation plans for all todo items
Created detailed markdown plans for all items in todo.md: 1. 01-playwright-scaffolding.md - Base Playwright infrastructure 2. 02-magnitude-tests-comprehensive.md - Complete test coverage 3. 03-stream-ai-to-deepgram-tts.md - TTS latency optimization 4. 04-fix-galaxy-node-clicking.md - Galaxy navigation bugs 5. 05-dark-light-mode-theme.md - Dark/light mode with dynamic favicons 6. 06-fix-double-border-desktop.md - UI polish 7. 07-delete-backup-files.md - Code cleanup 8. 08-ai-transition-to-edit.md - Intelligent node creation flow 9. 09-umap-minimum-nodes-analysis.md - Technical analysis Each plan includes: - Detailed problem analysis - Proposed solutions with code examples - Manual Playwright MCP testing strategy - Magnitude test specifications - Implementation steps - Success criteria Ready to implement in sequence. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
404
plans/01-playwright-scaffolding.md
Normal file
404
plans/01-playwright-scaffolding.md
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
# Plan: Add Playwright Scaffolding Files
|
||||||
|
|
||||||
|
**Priority:** CRITICAL - Must be done first
|
||||||
|
**Dependencies:** None
|
||||||
|
**Affects:** All future testing work
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Create base Playwright scaffolding files to improve efficiency of both manual Playwright MCP testing and Magnitude test automation. This infrastructure will make it easier to write, run, and maintain browser-based tests.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- No Playwright configuration file exists
|
||||||
|
- No test helpers or utilities for common operations
|
||||||
|
- No fixtures for authenticated states
|
||||||
|
- Manual testing with Playwright MCP is inefficient
|
||||||
|
- Magnitude tests will lack reusable components
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
Create a complete Playwright testing infrastructure with:
|
||||||
|
|
||||||
|
### 1. Core Configuration Files
|
||||||
|
|
||||||
|
#### `playwright.config.ts`
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/playwright',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
// Setup authentication
|
||||||
|
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||||
|
|
||||||
|
// Desktop browsers
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: 'tests/playwright/.auth/user.json',
|
||||||
|
},
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Firefox'],
|
||||||
|
storageState: 'tests/playwright/.auth/user.json',
|
||||||
|
},
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mobile browsers
|
||||||
|
{
|
||||||
|
name: 'mobile-chrome',
|
||||||
|
use: {
|
||||||
|
...devices['Pixel 5'],
|
||||||
|
storageState: 'tests/playwright/.auth/user.json',
|
||||||
|
},
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm dev',
|
||||||
|
url: 'http://localhost:3000',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.gitignore` updates
|
||||||
|
```
|
||||||
|
# Playwright
|
||||||
|
tests/playwright/.auth/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Authentication Setup
|
||||||
|
|
||||||
|
#### `tests/playwright/auth.setup.ts`
|
||||||
|
```typescript
|
||||||
|
import { test as setup, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const authFile = 'tests/playwright/.auth/user.json';
|
||||||
|
|
||||||
|
setup('authenticate', async ({ page }) => {
|
||||||
|
// Check if auth already exists and is valid
|
||||||
|
const authCookie = process.env.PLAYWRIGHT_AUTH_COOKIE;
|
||||||
|
|
||||||
|
if (authCookie) {
|
||||||
|
// Use existing auth from environment
|
||||||
|
await page.context().addCookies([{
|
||||||
|
name: 'ponderants-auth',
|
||||||
|
value: authCookie,
|
||||||
|
domain: 'localhost',
|
||||||
|
path: '/',
|
||||||
|
}]);
|
||||||
|
} else {
|
||||||
|
// Perform OAuth login
|
||||||
|
await page.goto('/');
|
||||||
|
await page.click('text=Log in with Bluesky');
|
||||||
|
|
||||||
|
// Wait for OAuth redirect
|
||||||
|
await page.waitForURL(/bsky\.social/);
|
||||||
|
|
||||||
|
// Fill in credentials
|
||||||
|
await page.fill('input[name="identifier"]', process.env.TEST_USER_HANDLE!);
|
||||||
|
await page.fill('input[name="password"]', process.env.TEST_USER_PASSWORD!);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
|
// Wait for redirect back to app
|
||||||
|
await page.waitForURL(/localhost:3000/);
|
||||||
|
|
||||||
|
// Verify we're authenticated
|
||||||
|
await expect(page.getByRole('button', { name: /profile/i })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save signed-in state
|
||||||
|
await page.context().storageState({ path: authFile });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Fixtures and Helpers
|
||||||
|
|
||||||
|
#### `tests/playwright/fixtures.ts`
|
||||||
|
```typescript
|
||||||
|
import { test as base, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
type Fixtures = {
|
||||||
|
authenticatedPage: Page;
|
||||||
|
chatPage: Page;
|
||||||
|
galaxyPage: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const test = base.extend<Fixtures>({
|
||||||
|
authenticatedPage: async ({ page }, use) => {
|
||||||
|
// Already authenticated via setup
|
||||||
|
await page.goto('/');
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
|
||||||
|
chatPage: async ({ page }, use) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
|
||||||
|
galaxyPage: async ({ page }, use) => {
|
||||||
|
await page.goto('/galaxy');
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tests/playwright/helpers/chat.ts`
|
||||||
|
```typescript
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export class ChatHelper {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
async sendMessage(message: string) {
|
||||||
|
await this.page.fill('textarea[placeholder*="Type"]', message);
|
||||||
|
await this.page.press('textarea[placeholder*="Type"]', 'Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForAIResponse() {
|
||||||
|
// Wait for typing indicator to disappear
|
||||||
|
await this.page.waitForSelector('[data-testid="typing-indicator"]', {
|
||||||
|
state: 'hidden',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNode() {
|
||||||
|
await this.page.click('button:has-text("Create Node")');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastMessage() {
|
||||||
|
const messages = this.page.locator('[data-testid="chat-message"]');
|
||||||
|
const count = await messages.count();
|
||||||
|
return messages.nth(count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tests/playwright/helpers/galaxy.ts`
|
||||||
|
```typescript
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export class GalaxyHelper {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
async waitForGalaxyLoad() {
|
||||||
|
// Wait for canvas to be visible
|
||||||
|
await this.page.waitForSelector('canvas', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Wait for nodes to be loaded (check for at least one node)
|
||||||
|
await this.page.waitForFunction(() => {
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
return canvas && canvas.getContext('2d') !== null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickNode(nodeId: string) {
|
||||||
|
// Find and click on a specific node
|
||||||
|
await this.page.evaluate((id) => {
|
||||||
|
const event = new CustomEvent('node-click', { detail: { nodeId: id } });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNodeCount() {
|
||||||
|
return await this.page.evaluate(() => {
|
||||||
|
// Access R3F scene data
|
||||||
|
return window.__galaxyNodes?.length || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tests/playwright/helpers/node.ts`
|
||||||
|
```typescript
|
||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export class NodeHelper {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
async createNode(title: string, body: string) {
|
||||||
|
// Navigate to chat
|
||||||
|
await this.page.goto('/chat');
|
||||||
|
|
||||||
|
// Trigger node creation
|
||||||
|
await this.page.fill('input[placeholder*="Title"]', title);
|
||||||
|
await this.page.fill('textarea[placeholder*="Body"]', body);
|
||||||
|
await this.page.click('button:has-text("Publish")');
|
||||||
|
|
||||||
|
// Wait for success
|
||||||
|
await expect(this.page.getByText(/published/i)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForUMAPCalculation() {
|
||||||
|
// Poll /api/galaxy until nodes have coords_3d
|
||||||
|
await this.page.waitForFunction(async () => {
|
||||||
|
const response = await fetch('/api/galaxy');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.nodes.every((n: any) => n.coords_3d !== null);
|
||||||
|
}, { timeout: 60000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Example Tests
|
||||||
|
|
||||||
|
#### `tests/playwright/smoke.spec.ts`
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
|
test.describe('Smoke Tests', () => {
|
||||||
|
test('homepage loads', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/Ponderants/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated user can access chat', async ({ chatPage }) => {
|
||||||
|
await expect(chatPage.getByRole('textbox')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated user can access galaxy', async ({ galaxyPage }) => {
|
||||||
|
await expect(galaxyPage.locator('canvas')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Environment Variables
|
||||||
|
|
||||||
|
#### `.env.test` (template)
|
||||||
|
```bash
|
||||||
|
# Test user credentials
|
||||||
|
TEST_USER_HANDLE=your-test-handle.bsky.social
|
||||||
|
TEST_USER_PASSWORD=your-test-password
|
||||||
|
|
||||||
|
# Optional: Pre-authenticated cookie for faster tests
|
||||||
|
PLAYWRIGHT_AUTH_COOKIE=
|
||||||
|
|
||||||
|
# Playwright settings
|
||||||
|
PLAYWRIGHT_BASE_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Create directory structure**
|
||||||
|
```bash
|
||||||
|
mkdir -p tests/playwright/.auth
|
||||||
|
mkdir -p tests/playwright/helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
```bash
|
||||||
|
pnpm add -D @playwright/test
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create configuration files**
|
||||||
|
- playwright.config.ts
|
||||||
|
- .gitignore updates
|
||||||
|
- .env.test template
|
||||||
|
|
||||||
|
4. **Create authentication setup**
|
||||||
|
- auth.setup.ts
|
||||||
|
|
||||||
|
5. **Create fixtures and helpers**
|
||||||
|
- fixtures.ts
|
||||||
|
- helpers/chat.ts
|
||||||
|
- helpers/galaxy.ts
|
||||||
|
- helpers/node.ts
|
||||||
|
|
||||||
|
6. **Create example tests**
|
||||||
|
- smoke.spec.ts
|
||||||
|
|
||||||
|
7. **Update package.json scripts**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test:playwright": "playwright test",
|
||||||
|
"test:playwright:ui": "playwright test --ui",
|
||||||
|
"test:playwright:debug": "playwright test --debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
### Manual Playwright MCP Testing
|
||||||
|
1. Open Playwright MCP browser
|
||||||
|
2. Navigate to http://localhost:3000
|
||||||
|
3. Test authentication flow
|
||||||
|
4. Test chat interaction
|
||||||
|
5. Test node creation
|
||||||
|
6. Test galaxy visualization
|
||||||
|
7. Test node detail modal
|
||||||
|
|
||||||
|
### Magnitude Test Integration
|
||||||
|
After manual testing succeeds, create magnitude tests that use the same patterns:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('User can create and view nodes in galaxy', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.act('Log in with test credentials');
|
||||||
|
await agent.check('User is authenticated');
|
||||||
|
|
||||||
|
await agent.act('Navigate to chat');
|
||||||
|
await agent.act('Create a new node with title "Test Node" and body "Test content"');
|
||||||
|
await agent.check('Node is published to Bluesky');
|
||||||
|
|
||||||
|
await agent.act('Navigate to galaxy');
|
||||||
|
await agent.check('Can see the new node in 3D visualization');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ Playwright configuration file exists
|
||||||
|
- ✅ Authentication setup works (both OAuth and cookie-based)
|
||||||
|
- ✅ Helper classes for chat, galaxy, and node operations exist
|
||||||
|
- ✅ Example smoke tests pass
|
||||||
|
- ✅ Can run `pnpm test:playwright` successfully
|
||||||
|
- ✅ Manual Playwright MCP testing is efficient with helpers
|
||||||
|
- ✅ Foundation exists for Magnitude test creation
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
1. `playwright.config.ts` - Main configuration
|
||||||
|
2. `tests/playwright/auth.setup.ts` - Authentication setup
|
||||||
|
3. `tests/playwright/fixtures.ts` - Custom fixtures
|
||||||
|
4. `tests/playwright/helpers/chat.ts` - Chat helper functions
|
||||||
|
5. `tests/playwright/helpers/galaxy.ts` - Galaxy helper functions
|
||||||
|
6. `tests/playwright/helpers/node.ts` - Node helper functions
|
||||||
|
7. `tests/playwright/smoke.spec.ts` - Example smoke tests
|
||||||
|
8. `.env.test` - Environment variables template
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
|
||||||
|
1. `.gitignore` - Add Playwright artifacts
|
||||||
|
2. `package.json` - Add test scripts
|
||||||
|
3. `README.md` - Document testing approach (optional)
|
||||||
569
plans/02-magnitude-tests-comprehensive.md
Normal file
569
plans/02-magnitude-tests-comprehensive.md
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
# Plan: Add Comprehensive Magnitude Tests for All Features
|
||||||
|
|
||||||
|
**Priority:** CRITICAL - Must be done second (after Playwright scaffolding)
|
||||||
|
**Dependencies:** 01-playwright-scaffolding.md
|
||||||
|
**Affects:** Code quality, regression prevention, production confidence
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Create comprehensive Magnitude test coverage for ALL existing and new features. Every user flow must be tested fully, including both happy paths and unhappy paths.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- No Magnitude tests exist
|
||||||
|
- No test coverage for critical flows (auth, chat, node creation, galaxy)
|
||||||
|
- No regression testing
|
||||||
|
- Production deployments lack confidence
|
||||||
|
|
||||||
|
## Test Coverage Required
|
||||||
|
|
||||||
|
### 1. Authentication Flow Tests
|
||||||
|
|
||||||
|
#### `tests/magnitude/auth.mag.ts`
|
||||||
|
|
||||||
|
**Happy Paths:**
|
||||||
|
- User can log in with valid Bluesky credentials
|
||||||
|
- User can log out successfully
|
||||||
|
- User session persists across page refreshes
|
||||||
|
- User can access protected routes after authentication
|
||||||
|
|
||||||
|
**Unhappy Paths:**
|
||||||
|
- Login fails with invalid credentials
|
||||||
|
- Login fails with non-existent handle
|
||||||
|
- Unauthenticated user redirected from protected routes
|
||||||
|
- Session expiry handled gracefully
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('User can log in with valid credentials', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.check('Login page is visible');
|
||||||
|
|
||||||
|
await agent.act('Click "Log in with Bluesky" button')
|
||||||
|
.data({
|
||||||
|
handle: process.env.TEST_USER_HANDLE,
|
||||||
|
password: process.env.TEST_USER_PASSWORD
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.check('User is redirected to Bluesky OAuth');
|
||||||
|
await agent.check('User is redirected back to Ponderants');
|
||||||
|
await agent.check('User sees authenticated interface');
|
||||||
|
await agent.check('Profile menu is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Login fails with invalid credentials', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.act('Click "Log in with Bluesky" button')
|
||||||
|
.data({
|
||||||
|
handle: 'invalid-user.bsky.social',
|
||||||
|
password: 'wrongpassword'
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.check('Error message is displayed');
|
||||||
|
await agent.check('User remains on login page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User session persists across refresh', async (agent) => {
|
||||||
|
// First login
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.act('Log in with valid credentials');
|
||||||
|
await agent.check('User is authenticated');
|
||||||
|
|
||||||
|
// Refresh page
|
||||||
|
await agent.act('Refresh the page');
|
||||||
|
await agent.check('User is still authenticated');
|
||||||
|
await agent.check('Profile menu is still visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User can log out', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.act('Log in with valid credentials');
|
||||||
|
await agent.check('User is authenticated');
|
||||||
|
|
||||||
|
await agent.act('Click profile menu');
|
||||||
|
await agent.act('Click "Log out" button');
|
||||||
|
await agent.check('User is redirected to login page');
|
||||||
|
await agent.check('Session is cleared');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Chat Interface Tests
|
||||||
|
|
||||||
|
#### `tests/magnitude/chat.mag.ts`
|
||||||
|
|
||||||
|
**Happy Paths:**
|
||||||
|
- User can send text message and receive AI response
|
||||||
|
- User can use voice input and receive AI response
|
||||||
|
- User can see typing indicator while AI is thinking
|
||||||
|
- User can create a node from conversation
|
||||||
|
- User can select AI persona
|
||||||
|
- Chat history persists
|
||||||
|
|
||||||
|
**Unhappy Paths:**
|
||||||
|
- Empty message submission is prevented
|
||||||
|
- Voice input fails gracefully without microphone permission
|
||||||
|
- AI error displays user-friendly message
|
||||||
|
- Network error handled with retry option
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('User can send message and receive AI response', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.act('Type "What is the meaning of life?" into chat input');
|
||||||
|
await agent.act('Press Enter to send');
|
||||||
|
|
||||||
|
await agent.check('Message appears in chat history');
|
||||||
|
await agent.check('Typing indicator is visible');
|
||||||
|
await agent.check('AI response appears after typing indicator disappears');
|
||||||
|
await agent.check('Response contains thoughtful content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User can use voice input', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.act('Click microphone button');
|
||||||
|
await agent.check('Recording indicator is visible');
|
||||||
|
|
||||||
|
await agent.act('Speak "Hello, how are you?"');
|
||||||
|
await agent.act('Click microphone button to stop recording');
|
||||||
|
|
||||||
|
await agent.check('Transcribed text appears in chat');
|
||||||
|
await agent.check('AI response is generated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User can create node from conversation', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.act('Have a conversation about "quantum computing"');
|
||||||
|
await agent.check('At least 3 messages exchanged');
|
||||||
|
|
||||||
|
await agent.act('Click "Create Node" button');
|
||||||
|
await agent.check('Node editor opens with AI-generated draft');
|
||||||
|
await agent.check('Title is populated');
|
||||||
|
await agent.check('Body contains conversation insights');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Empty message submission is prevented', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.act('Press Enter without typing anything');
|
||||||
|
await agent.check('No message is sent');
|
||||||
|
await agent.check('Send button remains disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI error displays user-friendly message', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
// Simulate network error by blocking API calls
|
||||||
|
await agent.act('Send a message while API is blocked');
|
||||||
|
|
||||||
|
await agent.check('Error notification appears');
|
||||||
|
await agent.check('Error message is user-friendly');
|
||||||
|
await agent.check('Retry option is available');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Node Creation and Publishing Tests
|
||||||
|
|
||||||
|
#### `tests/magnitude/nodes.mag.ts`
|
||||||
|
|
||||||
|
**Happy Paths:**
|
||||||
|
- User can create a simple node (title + body)
|
||||||
|
- Node is published to Bluesky as a post
|
||||||
|
- Node is published to Bluesky as a thread (long content)
|
||||||
|
- Node appears in local cache (SurrealDB)
|
||||||
|
- Embedding is generated for node
|
||||||
|
- Node can link to other existing nodes
|
||||||
|
- AI suggests relevant node links
|
||||||
|
- Node appears in galaxy after creation
|
||||||
|
|
||||||
|
**Unhappy Paths:**
|
||||||
|
- Node creation fails without title
|
||||||
|
- Node creation fails without body
|
||||||
|
- Bluesky API error handled gracefully
|
||||||
|
- Embedding generation failure doesn't block node creation
|
||||||
|
- Link suggestion failure doesn't block node creation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('User can create a simple node', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.act('Click "New Node" button');
|
||||||
|
await agent.check('Node editor opens');
|
||||||
|
|
||||||
|
await agent.act('Type "My First Thought" into title field');
|
||||||
|
await agent.act('Type "This is my first ponderant about philosophy" into body field');
|
||||||
|
await agent.act('Click "Publish" button');
|
||||||
|
|
||||||
|
await agent.check('Success notification appears');
|
||||||
|
await agent.check('Node is published to Bluesky');
|
||||||
|
await agent.check('Node appears in cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Long node is published as thread', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
const longBody = 'This is a very long thought that exceeds 300 graphemes. '.repeat(20);
|
||||||
|
|
||||||
|
await agent.act('Create new node');
|
||||||
|
await agent.act(`Type "Long Thought" into title`);
|
||||||
|
await agent.act(`Type long content into body`).data({ body: longBody });
|
||||||
|
await agent.act('Click "Publish" button');
|
||||||
|
|
||||||
|
await agent.check('Node is published as Bluesky thread');
|
||||||
|
await agent.check('Thread contains multiple posts');
|
||||||
|
await agent.check('First post contains link to detail page');
|
||||||
|
await agent.check('Thread indicators show (2/3), (3/3), etc.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Node with emojis handles grapheme counting correctly', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
const emojiContent = '🎉 '.repeat(100) + 'Hello world! 👋 ';
|
||||||
|
|
||||||
|
await agent.act('Create new node');
|
||||||
|
await agent.act('Type "Emoji Test" into title');
|
||||||
|
await agent.act('Type emoji content into body').data({ body: emojiContent });
|
||||||
|
await agent.act('Click "Publish" button');
|
||||||
|
|
||||||
|
await agent.check('Node is published without exceeding 300 grapheme limit');
|
||||||
|
await agent.check('Emojis are counted as 1 grapheme each');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Node creation fails without title', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.act('Create new node');
|
||||||
|
await agent.act('Type "Some body content" into body field');
|
||||||
|
await agent.act('Click "Publish" button without entering title');
|
||||||
|
|
||||||
|
await agent.check('Validation error appears');
|
||||||
|
await agent.check('Error message says "Title is required"');
|
||||||
|
await agent.check('Node is not published');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Node links to existing nodes', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
// Create first node
|
||||||
|
await agent.act('Create node titled "Philosophy Basics"');
|
||||||
|
await agent.check('First node is published');
|
||||||
|
|
||||||
|
// Create second node that links to first
|
||||||
|
await agent.act('Create new node titled "Advanced Philosophy"');
|
||||||
|
await agent.check('AI suggests linking to "Philosophy Basics"');
|
||||||
|
await agent.act('Accept suggested link');
|
||||||
|
await agent.act('Publish node');
|
||||||
|
|
||||||
|
await agent.check('Node is published with link');
|
||||||
|
await agent.check('Graph relationship exists in database');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Galaxy Visualization Tests
|
||||||
|
|
||||||
|
#### `tests/magnitude/galaxy.mag.ts`
|
||||||
|
|
||||||
|
**Happy Paths:**
|
||||||
|
- Galaxy loads and displays nodes with 3D coordinates
|
||||||
|
- User can rotate/zoom/pan the galaxy
|
||||||
|
- User can click on a node to view details
|
||||||
|
- Node detail modal displays correct information
|
||||||
|
- User can navigate from modal to chat
|
||||||
|
- User can navigate from modal to node detail page
|
||||||
|
- Empty state shows when no nodes exist
|
||||||
|
- UMAP calculation triggers automatically after 3rd node
|
||||||
|
|
||||||
|
**Unhappy Paths:**
|
||||||
|
- Galaxy handles zero nodes gracefully
|
||||||
|
- Galaxy handles nodes without coordinates
|
||||||
|
- WebGL not available fallback
|
||||||
|
- Node click on non-existent node handled
|
||||||
|
- Modal close doesn't break navigation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('Galaxy loads and displays nodes', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
// Ensure user has at least 3 nodes with embeddings
|
||||||
|
await agent.check('Galaxy canvas is visible');
|
||||||
|
await agent.check('3D nodes are rendered');
|
||||||
|
await agent.check('Can rotate the galaxy by dragging');
|
||||||
|
await agent.check('Can zoom with scroll wheel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User can click on node to view details', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.check('At least one node is visible');
|
||||||
|
await agent.act('Click on a node sphere');
|
||||||
|
|
||||||
|
await agent.check('Node detail modal opens');
|
||||||
|
await agent.check('Modal shows node title');
|
||||||
|
await agent.check('Modal shows node body');
|
||||||
|
await agent.check('Modal shows Bluesky post link');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Node detail modal navigation works', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.act('Click on a node');
|
||||||
|
await agent.check('Modal is open');
|
||||||
|
|
||||||
|
await agent.act('Click "View Full Detail" button');
|
||||||
|
await agent.check('Navigated to /galaxy/[node-id] page');
|
||||||
|
await agent.check('URL contains node ID');
|
||||||
|
await agent.check('Page shows full node content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Empty galaxy shows helpful message', async (agent) => {
|
||||||
|
// Use a new test user with no nodes
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
await agent.act('Log in with empty account');
|
||||||
|
|
||||||
|
await agent.check('Empty state message is visible');
|
||||||
|
await agent.check('Message says "No thoughts yet"');
|
||||||
|
await agent.check('Button to create first node is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UMAP calculation triggers after 3rd node', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in with empty account');
|
||||||
|
|
||||||
|
// Create 3 nodes
|
||||||
|
await agent.act('Create node 1: "First Thought"');
|
||||||
|
await agent.act('Create node 2: "Second Thought"');
|
||||||
|
await agent.act('Create node 3: "Third Thought"');
|
||||||
|
|
||||||
|
await agent.check('UMAP calculation started automatically');
|
||||||
|
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
await agent.check('All 3 nodes have 3D coordinates');
|
||||||
|
await agent.check('Nodes are positioned in 3D space');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Galaxy handles WebGL unavailable', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
await agent.act('Disable WebGL in browser');
|
||||||
|
|
||||||
|
await agent.check('Fallback message is displayed');
|
||||||
|
await agent.check('Message explains WebGL requirement');
|
||||||
|
await agent.check('Link to enable WebGL provided');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Voice Feature Tests
|
||||||
|
|
||||||
|
#### `tests/magnitude/voice.mag.ts`
|
||||||
|
|
||||||
|
**Happy Paths:**
|
||||||
|
- User can start voice recording
|
||||||
|
- User can stop voice recording
|
||||||
|
- Voice is transcribed to text
|
||||||
|
- Transcribed text is sent to AI
|
||||||
|
- TTS plays AI response
|
||||||
|
- User can toggle TTS on/off
|
||||||
|
|
||||||
|
**Unhappy Paths:**
|
||||||
|
- Microphone permission denied
|
||||||
|
- Deepgram API error
|
||||||
|
- Network error during transcription
|
||||||
|
- TTS fails gracefully
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('User can record and transcribe voice', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
|
||||||
|
await agent.act('Click microphone button');
|
||||||
|
await agent.check('Recording indicator shows');
|
||||||
|
await agent.check('Microphone permission granted');
|
||||||
|
|
||||||
|
await agent.act('Speak "What is artificial intelligence?"');
|
||||||
|
await agent.wait(2000);
|
||||||
|
await agent.act('Click microphone button to stop');
|
||||||
|
|
||||||
|
await agent.check('Recording stops');
|
||||||
|
await agent.check('Text is transcribed');
|
||||||
|
await agent.check('Transcribed text appears in chat input');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Microphone permission denied handled gracefully', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
await agent.act('Deny microphone permission');
|
||||||
|
|
||||||
|
await agent.act('Click microphone button');
|
||||||
|
|
||||||
|
await agent.check('Error notification appears');
|
||||||
|
await agent.check('Error explains permission is needed');
|
||||||
|
await agent.check('Link to browser settings provided');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TTS plays AI response', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Log in if needed');
|
||||||
|
await agent.act('Enable TTS in settings');
|
||||||
|
|
||||||
|
await agent.act('Send message "Hello"');
|
||||||
|
await agent.check('AI responds with text');
|
||||||
|
await agent.check('TTS audio plays automatically');
|
||||||
|
await agent.check('Audio player controls are visible');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Integration Tests
|
||||||
|
|
||||||
|
#### `tests/magnitude/integration.mag.ts`
|
||||||
|
|
||||||
|
**Full user journeys:**
|
||||||
|
- Complete flow: Login → Chat → Create Node → View in Galaxy
|
||||||
|
- Complete flow: Login → Galaxy → View Node → Edit Node
|
||||||
|
- Complete flow: Login → Create 3 Nodes → UMAP → Explore Galaxy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('Complete user journey: Login to Galaxy visualization', async (agent) => {
|
||||||
|
// 1. Login
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.act('Log in with valid credentials');
|
||||||
|
await agent.check('User is authenticated');
|
||||||
|
|
||||||
|
// 2. Chat with AI
|
||||||
|
await agent.act('Navigate to /chat');
|
||||||
|
await agent.act('Send message "Tell me about philosophy"');
|
||||||
|
await agent.check('AI responds');
|
||||||
|
|
||||||
|
// 3. Create node from conversation
|
||||||
|
await agent.act('Click "Create Node" button');
|
||||||
|
await agent.check('Node editor opens with AI draft');
|
||||||
|
await agent.act('Review and edit node content');
|
||||||
|
await agent.act('Click "Publish"');
|
||||||
|
await agent.check('Node is published to Bluesky');
|
||||||
|
await agent.check('Success notification appears');
|
||||||
|
|
||||||
|
// 4. View in galaxy
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
await agent.check('Galaxy canvas loads');
|
||||||
|
await agent.check('New node appears in galaxy');
|
||||||
|
await agent.act('Click on the new node');
|
||||||
|
await agent.check('Node details modal opens');
|
||||||
|
await agent.check('Content matches published node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User creates 3 nodes and explores galaxy', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.act('Log in with empty account');
|
||||||
|
|
||||||
|
// Create 3 nodes
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
await agent.act(`Create node titled "Thought ${i}"`);
|
||||||
|
await agent.check(`Node ${i} published successfully`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check UMAP triggered
|
||||||
|
await agent.check('UMAP calculation started');
|
||||||
|
await agent.wait(5000); // Wait for UMAP to complete
|
||||||
|
|
||||||
|
// Explore galaxy
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
await agent.check('All 3 nodes have coordinates');
|
||||||
|
await agent.check('Nodes are spatially distributed');
|
||||||
|
|
||||||
|
await agent.act('Rotate galaxy to different angle');
|
||||||
|
await agent.act('Zoom in on cluster of nodes');
|
||||||
|
await agent.act('Click on each node to view details');
|
||||||
|
await agent.check('All node details accessible');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Create test directory structure**
|
||||||
|
```bash
|
||||||
|
mkdir -p tests/magnitude
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create test files in order**
|
||||||
|
- `tests/magnitude/auth.mag.ts` - Authentication tests
|
||||||
|
- `tests/magnitude/chat.mag.ts` - Chat interface tests
|
||||||
|
- `tests/magnitude/nodes.mag.ts` - Node creation tests
|
||||||
|
- `tests/magnitude/galaxy.mag.ts` - Galaxy visualization tests
|
||||||
|
- `tests/magnitude/voice.mag.ts` - Voice feature tests
|
||||||
|
- `tests/magnitude/integration.mag.ts` - Full user journeys
|
||||||
|
|
||||||
|
3. **Update package.json**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test": "npx magnitude",
|
||||||
|
"test:watch": "npx magnitude --watch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Create test data helpers**
|
||||||
|
```typescript
|
||||||
|
// tests/magnitude/helpers/data.ts
|
||||||
|
export const createTestNode = (index: number) => ({
|
||||||
|
title: `Test Node ${index}`,
|
||||||
|
body: `This is test content for node ${index}. It discusses various topics.`
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run tests and fix issues**
|
||||||
|
- Run each test file individually first
|
||||||
|
- Fix failing tests
|
||||||
|
- Run full suite
|
||||||
|
- Ensure all tests pass
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ All authentication flows tested (happy + unhappy paths)
|
||||||
|
- ✅ All chat interactions tested (text, voice, AI response)
|
||||||
|
- ✅ All node creation scenarios tested (simple, thread, emojis, links)
|
||||||
|
- ✅ All galaxy visualization features tested (load, click, navigate)
|
||||||
|
- ✅ All voice features tested (record, transcribe, TTS)
|
||||||
|
- ✅ Integration tests cover complete user journeys
|
||||||
|
- ✅ All tests pass reliably
|
||||||
|
- ✅ Test suite runs in CI/CD
|
||||||
|
- ✅ New features cannot be merged without tests
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
1. `tests/magnitude/auth.mag.ts`
|
||||||
|
2. `tests/magnitude/chat.mag.ts`
|
||||||
|
3. `tests/magnitude/nodes.mag.ts`
|
||||||
|
4. `tests/magnitude/galaxy.mag.ts`
|
||||||
|
5. `tests/magnitude/voice.mag.ts`
|
||||||
|
6. `tests/magnitude/integration.mag.ts`
|
||||||
|
7. `tests/magnitude/helpers/data.ts`
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
|
||||||
|
1. `package.json` - Add test scripts
|
||||||
|
2. `.github/workflows/test.yml` - Add CI test workflow (if exists)
|
||||||
382
plans/03-stream-ai-to-deepgram-tts.md
Normal file
382
plans/03-stream-ai-to-deepgram-tts.md
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
# Plan: Stream AI Output to Deepgram for Faster TTS Synthesis
|
||||||
|
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
**Dependencies:** None
|
||||||
|
**Affects:** Voice interaction latency, user experience
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Currently, the app waits for the complete AI response before sending it to Deepgram for TTS. This creates a laggy experience. By streaming the AI output directly to Deepgram as it's generated, we can start playing audio much faster and create a more responsive voice interaction.
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
### Current Flow (SLOW)
|
||||||
|
```
|
||||||
|
User speaks → Deepgram transcribe → Send to AI
|
||||||
|
↓
|
||||||
|
Wait for full response (3-10s)
|
||||||
|
↓
|
||||||
|
Send complete text to Deepgram TTS
|
||||||
|
↓
|
||||||
|
Wait for audio generation (1-3s)
|
||||||
|
↓
|
||||||
|
Play audio
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total latency:** 4-13 seconds before first audio plays
|
||||||
|
|
||||||
|
## Proposed Implementation
|
||||||
|
|
||||||
|
### New Flow (FAST)
|
||||||
|
```
|
||||||
|
User speaks → Deepgram transcribe → Stream to AI
|
||||||
|
↓
|
||||||
|
Stream chunks to Deepgram TTS
|
||||||
|
↓ (chunks arrive)
|
||||||
|
Play audio chunks immediately
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total latency:** 1-2 seconds before first audio plays
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### 1. Modify AI SDK Integration
|
||||||
|
|
||||||
|
Currently using `useChat` from Vercel AI SDK with async completion:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Current (app/api/chat/route.ts)
|
||||||
|
const result = await streamText({
|
||||||
|
model: google('gemini-2.0-flash-exp'),
|
||||||
|
messages,
|
||||||
|
system: systemPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.toDataStreamResponse();
|
||||||
|
```
|
||||||
|
|
||||||
|
Need to add TTS streaming:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// New approach
|
||||||
|
const result = streamText({
|
||||||
|
model: google('gemini-2.0-flash-exp'),
|
||||||
|
messages,
|
||||||
|
system: systemPrompt,
|
||||||
|
async onChunk({ chunk }) {
|
||||||
|
// Stream each chunk to Deepgram TTS
|
||||||
|
if (chunk.type === 'text-delta') {
|
||||||
|
await streamToDeepgram(chunk.textDelta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.toDataStreamResponse();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Deepgram TTS Streaming Service
|
||||||
|
|
||||||
|
#### `lib/deepgram-tts-stream.ts`
|
||||||
|
```typescript
|
||||||
|
import { createClient, LiveClient } from '@deepgram/sdk';
|
||||||
|
|
||||||
|
export class DeepgramTTSStream {
|
||||||
|
private client: LiveClient;
|
||||||
|
private audioQueue: Uint8Array[] = [];
|
||||||
|
private isPlaying = false;
|
||||||
|
|
||||||
|
constructor(apiKey: string) {
|
||||||
|
const deepgram = createClient(apiKey);
|
||||||
|
this.client = deepgram.speak.live({
|
||||||
|
model: 'aura-asteria-en',
|
||||||
|
encoding: 'linear16',
|
||||||
|
sample_rate: 24000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('data', (data: Buffer) => {
|
||||||
|
this.audioQueue.push(new Uint8Array(data));
|
||||||
|
this.playNextChunk();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamText(text: string) {
|
||||||
|
// Send text chunk to Deepgram for synthesis
|
||||||
|
this.client.send(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush() {
|
||||||
|
// Signal end of text stream
|
||||||
|
this.client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async playNextChunk() {
|
||||||
|
if (this.isPlaying || this.audioQueue.length === 0) return;
|
||||||
|
|
||||||
|
this.isPlaying = true;
|
||||||
|
const chunk = this.audioQueue.shift()!;
|
||||||
|
|
||||||
|
// Play audio chunk using Web Audio API
|
||||||
|
await this.playAudioChunk(chunk);
|
||||||
|
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.playNextChunk(); // Play next if available
|
||||||
|
}
|
||||||
|
|
||||||
|
private async playAudioChunk(chunk: Uint8Array) {
|
||||||
|
const audioContext = new AudioContext({ sampleRate: 24000 });
|
||||||
|
const audioBuffer = audioContext.createBuffer(
|
||||||
|
1, // mono
|
||||||
|
chunk.length / 2, // 16-bit samples
|
||||||
|
24000
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelData = audioBuffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < chunk.length / 2; i++) {
|
||||||
|
// Convert 16-bit PCM to float32
|
||||||
|
const sample = (chunk[i * 2] | (chunk[i * 2 + 1] << 8));
|
||||||
|
channelData[i] = sample / 32768.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
source.onended = resolve;
|
||||||
|
source.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Server-Sent Events (SSE) Endpoint for TTS
|
||||||
|
|
||||||
|
#### `app/api/chat-with-tts/route.ts`
|
||||||
|
```typescript
|
||||||
|
import { DeepgramTTSStream } from '@/lib/deepgram-tts-stream';
|
||||||
|
import { streamText } from 'ai';
|
||||||
|
import { google } from '@ai-sdk/google';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { messages } = await request.json();
|
||||||
|
|
||||||
|
// Create a TransformStream for SSE
|
||||||
|
const stream = new TransformStream();
|
||||||
|
const writer = stream.writable.getWriter();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
// Start streaming AI response
|
||||||
|
(async () => {
|
||||||
|
const ttsStream = new DeepgramTTSStream(process.env.DEEPGRAM_API_KEY!);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = streamText({
|
||||||
|
model: google('gemini-2.0-flash-exp'),
|
||||||
|
messages,
|
||||||
|
async onChunk({ chunk }) {
|
||||||
|
if (chunk.type === 'text-delta') {
|
||||||
|
// Send text to client
|
||||||
|
await writer.write(
|
||||||
|
encoder.encode(`data: ${JSON.stringify({ text: chunk.textDelta })}\n\n`)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream to Deepgram TTS
|
||||||
|
await ttsStream.streamText(chunk.textDelta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await result.text; // Wait for completion
|
||||||
|
await ttsStream.flush();
|
||||||
|
|
||||||
|
await writer.write(encoder.encode('data: [DONE]\n\n'));
|
||||||
|
} catch (error) {
|
||||||
|
await writer.write(
|
||||||
|
encoder.encode(`data: ${JSON.stringify({ error: error.message })}\n\n`)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await writer.close();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return new Response(stream.readable, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update Client to Consume SSE with TTS
|
||||||
|
|
||||||
|
#### `components/ChatInterface.tsx`
|
||||||
|
```typescript
|
||||||
|
const [isTTSEnabled, setIsTTSEnabled] = useState(false);
|
||||||
|
const ttsStreamRef = useRef<DeepgramTTSStream | null>(null);
|
||||||
|
|
||||||
|
async function sendMessageWithTTS(message: string) {
|
||||||
|
const response = await fetch('/api/chat-with-tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messages: [...messages, { role: 'user', content: message }] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
// Initialize TTS stream
|
||||||
|
if (isTTSEnabled) {
|
||||||
|
ttsStreamRef.current = new DeepgramTTSStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
if (ttsStreamRef.current) {
|
||||||
|
await ttsStreamRef.current.flush();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.text) {
|
||||||
|
fullText += parsed.text;
|
||||||
|
// Update UI with incremental text
|
||||||
|
setMessages((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
return [...prev.slice(0, -1), { ...last, content: fullText }];
|
||||||
|
}
|
||||||
|
return [...prev, { role: 'assistant', content: fullText }];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream to TTS
|
||||||
|
if (ttsStreamRef.current) {
|
||||||
|
await ttsStreamRef.current.streamText(parsed.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse SSE data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative: Use Deepgram's Native Streaming TTS
|
||||||
|
|
||||||
|
Deepgram has a WebSocket-based streaming TTS API that's even more efficient:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const deepgram = createClient(process.env.DEEPGRAM_API_KEY);
|
||||||
|
|
||||||
|
const connection = deepgram.speak.live({
|
||||||
|
model: 'aura-asteria-en',
|
||||||
|
encoding: 'linear16',
|
||||||
|
sample_rate: 24000,
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('open', () => {
|
||||||
|
console.log('TTS connection established');
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('data', (audioData: Buffer) => {
|
||||||
|
// Play audio chunk immediately
|
||||||
|
playAudioBuffer(audioData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// As AI chunks arrive, send to Deepgram
|
||||||
|
aiStream.on('text-delta', (text) => {
|
||||||
|
connection.send(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When AI completes
|
||||||
|
aiStream.on('finish', () => {
|
||||||
|
connection.close();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Research Deepgram TTS Streaming API**
|
||||||
|
- Review docs: https://developers.deepgram.com/docs/tts-streaming
|
||||||
|
- Test WebSocket connection manually
|
||||||
|
- Understand audio format and buffering
|
||||||
|
|
||||||
|
2. **Create TTS streaming service**
|
||||||
|
- `lib/deepgram-tts-stream.ts`
|
||||||
|
- Implement audio queue and playback
|
||||||
|
- Handle reconnection and errors
|
||||||
|
|
||||||
|
3. **Modify API route for streaming**
|
||||||
|
- Create `/api/chat-with-tts` route
|
||||||
|
- Implement SSE response
|
||||||
|
- Connect AI stream to TTS stream
|
||||||
|
|
||||||
|
4. **Update client components**
|
||||||
|
- Add TTS toggle in UI
|
||||||
|
- Implement SSE consumption
|
||||||
|
- Connect to audio playback
|
||||||
|
|
||||||
|
5. **Test with Playwright MCP**
|
||||||
|
- Enable TTS
|
||||||
|
- Send message
|
||||||
|
- Verify audio starts playing quickly (< 2s)
|
||||||
|
- Verify audio quality
|
||||||
|
- Test error handling (network drop, TTS failure)
|
||||||
|
|
||||||
|
6. **Add Magnitude test**
|
||||||
|
```typescript
|
||||||
|
test('TTS streams audio with low latency', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Enable TTS in settings');
|
||||||
|
await agent.act('Send message "Hello"');
|
||||||
|
|
||||||
|
await agent.check('Audio starts playing within 2 seconds');
|
||||||
|
await agent.check('Audio continues as AI generates response');
|
||||||
|
await agent.check('Audio completes without gaps');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Targets
|
||||||
|
|
||||||
|
- **Time to first audio:** < 2 seconds (vs current 4-13s)
|
||||||
|
- **Perceived latency:** Near real-time streaming
|
||||||
|
- **Audio quality:** No degradation from current implementation
|
||||||
|
- **Reliability:** Graceful fallback if streaming fails
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ TTS audio starts playing within 2 seconds of AI response beginning
|
||||||
|
- ✅ Audio streams continuously as AI generates text
|
||||||
|
- ✅ No perceptible gaps or stuttering in audio playback
|
||||||
|
- ✅ Graceful fallback to batch TTS if streaming fails
|
||||||
|
- ✅ Playwright MCP manual test passes
|
||||||
|
- ✅ Magnitude test passes
|
||||||
|
- ✅ No regression in audio quality
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
1. `lib/deepgram-tts-stream.ts` - TTS streaming service
|
||||||
|
2. `app/api/chat-with-tts/route.ts` - SSE endpoint for TTS
|
||||||
|
3. `tests/playwright/tts-streaming.spec.ts` - Manual Playwright test
|
||||||
|
4. `tests/magnitude/tts-streaming.mag.ts` - Magnitude test
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
|
||||||
|
1. `components/ChatInterface.tsx` - Add TTS streaming consumption
|
||||||
|
2. `app/theme.ts` - Add TTS toggle styling if needed
|
||||||
383
plans/04-fix-galaxy-node-clicking.md
Normal file
383
plans/04-fix-galaxy-node-clicking.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# Plan: Fix Galaxy Node Clicking and Navigation
|
||||||
|
|
||||||
|
**Priority:** HIGH - Critical user experience issue
|
||||||
|
**Dependencies:** None
|
||||||
|
**Affects:** Galaxy visualization navigation, user frustration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
There are two critical bugs with galaxy node interaction:
|
||||||
|
1. When going directly to a node ID link (`/galaxy/node:xxx`), it redirects to `/chat`
|
||||||
|
2. When clicking on a node in `/galaxy` (either general or on a specific node ID URL), the modal closes automatically
|
||||||
|
|
||||||
|
Both issues severely impact the galaxy user experience and need immediate fixing.
|
||||||
|
|
||||||
|
## Current Broken Behavior
|
||||||
|
|
||||||
|
### Issue 1: Direct Node URL Redirects to Chat
|
||||||
|
```
|
||||||
|
User clicks: /galaxy/node:abc123
|
||||||
|
Expected: Shows node detail page with full content
|
||||||
|
Actual: Redirects to /chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 2: Modal Auto-Closes on Node Click
|
||||||
|
```
|
||||||
|
User action: Click node sphere in galaxy visualization
|
||||||
|
Expected: Modal stays open showing node details
|
||||||
|
Actual: Modal opens briefly then closes immediately
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
Let me investigate the current implementation:
|
||||||
|
|
||||||
|
### 1. Check `/app/galaxy/[node-id]/page.tsx`
|
||||||
|
|
||||||
|
Likely issues:
|
||||||
|
- Missing authentication check causing redirect
|
||||||
|
- Incorrect route parameter parsing
|
||||||
|
- Navigation logic in wrong place
|
||||||
|
|
||||||
|
### 2. Check Galaxy Modal Component
|
||||||
|
|
||||||
|
Likely issues:
|
||||||
|
- Event handler conflict (click bubbling to parent)
|
||||||
|
- State management issue (modal state reset)
|
||||||
|
- React Three Fiber event handling bug
|
||||||
|
|
||||||
|
## Proposed Fixes
|
||||||
|
|
||||||
|
### Fix 1: Node Detail Page Route
|
||||||
|
|
||||||
|
#### Current (broken):
|
||||||
|
```typescript
|
||||||
|
// app/galaxy/[node-id]/page.tsx
|
||||||
|
export default async function NodeDetailPage({ params }: { params: { 'node-id': string } }) {
|
||||||
|
// Redirects to /chat if not authenticated?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fixed:
|
||||||
|
```typescript
|
||||||
|
// app/galaxy/[node-id]/page.tsx
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { verifySurrealJwt } from '@/lib/auth/jwt';
|
||||||
|
import { connectToDB } from '@/lib/db';
|
||||||
|
|
||||||
|
export default async function NodeDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { 'node-id': string };
|
||||||
|
}) {
|
||||||
|
// 1. Verify authentication
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
||||||
|
|
||||||
|
if (!surrealJwt) {
|
||||||
|
// Redirect to login, preserving the intended destination
|
||||||
|
redirect(`/login?redirect=/galaxy/${params['node-id']}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSession = verifySurrealJwt(surrealJwt);
|
||||||
|
if (!userSession) {
|
||||||
|
redirect(`/login?redirect=/galaxy/${params['node-id']}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch node data
|
||||||
|
const db = await connectToDB();
|
||||||
|
const nodeId = params['node-id'];
|
||||||
|
|
||||||
|
const nodes = await db.select<NodeData>(nodeId);
|
||||||
|
const node = nodes[0];
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
// Node doesn't exist or user doesn't have access
|
||||||
|
redirect('/galaxy?error=node-not-found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify user owns this node
|
||||||
|
if (node.user_did !== userSession.did) {
|
||||||
|
redirect('/galaxy?error=unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Render node detail page
|
||||||
|
return (
|
||||||
|
<Stack p="xl">
|
||||||
|
<Title order={1}>{node.title}</Title>
|
||||||
|
<Text>{node.body}</Text>
|
||||||
|
{node.atp_uri && (
|
||||||
|
<Anchor href={node.atp_uri} target="_blank">
|
||||||
|
View on Bluesky
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
<Button component={Link} href="/galaxy">
|
||||||
|
Back to Galaxy
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 2: Galaxy Modal Component
|
||||||
|
|
||||||
|
#### Problem: Click event bubbling
|
||||||
|
|
||||||
|
When user clicks node in R3F, the click event might be:
|
||||||
|
- Bubbling to parent canvas
|
||||||
|
- Triggering canvas click handler that closes modal
|
||||||
|
- Or: React state update race condition
|
||||||
|
|
||||||
|
#### Solution: Stop event propagation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/galaxy/NodeSphere.tsx
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { ThreeEvent } from '@react-three/fiber';
|
||||||
|
|
||||||
|
function NodeSphere({ node, onClick }: { node: NodeData; onClick: (node: NodeData) => void }) {
|
||||||
|
const meshRef = useRef();
|
||||||
|
|
||||||
|
const handleClick = (event: ThreeEvent<MouseEvent>) => {
|
||||||
|
// CRITICAL: Stop propagation to prevent canvas click handler
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// Call parent onClick
|
||||||
|
onClick(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
ref={meshRef}
|
||||||
|
position={node.coords_3d}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.1, 16, 16]} />
|
||||||
|
<meshStandardMaterial color="#fff" />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Problem: Modal state management
|
||||||
|
|
||||||
|
Modal might be controlled by both:
|
||||||
|
- Galaxy component state
|
||||||
|
- URL query params
|
||||||
|
- Leading to race condition
|
||||||
|
|
||||||
|
#### Solution: Single source of truth
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/galaxy/page.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function GalaxyPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Modal state controlled ONLY by URL query param
|
||||||
|
const selectedNodeId = searchParams.get('node');
|
||||||
|
|
||||||
|
const handleNodeClick = (nodeId: string) => {
|
||||||
|
// Update URL to open modal
|
||||||
|
router.push(`/galaxy?node=${encodeURIComponent(nodeId)}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
// Remove query param to close modal
|
||||||
|
router.push('/galaxy', { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ThoughtGalaxy
|
||||||
|
nodes={nodes}
|
||||||
|
links={links}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={!!selectedNodeId}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{selectedNodeId && <NodeDetailModal nodeId={selectedNodeId} />}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 3: Canvas Click Handler
|
||||||
|
|
||||||
|
If canvas has a click handler that closes modal, remove it or add condition:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/galaxy/ThoughtGalaxy.tsx
|
||||||
|
<Canvas
|
||||||
|
onClick={(e) => {
|
||||||
|
// Only close modal if clicking on background (not a node)
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onBackgroundClick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ... */}
|
||||||
|
</Canvas>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Manual Playwright MCP Testing
|
||||||
|
|
||||||
|
#### Test 1: Direct Node URL
|
||||||
|
```typescript
|
||||||
|
test('Direct node URL loads correctly', async ({ page }) => {
|
||||||
|
// Create a node first
|
||||||
|
const nodeId = await createTestNode(page);
|
||||||
|
|
||||||
|
// Navigate directly to node detail page
|
||||||
|
await page.goto(`/galaxy/${nodeId}`);
|
||||||
|
|
||||||
|
// Verify we're on the correct page
|
||||||
|
await expect(page).toHaveURL(/\/galaxy\/node:/);
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||||
|
await expect(page.getByText(/View on Bluesky/)).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test 2: Modal Stays Open
|
||||||
|
```typescript
|
||||||
|
test('Galaxy modal stays open when clicking node', async ({ page }) => {
|
||||||
|
await page.goto('/galaxy');
|
||||||
|
|
||||||
|
// Click on a node sphere
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const event = new CustomEvent('node-click', { detail: { nodeId: 'node:123' } });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal should open
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Wait 2 seconds
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Modal should still be open
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test 3: Modal Navigation
|
||||||
|
```typescript
|
||||||
|
test('Can navigate from galaxy to node detail page', async ({ page }) => {
|
||||||
|
await page.goto('/galaxy');
|
||||||
|
|
||||||
|
// Click node to open modal
|
||||||
|
await clickNode(page, 'node:123');
|
||||||
|
|
||||||
|
// Modal opens
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Click "View Full Detail"
|
||||||
|
await page.click('button:has-text("View Full Detail")');
|
||||||
|
|
||||||
|
// Navigate to detail page
|
||||||
|
await expect(page).toHaveURL(/\/galaxy\/node:123/);
|
||||||
|
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Magnitude Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('User can navigate to node via direct URL', async (agent) => {
|
||||||
|
// Create a node first
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Create a test node');
|
||||||
|
const nodeUrl = await agent.getData('node-url');
|
||||||
|
|
||||||
|
// Navigate directly to node URL
|
||||||
|
await agent.open(nodeUrl);
|
||||||
|
await agent.check('Node detail page loads');
|
||||||
|
await agent.check('Node title is visible');
|
||||||
|
await agent.check('Node body is visible');
|
||||||
|
await agent.check('Bluesky link is visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Galaxy modal stays open when clicking nodes', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
await agent.act('Click on a node sphere');
|
||||||
|
|
||||||
|
await agent.check('Modal opens');
|
||||||
|
await agent.wait(2000);
|
||||||
|
await agent.check('Modal is still open');
|
||||||
|
await agent.check('Node details are visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can navigate from galaxy modal to node detail page', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
await agent.act('Click on a node');
|
||||||
|
await agent.check('Modal opens');
|
||||||
|
|
||||||
|
await agent.act('Click "View Full Detail" button');
|
||||||
|
await agent.check('Navigated to node detail page');
|
||||||
|
await agent.check('URL contains node ID');
|
||||||
|
await agent.check('Full node content is visible');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Fix node detail page route**
|
||||||
|
- Add proper authentication check
|
||||||
|
- Add redirect with return URL
|
||||||
|
- Add node ownership verification
|
||||||
|
- Add error handling for missing nodes
|
||||||
|
|
||||||
|
2. **Fix modal event handling**
|
||||||
|
- Add `event.stopPropagation()` to node click handler
|
||||||
|
- Remove or condition any canvas-level click handlers
|
||||||
|
- Ensure modal state is controlled by URL query param
|
||||||
|
|
||||||
|
3. **Test with Playwright MCP**
|
||||||
|
- Test direct URL navigation
|
||||||
|
- Test modal stays open
|
||||||
|
- Test modal to detail page navigation
|
||||||
|
- Fix any issues found
|
||||||
|
|
||||||
|
4. **Add Magnitude tests**
|
||||||
|
- Write tests covering all 3 scenarios
|
||||||
|
- Ensure tests pass
|
||||||
|
|
||||||
|
5. **Deploy and verify in production**
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ Direct node URLs (`/galaxy/node:xxx`) load correctly
|
||||||
|
- ✅ No redirect to `/chat` when accessing node URLs
|
||||||
|
- ✅ Galaxy modal stays open after clicking node
|
||||||
|
- ✅ Can navigate from modal to detail page
|
||||||
|
- ✅ Can navigate from detail page back to galaxy
|
||||||
|
- ✅ All Playwright MCP tests pass
|
||||||
|
- ✅ All Magnitude tests pass
|
||||||
|
- ✅ No console errors during navigation
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
|
||||||
|
1. `app/galaxy/[node-id]/page.tsx` - Fix authentication and routing
|
||||||
|
2. `app/galaxy/page.tsx` - Fix modal state management
|
||||||
|
3. `components/galaxy/ThoughtGalaxy.tsx` - Fix canvas click handling
|
||||||
|
4. `components/galaxy/NodeSphere.tsx` - Fix node click event propagation
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
1. `tests/playwright/galaxy-navigation.spec.ts` - Manual tests
|
||||||
|
2. `tests/magnitude/galaxy-navigation.mag.ts` - Magnitude tests
|
||||||
463
plans/05-dark-light-mode-theme.md
Normal file
463
plans/05-dark-light-mode-theme.md
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
# Plan: Add Dark/Light Mode with Dynamic Favicons
|
||||||
|
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
**Dependencies:** None
|
||||||
|
**Affects:** User experience, accessibility, branding
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add full dark mode / light mode support throughout the app, including:
|
||||||
|
- Dynamic theme switching
|
||||||
|
- Persistent user preference
|
||||||
|
- System preference detection
|
||||||
|
- Mode-specific favicons
|
||||||
|
- Smooth transitions
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- App uses a minimal grayscale theme
|
||||||
|
- No dark mode support
|
||||||
|
- Single favicon for all modes
|
||||||
|
- No theme toggle UI
|
||||||
|
|
||||||
|
## Proposed Implementation
|
||||||
|
|
||||||
|
### 1. Mantine Color Scheme Support
|
||||||
|
|
||||||
|
Mantine has built-in dark mode support. We'll leverage `@mantine/core` ColorSchemeScript and hooks.
|
||||||
|
|
||||||
|
#### Update `app/layout.tsx`
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ColorSchemeScript,
|
||||||
|
MantineProvider,
|
||||||
|
} from '@mantine/core';
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<ColorSchemeScript defaultColorScheme="auto" />
|
||||||
|
{/* Dynamic favicon based on theme */}
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="/favicon-light.svg"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="/favicon-dark.svg"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<MantineProvider defaultColorScheme="auto">
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Update Theme Configuration
|
||||||
|
|
||||||
|
#### `app/theme.ts`
|
||||||
|
```typescript
|
||||||
|
import { createTheme, MantineColorsTuple } from '@mantine/core';
|
||||||
|
|
||||||
|
const gray: MantineColorsTuple = [
|
||||||
|
'#f8f9fa',
|
||||||
|
'#f1f3f5',
|
||||||
|
'#e9ecef',
|
||||||
|
'#dee2e6',
|
||||||
|
'#ced4da',
|
||||||
|
'#adb5bd',
|
||||||
|
'#868e96',
|
||||||
|
'#495057',
|
||||||
|
'#343a40',
|
||||||
|
'#212529',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
primaryColor: 'gray',
|
||||||
|
colors: {
|
||||||
|
gray,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dark mode specific colors
|
||||||
|
black: '#0a0a0a',
|
||||||
|
white: '#ffffff',
|
||||||
|
|
||||||
|
// Component-specific dark mode overrides
|
||||||
|
components: {
|
||||||
|
AppShell: {
|
||||||
|
styles: (theme) => ({
|
||||||
|
main: {
|
||||||
|
backgroundColor: theme.colorScheme === 'dark'
|
||||||
|
? theme.colors.dark[8]
|
||||||
|
: theme.white,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
Paper: {
|
||||||
|
styles: (theme) => ({
|
||||||
|
root: {
|
||||||
|
backgroundColor: theme.colorScheme === 'dark'
|
||||||
|
? theme.colors.dark[7]
|
||||||
|
: theme.white,
|
||||||
|
borderColor: theme.colorScheme === 'dark'
|
||||||
|
? theme.colors.dark[5]
|
||||||
|
: theme.colors.gray[3],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
Modal: {
|
||||||
|
styles: (theme) => ({
|
||||||
|
content: {
|
||||||
|
backgroundColor: theme.colorScheme === 'dark'
|
||||||
|
? theme.colors.dark[7]
|
||||||
|
: theme.white,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: theme.colorScheme === 'dark'
|
||||||
|
? theme.colors.dark[6]
|
||||||
|
: theme.colors.gray[0],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Theme Toggle Component
|
||||||
|
|
||||||
|
#### `components/ThemeToggle.tsx`
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
|
||||||
|
import { IconSun, IconMoon } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
const computedColorScheme = useComputedColorScheme('light');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark')}
|
||||||
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
>
|
||||||
|
{computedColorScheme === 'dark' ? (
|
||||||
|
<IconSun size={20} />
|
||||||
|
) : (
|
||||||
|
<IconMoon size={20} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add Toggle to Desktop Navigation
|
||||||
|
|
||||||
|
#### `components/DesktopNav.tsx`
|
||||||
|
```typescript
|
||||||
|
import { ThemeToggle } from './ThemeToggle';
|
||||||
|
|
||||||
|
export function DesktopNav() {
|
||||||
|
return (
|
||||||
|
<AppShell.Navbar p="md">
|
||||||
|
<Stack justify="space-between" h="100%">
|
||||||
|
<Stack>
|
||||||
|
{/* Navigation items */}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
<ThemeToggle />
|
||||||
|
{/* Profile menu */}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Mode-Specific Favicons
|
||||||
|
|
||||||
|
#### Design Requirements
|
||||||
|
- **Light mode favicon:** Dark icon on transparent background
|
||||||
|
- **Dark mode favicon:** Light icon on transparent background
|
||||||
|
- Both should be SVG for scalability
|
||||||
|
- Simple, recognizable icon representing "thoughts" or "ponderants"
|
||||||
|
|
||||||
|
#### `public/favicon-light.svg`
|
||||||
|
```svg
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<!-- Dark icon for light mode -->
|
||||||
|
<circle cx="50" cy="50" r="40" fill="#212529" />
|
||||||
|
<circle cx="35" cy="45" r="5" fill="#ffffff" />
|
||||||
|
<circle cx="65" cy="45" r="5" fill="#ffffff" />
|
||||||
|
<path d="M 30 65 Q 50 75 70 65" stroke="#ffffff" stroke-width="3" fill="none" />
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `public/favicon-dark.svg`
|
||||||
|
```svg
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<!-- Light icon for dark mode -->
|
||||||
|
<circle cx="50" cy="50" r="40" fill="#f8f9fa" />
|
||||||
|
<circle cx="35" cy="45" r="5" fill="#212529" />
|
||||||
|
<circle cx="65" cy="45" r="5" fill="#212529" />
|
||||||
|
<path d="M 30 65 Q 50 75 70 65" stroke="#212529" stroke-width="3" fill="none" />
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Update Galaxy Visualization for Dark Mode
|
||||||
|
|
||||||
|
The 3D galaxy needs to look good in both modes:
|
||||||
|
|
||||||
|
#### `components/galaxy/ThoughtGalaxy.tsx`
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useComputedColorScheme } from '@mantine/core';
|
||||||
|
|
||||||
|
export function ThoughtGalaxy() {
|
||||||
|
const colorScheme = useComputedColorScheme('light');
|
||||||
|
const isDark = colorScheme === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Canvas
|
||||||
|
style={{
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(to bottom, #0a0a0a, #1a1a1a)'
|
||||||
|
: 'linear-gradient(to bottom, #f8f9fa, #e9ecef)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={isDark ? 0.3 : 0.5} />
|
||||||
|
<pointLight position={[10, 10, 10]} intensity={isDark ? 0.8 : 1} />
|
||||||
|
|
||||||
|
{/* Node spheres - adjust color based on theme */}
|
||||||
|
<NodeSphere color={isDark ? '#f8f9fa' : '#212529'} />
|
||||||
|
|
||||||
|
{/* Link lines */}
|
||||||
|
<LinkLine color={isDark ? 'rgba(248, 249, 250, 0.3)' : 'rgba(33, 37, 41, 0.3)'} />
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Persist User Preference
|
||||||
|
|
||||||
|
Mantine automatically stores the preference in localStorage with the key `mantine-color-scheme-value`.
|
||||||
|
|
||||||
|
For server-side rendering, we can add a cookie fallback:
|
||||||
|
|
||||||
|
#### `middleware.ts`
|
||||||
|
```typescript
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
|
||||||
|
// Set color scheme cookie if not present
|
||||||
|
if (!request.cookies.has('mantine-color-scheme')) {
|
||||||
|
response.cookies.set('mantine-color-scheme', 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Manual Playwright MCP Testing
|
||||||
|
|
||||||
|
#### Test 1: Theme Toggle
|
||||||
|
```typescript
|
||||||
|
test('User can toggle between light and dark mode', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Check initial theme
|
||||||
|
const initialTheme = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click theme toggle
|
||||||
|
await page.click('[aria-label="Toggle color scheme"]');
|
||||||
|
|
||||||
|
// Check theme changed
|
||||||
|
const newTheme = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(newTheme).not.toBe(initialTheme);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test 2: Theme Persistence
|
||||||
|
```typescript
|
||||||
|
test('Theme preference persists across page refreshes', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Set to dark mode
|
||||||
|
await page.click('[aria-label="Toggle color scheme"]');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
const darkTheme = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh page
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Check theme persisted
|
||||||
|
const persistedTheme = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(persistedTheme).toBe(darkTheme);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Test 3: Favicon Changes
|
||||||
|
```typescript
|
||||||
|
test('Favicon changes with theme', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Get favicon in light mode
|
||||||
|
const lightFavicon = await page.evaluate(() => {
|
||||||
|
const link = document.querySelector('link[rel="icon"]');
|
||||||
|
return link?.getAttribute('href');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to dark mode
|
||||||
|
await page.click('[aria-label="Toggle color scheme"]');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
|
||||||
|
// Get favicon in dark mode
|
||||||
|
const darkFavicon = await page.evaluate(() => {
|
||||||
|
const link = document.querySelector('link[rel="icon"]');
|
||||||
|
return link?.getAttribute('href');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lightFavicon).toContain('light');
|
||||||
|
expect(darkFavicon).toContain('dark');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Magnitude Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('User can switch between light and dark mode', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.check('Page loads in default theme');
|
||||||
|
|
||||||
|
await agent.act('Click theme toggle button');
|
||||||
|
await agent.check('Theme switches to opposite mode');
|
||||||
|
await agent.check('All UI elements are visible in new theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dark mode looks good throughout app', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.act('Switch to dark mode');
|
||||||
|
|
||||||
|
await agent.act('Navigate to /chat');
|
||||||
|
await agent.check('Chat interface is readable in dark mode');
|
||||||
|
|
||||||
|
await agent.act('Navigate to /galaxy');
|
||||||
|
await agent.check('Galaxy visualization looks good in dark mode');
|
||||||
|
|
||||||
|
await agent.act('Navigate to /edit');
|
||||||
|
await agent.check('Editor is readable in dark mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Theme preference persists', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000');
|
||||||
|
await agent.act('Switch to dark mode');
|
||||||
|
await agent.check('App is in dark mode');
|
||||||
|
|
||||||
|
await agent.act('Refresh page');
|
||||||
|
await agent.check('App remains in dark mode');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Update Mantine provider and layout**
|
||||||
|
- Add ColorSchemeScript to head
|
||||||
|
- Add dynamic favicon links
|
||||||
|
- Update MantineProvider with defaultColorScheme
|
||||||
|
|
||||||
|
2. **Update theme configuration**
|
||||||
|
- Add dark mode color overrides
|
||||||
|
- Update component styles for both modes
|
||||||
|
|
||||||
|
3. **Create ThemeToggle component**
|
||||||
|
- Add sun/moon icon toggle
|
||||||
|
- Wire up to Mantine color scheme hooks
|
||||||
|
|
||||||
|
4. **Add toggle to navigation**
|
||||||
|
- Add to DesktopNav
|
||||||
|
- Add to mobile nav if applicable
|
||||||
|
|
||||||
|
5. **Create favicons**
|
||||||
|
- Design light mode favicon (dark icon)
|
||||||
|
- Design dark mode favicon (light icon)
|
||||||
|
- Export as SVG
|
||||||
|
- Add to public folder
|
||||||
|
|
||||||
|
6. **Update galaxy visualization**
|
||||||
|
- Add theme-aware colors
|
||||||
|
- Test in both modes
|
||||||
|
|
||||||
|
7. **Test with Playwright MCP**
|
||||||
|
- Test toggle functionality
|
||||||
|
- Test persistence
|
||||||
|
- Test favicon changes
|
||||||
|
- Test all pages in both modes
|
||||||
|
|
||||||
|
8. **Add Magnitude tests**
|
||||||
|
- Write comprehensive theme tests
|
||||||
|
- Ensure all pass
|
||||||
|
|
||||||
|
9. **Commit and push (NO DEPLOY)**
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ User can toggle between light and dark mode
|
||||||
|
- ✅ Theme preference persists across sessions
|
||||||
|
- ✅ Favicon changes based on active theme
|
||||||
|
- ✅ All UI elements are readable in both modes
|
||||||
|
- ✅ Galaxy visualization looks good in both modes
|
||||||
|
- ✅ System preference is detected and respected
|
||||||
|
- ✅ Smooth transitions between modes
|
||||||
|
- ✅ All Playwright MCP tests pass
|
||||||
|
- ✅ All Magnitude tests pass
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
1. `components/ThemeToggle.tsx` - Toggle component
|
||||||
|
2. `public/favicon-light.svg` - Light mode favicon
|
||||||
|
3. `public/favicon-dark.svg` - Dark mode favicon
|
||||||
|
4. `tests/playwright/theme.spec.ts` - Manual tests
|
||||||
|
5. `tests/magnitude/theme.mag.ts` - Magnitude tests
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
|
||||||
|
1. `app/layout.tsx` - Add ColorSchemeScript and dynamic favicons
|
||||||
|
2. `app/theme.ts` - Add dark mode color overrides
|
||||||
|
3. `components/DesktopNav.tsx` - Add ThemeToggle
|
||||||
|
4. `components/galaxy/ThoughtGalaxy.tsx` - Add theme-aware colors
|
||||||
171
plans/06-fix-double-border-desktop.md
Normal file
171
plans/06-fix-double-border-desktop.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Plan: Fix Double Border on Desktop Between Sidebar and Conversation
|
||||||
|
|
||||||
|
**Priority:** LOW - Visual polish
|
||||||
|
**Dependencies:** None
|
||||||
|
**Affects:** Desktop UI aesthetics
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
There's a double border appearing on desktop between the sidebar and the main conversation area. This creates a visual inconsistency and needs to be fixed for a polished look.
|
||||||
|
|
||||||
|
## Current Issue
|
||||||
|
|
||||||
|
```
|
||||||
|
+----------+||+-------------+
|
||||||
|
| Sidebar ||| Main |
|
||||||
|
| ||| Content |
|
||||||
|
| ||| |
|
||||||
|
+----------+||+-------------+
|
||||||
|
^^
|
||||||
|
Double border
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
1. Both AppShell.Navbar and AppShell.Main have borders
|
||||||
|
2. Border-box sizing causing borders to stack
|
||||||
|
3. Mantine default styles adding extra borders
|
||||||
|
|
||||||
|
## Investigation Steps
|
||||||
|
|
||||||
|
1. **Inspect current implementation**
|
||||||
|
```typescript
|
||||||
|
// Check components/DesktopNav.tsx
|
||||||
|
// Check app/layout.tsx AppShell usage
|
||||||
|
// Check theme.ts AppShell styles
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Identify which elements have borders**
|
||||||
|
- AppShell.Navbar
|
||||||
|
- AppShell.Main
|
||||||
|
- Any wrapper components
|
||||||
|
|
||||||
|
## Proposed Fixes
|
||||||
|
|
||||||
|
### Option 1: Remove Border from One Side
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/theme.ts
|
||||||
|
export const theme = createTheme({
|
||||||
|
components: {
|
||||||
|
AppShell: {
|
||||||
|
styles: {
|
||||||
|
navbar: {
|
||||||
|
borderRight: 'none', // Remove right border from navbar
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Use Single Shared Border
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/theme.ts
|
||||||
|
export const theme = createTheme({
|
||||||
|
components: {
|
||||||
|
AppShell: {
|
||||||
|
styles: (theme) => ({
|
||||||
|
navbar: {
|
||||||
|
borderRight: `1px solid ${theme.colors.gray[3]}`,
|
||||||
|
},
|
||||||
|
main: {
|
||||||
|
borderLeft: 'none', // Remove left border from main
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Use Divider Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// components/DesktopNav.tsx
|
||||||
|
<Group h="100%" gap={0} wrap="nowrap">
|
||||||
|
<AppShell.Navbar>{/* ... */}</AppShell.Navbar>
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
<AppShell.Main>{/* ... */}</AppShell.Main>
|
||||||
|
</Group>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Playwright MCP Test
|
||||||
|
```typescript
|
||||||
|
test('No double border between sidebar and main content', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||||
|
|
||||||
|
// Take screenshot of border area
|
||||||
|
const borderElement = page.locator('.mantine-AppShell-navbar');
|
||||||
|
await borderElement.screenshot({ path: 'border-before.png' });
|
||||||
|
|
||||||
|
// Check computed styles
|
||||||
|
const navbarBorder = await page.evaluate(() => {
|
||||||
|
const navbar = document.querySelector('.mantine-AppShell-navbar');
|
||||||
|
return window.getComputedStyle(navbar!).borderRight;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainBorder = await page.evaluate(() => {
|
||||||
|
const main = document.querySelector('.mantine-AppShell-main');
|
||||||
|
return window.getComputedStyle(main!).borderLeft;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only one should have a border
|
||||||
|
const hasDouble = navbarBorder !== 'none' && mainBorder !== 'none';
|
||||||
|
expect(hasDouble).toBe(false);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Magnitude Test
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('Desktop sidebar has clean border', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
await agent.act('Resize window to desktop size (1920x1080)');
|
||||||
|
await agent.check('Sidebar is visible');
|
||||||
|
await agent.check('No double border between sidebar and content');
|
||||||
|
await agent.check('Border is clean and single-width');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Identify current border setup**
|
||||||
|
- Inspect DesktopNav component
|
||||||
|
- Check AppShell configuration
|
||||||
|
- Check theme.ts
|
||||||
|
|
||||||
|
2. **Choose fix approach**
|
||||||
|
- Decide which element keeps the border
|
||||||
|
- Remove border from other element
|
||||||
|
|
||||||
|
3. **Implement fix**
|
||||||
|
- Update theme.ts or component styles
|
||||||
|
|
||||||
|
4. **Test with Playwright MCP**
|
||||||
|
- Visual inspection
|
||||||
|
- Computed styles check
|
||||||
|
|
||||||
|
5. **Add Magnitude test**
|
||||||
|
|
||||||
|
6. **Commit and push**
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ Only one border between sidebar and main content
|
||||||
|
- ✅ Border is clean and single-width
|
||||||
|
- ✅ Consistent across all pages
|
||||||
|
- ✅ Works in both light and dark mode
|
||||||
|
- ✅ Playwright MCP test passes
|
||||||
|
- ✅ Magnitude test passes
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
|
||||||
|
1. `app/theme.ts` - Update AppShell border styles
|
||||||
|
2. `tests/playwright/desktop-border.spec.ts` - Manual test
|
||||||
|
3. `tests/magnitude/desktop-border.mag.ts` - Magnitude test
|
||||||
78
plans/07-delete-backup-files.md
Normal file
78
plans/07-delete-backup-files.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Plan: Delete Backup/Old Page Files
|
||||||
|
|
||||||
|
**Priority:** LOW - Code cleanup
|
||||||
|
**Dependencies:** None
|
||||||
|
**Affects:** Code maintenance, repository cleanliness
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Remove all backup and old page.tsx files that are no longer needed. These files clutter the codebase and can cause confusion.
|
||||||
|
|
||||||
|
## Files to Delete
|
||||||
|
|
||||||
|
Need to search for:
|
||||||
|
- `*.backup.*` files
|
||||||
|
- `*.old.*` files
|
||||||
|
- `*-old.*` files
|
||||||
|
- Files with "backup" in the name
|
||||||
|
- Commented-out old implementations
|
||||||
|
|
||||||
|
## Search Strategy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find all backup files
|
||||||
|
find . -name "*.backup.*" -o -name "*.old.*" -o -name "*-old.*"
|
||||||
|
|
||||||
|
# Find files with "backup" in name
|
||||||
|
find . -iname "*backup*"
|
||||||
|
|
||||||
|
# Check git status for renamed files
|
||||||
|
git status | grep -i backup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Search for backup files**
|
||||||
|
```bash
|
||||||
|
find app -name "*.backup*" -o -name "*.old*"
|
||||||
|
find components -name "*.backup*" -o -name "*.old*"
|
||||||
|
find lib -name "*.backup*" -o -name "*.old*"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify files are not imported anywhere**
|
||||||
|
```bash
|
||||||
|
# For each found file, check if it's imported
|
||||||
|
grep -r "import.*from.*filename" .
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Delete files**
|
||||||
|
```bash
|
||||||
|
git rm app/chat/page.tsx.backup
|
||||||
|
# ... repeat for each file
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check for commented-out code**
|
||||||
|
- Review recent commits for large commented blocks
|
||||||
|
- Remove if no longer needed
|
||||||
|
|
||||||
|
5. **Commit deletion**
|
||||||
|
```bash
|
||||||
|
git commit -m "chore: Remove backup and old files"
|
||||||
|
git push origin development
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ No `.backup` files in codebase
|
||||||
|
- ✅ No `.old` files in codebase
|
||||||
|
- ✅ No files with "backup" in name
|
||||||
|
- ✅ Code still builds successfully
|
||||||
|
- ✅ All tests still pass
|
||||||
|
|
||||||
|
## Files to Delete
|
||||||
|
|
||||||
|
(Will be determined during search step)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
1. `app/chat/page.tsx.backup`
|
||||||
|
2. Any other discovered backup files
|
||||||
290
plans/08-ai-transition-to-edit.md
Normal file
290
plans/08-ai-transition-to-edit.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# Plan: Allow AI to Transition to Edit Mode in Chat
|
||||||
|
|
||||||
|
**Priority:** MEDIUM
|
||||||
|
**Dependencies:** None
|
||||||
|
**Affects:** User experience, conversation flow
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Currently, users must manually click "Create Node" to transition from chat to the node editor. We should allow the AI to intelligently suggest and trigger this transition when appropriate during conversation.
|
||||||
|
|
||||||
|
## Current Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User: "I want to write about quantum computing"
|
||||||
|
AI: "That's interesting! Tell me more..."
|
||||||
|
[User continues conversation]
|
||||||
|
User: *clicks "Create Node" manually*
|
||||||
|
→ Editor opens with AI-generated draft
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proposed Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User: "I want to write about quantum computing"
|
||||||
|
AI: "That's interesting! Tell me more..."
|
||||||
|
[Conversation develops]
|
||||||
|
AI: "It sounds like you have a solid idea forming. Would you like me to help you draft a node about this?"
|
||||||
|
User: "Yes" / Clicks button
|
||||||
|
AI: *automatically transitions to edit mode*
|
||||||
|
→ Editor opens with AI-generated draft
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Approach
|
||||||
|
|
||||||
|
### 1. Add Tool Use to AI System Prompt
|
||||||
|
|
||||||
|
Update the AI system prompt to include a `createNode` tool:
|
||||||
|
|
||||||
|
#### `app/api/chat/route.ts`
|
||||||
|
```typescript
|
||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const tools = {
|
||||||
|
createNode: tool({
|
||||||
|
description: 'Create a thought node when the user has developed a coherent idea worth capturing. Use this when the conversation has explored a topic deeply enough to write about it.',
|
||||||
|
parameters: z.object({
|
||||||
|
reason: z.string().describe('Why you think this conversation is ready to become a node'),
|
||||||
|
}),
|
||||||
|
execute: async ({ reason }) => {
|
||||||
|
return { shouldTransition: true, reason };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { messages } = await request.json();
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: google('gemini-2.0-flash-exp'),
|
||||||
|
messages,
|
||||||
|
system: `You are a thoughtful AI assistant helping users explore and capture their ideas...
|
||||||
|
|
||||||
|
When a conversation reaches a natural conclusion or the user has developed a coherent thought, you can suggest creating a node to capture it. Use the createNode tool when:
|
||||||
|
- The conversation has explored a specific topic in depth
|
||||||
|
- The user has articulated a complete idea or insight
|
||||||
|
- There's enough substance for a meaningful blog post
|
||||||
|
- The user expresses readiness to capture their thoughts
|
||||||
|
|
||||||
|
Be conversational and natural when suggesting this - don't force it if the conversation is still developing.`,
|
||||||
|
tools,
|
||||||
|
maxSteps: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.toDataStreamResponse();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Handle Tool Calls in Client
|
||||||
|
|
||||||
|
Update the chat interface to handle the createNode tool call:
|
||||||
|
|
||||||
|
#### `components/ChatInterface.tsx`
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useChat } from 'ai/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function ChatInterface() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
||||||
|
api: '/api/chat',
|
||||||
|
onToolCall: async ({ toolCall }) => {
|
||||||
|
if (toolCall.toolName === 'createNode') {
|
||||||
|
// Show confirmation UI
|
||||||
|
setShowNodeCreationPrompt(true);
|
||||||
|
setNodeCreationReason(toolCall.args.reason);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showNodeCreationPrompt, setShowNodeCreationPrompt] = useState(false);
|
||||||
|
const [nodeCreationReason, setNodeCreationReason] = useState('');
|
||||||
|
|
||||||
|
const handleAcceptNodeCreation = async () => {
|
||||||
|
// Generate node draft
|
||||||
|
const response = await fetch('/api/generate-node-draft', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversation: messages,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { title, body } = await response.json();
|
||||||
|
|
||||||
|
// Transition to edit mode
|
||||||
|
router.push(`/edit?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack h="100%">
|
||||||
|
{/* Chat messages */}
|
||||||
|
|
||||||
|
{/* Node creation prompt */}
|
||||||
|
{showNodeCreationPrompt && (
|
||||||
|
<Paper p="md" withBorder>
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">{nodeCreationReason}</Text>
|
||||||
|
<Group>
|
||||||
|
<Button onClick={handleAcceptNodeCreation}>
|
||||||
|
Create Node
|
||||||
|
</Button>
|
||||||
|
<Button variant="subtle" onClick={() => setShowNodeCreationPrompt(false)}>
|
||||||
|
Continue Conversation
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Alternative: Button-Based Approach (Simpler)
|
||||||
|
|
||||||
|
Instead of using tool calls, simply add a button that appears contextually:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function ChatInterface() {
|
||||||
|
const { messages } = useChat();
|
||||||
|
const [showCreateButton, setShowCreateButton] = useState(false);
|
||||||
|
|
||||||
|
// Show "Create Node" button after 5+ messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length >= 5) {
|
||||||
|
setShowCreateButton(true);
|
||||||
|
}
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{/* Messages */}
|
||||||
|
|
||||||
|
{showCreateButton && (
|
||||||
|
<Paper p="md" withBorder bg="gray.0">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text fw={500}>Ready to capture this idea?</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
You've explored this topic well. Would you like to create a node?
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button onClick={handleCreateNode}>
|
||||||
|
Create Node
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Manual Playwright MCP Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test('AI suggests creating node after deep conversation', async ({ page }) => {
|
||||||
|
await page.goto('/chat');
|
||||||
|
|
||||||
|
// Have a conversation
|
||||||
|
await sendMessage(page, 'I want to write about philosophy');
|
||||||
|
await waitForAIResponse(page);
|
||||||
|
|
||||||
|
await sendMessage(page, 'Specifically about existentialism');
|
||||||
|
await waitForAIResponse(page);
|
||||||
|
|
||||||
|
await sendMessage(page, 'And how it relates to modern life');
|
||||||
|
await waitForAIResponse(page);
|
||||||
|
|
||||||
|
// Check if AI suggests creating node
|
||||||
|
await expect(page.getByText(/create a node/i)).toBeVisible({ timeout: 30000 });
|
||||||
|
|
||||||
|
// Click accept
|
||||||
|
await page.click('button:has-text("Create Node")');
|
||||||
|
|
||||||
|
// Should navigate to editor
|
||||||
|
await expect(page).toHaveURL(/\/edit/);
|
||||||
|
await expect(page.getByRole('textbox', { name: /title/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Magnitude Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test } from 'magnitude-test';
|
||||||
|
|
||||||
|
test('AI intelligently suggests creating node', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
|
||||||
|
await agent.act('Have a conversation about quantum computing');
|
||||||
|
await agent.act('Discuss multiple aspects over 5+ messages');
|
||||||
|
|
||||||
|
await agent.check('AI suggests creating a node');
|
||||||
|
await agent.check('Suggestion includes reasoning');
|
||||||
|
|
||||||
|
await agent.act('Click "Create Node" button');
|
||||||
|
await agent.check('Transitions to edit mode');
|
||||||
|
await agent.check('Editor has AI-generated draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('User can decline node creation and continue chatting', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/chat');
|
||||||
|
|
||||||
|
await agent.act('Have a conversation that triggers node suggestion');
|
||||||
|
await agent.check('AI suggests creating a node');
|
||||||
|
|
||||||
|
await agent.act('Click "Continue Conversation"');
|
||||||
|
await agent.check('Suggestion dismisses');
|
||||||
|
await agent.check('Can continue chatting');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Choose approach**
|
||||||
|
- Tool-based (more intelligent, AI decides)
|
||||||
|
- Button-based (simpler, always available after N messages)
|
||||||
|
- Hybrid (both)
|
||||||
|
|
||||||
|
2. **Implement AI tool/prompt**
|
||||||
|
- Add createNode tool definition
|
||||||
|
- Update system prompt
|
||||||
|
- Handle tool calls
|
||||||
|
|
||||||
|
3. **Update ChatInterface**
|
||||||
|
- Add suggestion UI
|
||||||
|
- Handle accept/decline
|
||||||
|
- Transition to edit mode
|
||||||
|
|
||||||
|
4. **Test with Playwright MCP**
|
||||||
|
- Test suggestion appears
|
||||||
|
- Test accept flow
|
||||||
|
- Test decline flow
|
||||||
|
|
||||||
|
5. **Add Magnitude tests**
|
||||||
|
|
||||||
|
6. **Commit and push**
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ AI suggests node creation at appropriate times
|
||||||
|
- ✅ User can accept and transition to edit mode
|
||||||
|
- ✅ User can decline and continue conversation
|
||||||
|
- ✅ Transition is smooth and intuitive
|
||||||
|
- ✅ Works well in conversation flow
|
||||||
|
- ✅ Playwright MCP tests pass
|
||||||
|
- ✅ Magnitude tests pass
|
||||||
|
|
||||||
|
## Files to Update
|
||||||
|
|
||||||
|
1. `app/api/chat/route.ts` - Add tool or enhanced prompt
|
||||||
|
2. `components/ChatInterface.tsx` - Add suggestion UI and handling
|
||||||
|
3. `tests/playwright/ai-to-edit.spec.ts` - Manual tests
|
||||||
|
4. `tests/magnitude/ai-to-edit.mag.ts` - Magnitude tests
|
||||||
254
plans/09-umap-minimum-nodes-analysis.md
Normal file
254
plans/09-umap-minimum-nodes-analysis.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# Plan: Analysis - Why Wait for Three Nodes Before UMAP?
|
||||||
|
|
||||||
|
**Priority:** LOW - Analysis and potential optimization
|
||||||
|
**Dependencies:** None
|
||||||
|
**Affects:** User experience for early galaxy usage
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Why do we wait until the user has created 3 nodes before running UMAP to calculate 3D coordinates? Is this an arbitrary choice or is there a technical reason?
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/nodes/route.ts (lines 277-305)
|
||||||
|
const totalNodes = countResult[0]?.[0]?.total || 0;
|
||||||
|
|
||||||
|
if (totalNodes >= 3) {
|
||||||
|
// Trigger UMAP calculation
|
||||||
|
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/calculate-graph`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Cookie': cookieHeader },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UMAP Technical Requirements
|
||||||
|
|
||||||
|
### Minimum Data Points
|
||||||
|
|
||||||
|
UMAP (Uniform Manifold Approximation and Projection) is a dimensionality reduction algorithm. Let's investigate its minimum requirements:
|
||||||
|
|
||||||
|
1. **Mathematical minimum**: UMAP needs at least `nNeighbors + 1` data points
|
||||||
|
- Our config: `nNeighbors: Math.min(15, nodes.length - 1)`
|
||||||
|
- So technically we need minimum 2 points
|
||||||
|
|
||||||
|
2. **Practical minimum**: With 1-2 points, the projection is trivial:
|
||||||
|
- 1 point: Just sits at origin [0, 0, 0]
|
||||||
|
- 2 points: Linear projection on single axis
|
||||||
|
- 3+ points: Meaningful 3D spatial distribution
|
||||||
|
|
||||||
|
3. **Meaningful visualization**: For interesting galaxy visualization:
|
||||||
|
- 1 node: Just a single sphere (boring)
|
||||||
|
- 2 nodes: Two spheres in a line (boring)
|
||||||
|
- 3 nodes: Triangle configuration (starting to be interesting)
|
||||||
|
- 4+ nodes: Complex 3D structure (compelling)
|
||||||
|
|
||||||
|
## Options to Consider
|
||||||
|
|
||||||
|
### Option 1: Keep Current (3-node minimum) ✅ RECOMMENDED
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Ensures meaningful visualization
|
||||||
|
- UMAP produces better results with more data
|
||||||
|
- Avoids wasted computation on trivial cases
|
||||||
|
- User has enough content to make galaxy worth exploring
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- User can't see galaxy until 3rd node
|
||||||
|
- Feels like arbitrary limitation
|
||||||
|
|
||||||
|
### Option 2: Allow 1+ nodes (Calculate always)
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Galaxy available immediately
|
||||||
|
- No waiting for 3rd node
|
||||||
|
- Simpler logic
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- 1-2 nodes produce boring visualization (single point, line)
|
||||||
|
- Wasted UMAP computation on trivial cases
|
||||||
|
- Poor user experience showing "empty" galaxy
|
||||||
|
|
||||||
|
### Option 3: Fallback Layout for 1-2 Nodes
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Galaxy available immediately
|
||||||
|
- 1-2 nodes get simple predetermined positions
|
||||||
|
- UMAP kicks in at 3+ for interesting layout
|
||||||
|
- Best of both worlds
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- More complex implementation
|
||||||
|
- Potential confusion when layout suddenly changes at 3rd node
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function calculateNodePositions(nodes: NodeData[]): NodeData[] {
|
||||||
|
if (nodes.length === 1) {
|
||||||
|
// Single node at origin
|
||||||
|
return [{
|
||||||
|
...nodes[0],
|
||||||
|
coords_3d: [0, 0, 0],
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 2) {
|
||||||
|
// Two nodes on X axis
|
||||||
|
return [
|
||||||
|
{ ...nodes[0], coords_3d: [-1, 0, 0] },
|
||||||
|
{ ...nodes[1], coords_3d: [1, 0, 0] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3+ nodes: Use UMAP
|
||||||
|
return runUMAP(nodes);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 4: Show Empty State with Onboarding
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Clear communication about galaxy feature
|
||||||
|
- Educational for new users
|
||||||
|
- No computation wasted
|
||||||
|
- Encourages node creation
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- More UI work
|
||||||
|
- Doesn't solve the "when to calculate" question
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/galaxy/page.tsx
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return <EmptyState message="Create your first node to start your galaxy!" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length < 3) {
|
||||||
|
return <PartialState message={`Create ${3 - nodes.length} more nodes to see your galaxy visualization!`} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**Keep the 3-node minimum** for the following reasons:
|
||||||
|
|
||||||
|
1. **User Experience**
|
||||||
|
- 1-2 nodes produce boring visualizations that don't showcase the galaxy feature
|
||||||
|
- Better to show compelling visualization from the start
|
||||||
|
- Empty state can explain "create 3 nodes to unlock galaxy"
|
||||||
|
|
||||||
|
2. **Technical Quality**
|
||||||
|
- UMAP produces better results with more data points
|
||||||
|
- 3 points is mathematical minimum for interesting 3D distribution
|
||||||
|
- Avoids wasted computation on trivial cases
|
||||||
|
|
||||||
|
3. **Product Story**
|
||||||
|
- Forces users to create meaningful content before "unlocking" visualization
|
||||||
|
- Makes galaxy feel like a reward for engagement
|
||||||
|
- Aligns with the product vision of "network of thoughts"
|
||||||
|
|
||||||
|
## Potential Enhancements
|
||||||
|
|
||||||
|
### 1. Better Onboarding
|
||||||
|
```typescript
|
||||||
|
// Show progress toward galaxy unlock
|
||||||
|
<Progress value={(nodes.length / 3) * 100} label={`${nodes.length}/3 nodes created`} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Preview Mode
|
||||||
|
```typescript
|
||||||
|
// Show static preview of galaxy with 1-2 nodes
|
||||||
|
<GalaxyPreview nodes={nodes} message="Create 1 more node to unlock 3D visualization!" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configurable Threshold
|
||||||
|
```typescript
|
||||||
|
// Allow power users to adjust in settings
|
||||||
|
const UMAP_MINIMUM_NODES = userSettings.umapMinimum || 3;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation (If Changing)
|
||||||
|
|
||||||
|
If we decide to implement Option 3 (fallback layout):
|
||||||
|
|
||||||
|
1. **Update calculate-graph logic**
|
||||||
|
```typescript
|
||||||
|
if (nodes.length < 3) {
|
||||||
|
return simpleLayout(nodes);
|
||||||
|
}
|
||||||
|
return umapLayout(nodes);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add simple layout function**
|
||||||
|
```typescript
|
||||||
|
function simpleLayout(nodes: NodeData[]): NodeData[] {
|
||||||
|
// Predetermined positions for 1-2 nodes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update API response**
|
||||||
|
```typescript
|
||||||
|
return NextResponse.json({
|
||||||
|
nodes_mapped: nodes.length,
|
||||||
|
layout_method: nodes.length < 3 ? 'simple' : 'umap',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
If implementing changes:
|
||||||
|
|
||||||
|
### Playwright MCP Test
|
||||||
|
```typescript
|
||||||
|
test('Galaxy works with 1-2 nodes', async ({ page }) => {
|
||||||
|
// Create 1 node
|
||||||
|
await createNode(page, 'First Node');
|
||||||
|
await page.goto('/galaxy');
|
||||||
|
await expect(page.locator('canvas')).toBeVisible();
|
||||||
|
|
||||||
|
// Create 2nd node
|
||||||
|
await createNode(page, 'Second Node');
|
||||||
|
await page.goto('/galaxy');
|
||||||
|
await expect(page.locator('canvas')).toBeVisible();
|
||||||
|
|
||||||
|
// Should see 2 nodes
|
||||||
|
const nodeCount = await getNodeCount(page);
|
||||||
|
expect(nodeCount).toBe(2);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Magnitude Test
|
||||||
|
```typescript
|
||||||
|
test('User understands galaxy requirement', async (agent) => {
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
|
||||||
|
// With 0 nodes
|
||||||
|
await agent.check('Sees message about creating nodes');
|
||||||
|
await agent.check('Message says "3 nodes" or similar');
|
||||||
|
|
||||||
|
// After creating 1 node
|
||||||
|
await agent.act('Create first node');
|
||||||
|
await agent.open('http://localhost:3000/galaxy');
|
||||||
|
await agent.check('Sees progress toward 3 nodes');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- ✅ Clear documentation of why 3-node minimum exists
|
||||||
|
- ✅ User understands requirement through UI/messaging
|
||||||
|
- ✅ If changed: Works correctly with 1-2 nodes
|
||||||
|
- ✅ If changed: Smooth transition to UMAP at 3+ nodes
|
||||||
|
- ✅ Tests pass
|
||||||
|
|
||||||
|
## Files to Update (if implementing changes)
|
||||||
|
|
||||||
|
1. `app/api/calculate-graph/route.ts` - Add fallback layout logic
|
||||||
|
2. `app/galaxy/page.tsx` - Add better onboarding messaging
|
||||||
|
3. `docs/architecture.md` - Document decision (create if needed)
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
1. `docs/decisions/umap-minimum-nodes.md` - Document the decision
|
||||||
|
2. `tests/playwright/galaxy-onboarding.spec.ts` - If implementing changes
|
||||||
|
3. `tests/magnitude/galaxy-onboarding.mag.ts` - If implementing changes
|
||||||
Reference in New Issue
Block a user